1<?php
2
3use dokuwiki\Extension\AuthPlugin;
4use dokuwiki\PassHash;
5use dokuwiki\Utf8\Sort;
6
7/**
8 * DokuWiki Plugin authpdo (Auth Component)
9 *
10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
11 * @author  Andreas Gohr <andi@splitbrain.org>
12 */
13
14/**
15 * Class auth_plugin_authpdo
16 */
17class auth_plugin_authpdo extends AuthPlugin
18{
19    /** @var PDO */
20    protected $pdo;
21
22    /** @var null|array The list of all groups */
23    protected $groupcache;
24
25    /**
26     * Constructor.
27     */
28    public function __construct()
29    {
30        parent::__construct(); // for compatibility
31
32        if (!class_exists('PDO')) {
33            $this->debugMsg('PDO extension for PHP not found.', -1, __LINE__);
34            $this->success = false;
35            return;
36        }
37
38        if (!$this->getConf('dsn')) {
39            $this->debugMsg('No DSN specified', -1, __LINE__);
40            $this->success = false;
41            return;
42        }
43
44        try {
45            $this->pdo = new PDO(
46                $this->getConf('dsn'),
47                $this->getConf('user'),
48                conf_decodeString($this->getConf('pass')),
49                [
50                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // always fetch as array
51                    PDO::ATTR_EMULATE_PREPARES => true, // emulating prepares allows us to reuse param names
52                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // we want exceptions, not error codes
53                ]
54            );
55        } catch (PDOException $e) {
56            $this->debugMsg($e);
57            msg($this->getLang('connectfail'), -1);
58            $this->success = false;
59            return;
60        }
61
62        // can Users be created?
63        $this->cando['addUser'] = $this->checkConfig(
64            ['select-user', 'select-user-groups', 'select-groups', 'insert-user', 'insert-group', 'join-group']
65        );
66
67        // can Users be deleted?
68        $this->cando['delUser'] = $this->checkConfig(
69            ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'delete-user']
70        );
71
72        // can login names be changed?
73        $this->cando['modLogin'] = $this->checkConfig(
74            ['select-user', 'select-user-groups', 'update-user-login']
75        );
76
77        // can passwords be changed?
78        $this->cando['modPass'] = $this->checkConfig(
79            ['select-user', 'select-user-groups', 'update-user-pass']
80        );
81
82        // can real names be changed?
83        $this->cando['modName'] = $this->checkConfig(
84            ['select-user', 'select-user-groups', 'update-user-info:name']
85        );
86
87        // can real email be changed?
88        $this->cando['modMail'] = $this->checkConfig(
89            ['select-user', 'select-user-groups', 'update-user-info:mail']
90        );
91
92        // can groups be changed?
93        $this->cando['modGroups'] = $this->checkConfig(
94            ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'join-group', 'insert-group']
95        );
96
97        // can a filtered list of users be retrieved?
98        $this->cando['getUsers'] = $this->checkConfig(
99            ['list-users']
100        );
101
102        // can the number of users be retrieved?
103        $this->cando['getUserCount'] = $this->checkConfig(
104            ['count-users']
105        );
106
107        // can a list of available groups be retrieved?
108        $this->cando['getGroups'] = $this->checkConfig(
109            ['select-groups']
110        );
111
112        $this->success = true;
113    }
114
115    /**
116     * Check user+password
117     *
118     * @param string $user the user name
119     * @param string $pass the clear text password
120     * @return  bool
121     */
122    public function checkPass($user, $pass)
123    {
124
125        $userdata = $this->selectUser($user);
126        if ($userdata == false) return false;
127
128        // password checking done in SQL?
129        if ($this->checkConfig(['check-pass'])) {
130            $userdata['clear'] = $pass;
131            $userdata['hash'] = auth_cryptPassword($pass);
132            $result = $this->query($this->getConf('check-pass'), $userdata);
133            if ($result === false) return false;
134            return (count($result) == 1);
135        }
136
137        // we do password checking on our own
138        if (isset($userdata['hash'])) {
139            // hashed password
140            $passhash = new PassHash();
141            return $passhash->verify_hash($pass, $userdata['hash']);
142        } else {
143            // clear text password in the database O_o
144            return ($pass === $userdata['clear']);
145        }
146    }
147
148    /**
149     * Return user info
150     *
151     * Returns info about the given user needs to contain
152     * at least these fields:
153     *
154     * name string  full name of the user
155     * mail string  email addres of the user
156     * grps array   list of groups the user is in
157     *
158     * @param string $user the user name
159     * @param bool $requireGroups whether or not the returned data must include groups
160     * @return array|bool containing user data or false
161     */
162    public function getUserData($user, $requireGroups = true)
163    {
164        $data = $this->selectUser($user);
165        if ($data == false) return false;
166
167        if (isset($data['hash'])) unset($data['hash']);
168        if (isset($data['clean'])) unset($data['clean']);
169
170        if ($requireGroups) {
171            $data['grps'] = $this->selectUserGroups($data);
172            if ($data['grps'] === false) return false;
173        }
174
175        return $data;
176    }
177
178    /**
179     * Create a new User [implement only where required/possible]
180     *
181     * Returns false if the user already exists, null when an error
182     * occurred and true if everything went well.
183     *
184     * The new user HAS TO be added to the default group by this
185     * function!
186     *
187     * Set addUser capability when implemented
188     *
189     * @param string $user
190     * @param string $clear
191     * @param string $name
192     * @param string $mail
193     * @param null|array $grps
194     * @return bool|null
195     */
196    public function createUser($user, $clear, $name, $mail, $grps = null)
197    {
198        global $conf;
199
200        if (($info = $this->getUserData($user, false)) !== false) {
201            msg($this->getLang('userexists'), -1);
202            return false; // user already exists
203        }
204
205        // prepare data
206        if ($grps == null) $grps = [];
207        array_unshift($grps, $conf['defaultgroup']);
208        $grps = array_unique($grps);
209        $hash = auth_cryptPassword($clear);
210        $userdata = ['user' => $user, 'clear' => $clear, 'hash' => $hash, 'name' => $name, 'mail' => $mail];
211
212        // action protected by transaction
213        $this->pdo->beginTransaction();
214        {
215            // insert the user
216            $ok = $this->query($this->getConf('insert-user'), $userdata);
217            if ($ok === false) goto FAIL;
218            $userdata = $this->getUserData($user, false);
219            if ($userdata === false) goto FAIL;
220
221            // create all groups that do not exist, the refetch the groups
222            $allgroups = $this->selectGroups();
223        foreach ($grps as $group) {
224            if (!isset($allgroups[$group])) {
225                $ok = $this->addGroup($group);
226                if ($ok === false) goto FAIL;
227            }
228        }
229            $allgroups = $this->selectGroups();
230
231            // add user to the groups
232        foreach ($grps as $group) {
233            $ok = $this->joinGroup($userdata, $allgroups[$group]);
234            if ($ok === false) goto FAIL;
235        }
236        }
237        $this->pdo->commit();
238        return true;
239
240        // something went wrong, rollback
241        FAIL:
242        $this->pdo->rollBack();
243        $this->debugMsg('Transaction rolled back', 0, __LINE__);
244        msg($this->getLang('writefail'), -1);
245        return null; // return error
246    }
247
248    /**
249     * Modify user data
250     *
251     * @param string $user nick of the user to be changed
252     * @param array $changes array of field/value pairs to be changed (password will be clear text)
253     * @return  bool
254     */
255    public function modifyUser($user, $changes)
256    {
257        // secure everything in transaction
258        $this->pdo->beginTransaction();
259        {
260            $olddata = $this->getUserData($user);
261            $oldgroups = $olddata['grps'];
262            unset($olddata['grps']);
263
264            // changing the user name?
265        if (isset($changes['user'])) {
266            if ($this->getUserData($changes['user'], false)) goto FAIL;
267            $params = $olddata;
268            $params['newlogin'] = $changes['user'];
269
270            $ok = $this->query($this->getConf('update-user-login'), $params);
271            if ($ok === false) goto FAIL;
272        }
273
274            // changing the password?
275        if (isset($changes['pass'])) {
276            $params = $olddata;
277            $params['clear'] = $changes['pass'];
278            $params['hash'] = auth_cryptPassword($changes['pass']);
279
280            $ok = $this->query($this->getConf('update-user-pass'), $params);
281            if ($ok === false) goto FAIL;
282        }
283
284            // changing info?
285        if (isset($changes['mail']) || isset($changes['name'])) {
286            $params = $olddata;
287            if (isset($changes['mail'])) $params['mail'] = $changes['mail'];
288            if (isset($changes['name'])) $params['name'] = $changes['name'];
289
290            $ok = $this->query($this->getConf('update-user-info'), $params);
291            if ($ok === false) goto FAIL;
292        }
293
294            // changing groups?
295        if (isset($changes['grps'])) {
296            $allgroups = $this->selectGroups();
297
298            // remove membership for previous groups
299            foreach ($oldgroups as $group) {
300                if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) {
301                    $ok = $this->leaveGroup($olddata, $allgroups[$group]);
302                    if ($ok === false) goto FAIL;
303                }
304            }
305
306            // create all new groups that are missing
307            $added = 0;
308            foreach ($changes['grps'] as $group) {
309                if (!isset($allgroups[$group])) {
310                    $ok = $this->addGroup($group);
311                    if ($ok === false) goto FAIL;
312                    $added++;
313                }
314            }
315            // reload group info
316            if ($added > 0) $allgroups = $this->selectGroups();
317
318            // add membership for new groups
319            foreach ($changes['grps'] as $group) {
320                if (!in_array($group, $oldgroups)) {
321                    $ok = $this->joinGroup($olddata, $allgroups[$group]);
322                    if ($ok === false) goto FAIL;
323                }
324            }
325        }
326
327        }
328        $this->pdo->commit();
329        return true;
330
331        // something went wrong, rollback
332        FAIL:
333        $this->pdo->rollBack();
334        $this->debugMsg('Transaction rolled back', 0, __LINE__);
335        msg($this->getLang('writefail'), -1);
336        return false; // return error
337    }
338
339    /**
340     * Delete one or more users
341     *
342     * Set delUser capability when implemented
343     *
344     * @param array $users
345     * @return  int    number of users deleted
346     */
347    public function deleteUsers($users)
348    {
349        $count = 0;
350        foreach ($users as $user) {
351            if ($this->deleteUser($user)) $count++;
352        }
353        return $count;
354    }
355
356    /**
357     * Bulk retrieval of user data [implement only where required/possible]
358     *
359     * Set getUsers capability when implemented
360     *
361     * @param int $start index of first user to be returned
362     * @param int $limit max number of users to be returned
363     * @param array $filter array of field/pattern pairs, null for no filter
364     * @return  array list of userinfo (refer getUserData for internal userinfo details)
365     */
366    public function retrieveUsers($start = 0, $limit = -1, $filter = null)
367    {
368        if ($limit < 0) $limit = 10000; // we don't support no limit
369        if (is_null($filter)) $filter = [];
370
371        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
372        foreach (['user', 'name', 'mail', 'group'] as $key) {
373            if (!isset($filter[$key])) {
374                $filter[$key] = '%';
375            } else {
376                $filter[$key] = '%' . $filter[$key] . '%';
377            }
378        }
379        $filter['start'] = (int)$start;
380        $filter['end'] = (int)$start + $limit;
381        $filter['limit'] = (int)$limit;
382
383        $result = $this->query($this->getConf('list-users'), $filter);
384        if (!$result) return [];
385        $users = [];
386        if (is_array($result)) {
387            foreach ($result as $row) {
388                if (!isset($row['user'])) {
389                    $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__);
390                    return [];
391                }
392                $users[] = $this->getUserData($row['user']);
393            }
394        } else {
395            $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__);
396        }
397        return $users;
398    }
399
400    /**
401     * Return a count of the number of user which meet $filter criteria
402     *
403     * @param array $filter array of field/pattern pairs, empty array for no filter
404     * @return int
405     */
406    public function getUserCount($filter = [])
407    {
408        if (is_null($filter)) $filter = [];
409
410        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
411        foreach (['user', 'name', 'mail', 'group'] as $key) {
412            if (!isset($filter[$key])) {
413                $filter[$key] = '%';
414            } else {
415                $filter[$key] = '%' . $filter[$key] . '%';
416            }
417        }
418
419        $result = $this->query($this->getConf('count-users'), $filter);
420        if (!$result || !isset($result[0]['count'])) {
421            $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__);
422        }
423        return (int)$result[0]['count'];
424    }
425
426    /**
427     * Create a new group with the given name
428     *
429     * @param string $group
430     * @return bool
431     */
432    public function addGroup($group)
433    {
434        $sql = $this->getConf('insert-group');
435
436        $result = $this->query($sql, [':group' => $group]);
437        $this->clearGroupCache();
438        if ($result === false) return false;
439        return true;
440    }
441
442    /**
443     * Retrieve groups
444     *
445     * Set getGroups capability when implemented
446     *
447     * @param int $start
448     * @param int $limit
449     * @return  array
450     */
451    public function retrieveGroups($start = 0, $limit = 0)
452    {
453        $groups = array_keys($this->selectGroups());
454        if ($groups === false) return [];
455
456        if (!$limit) {
457            return array_splice($groups, $start);
458        } else {
459            return array_splice($groups, $start, $limit);
460        }
461    }
462
463    /**
464     * Select data of a specified user
465     *
466     * @param string $user the user name
467     * @return bool|array user data, false on error
468     */
469    protected function selectUser($user)
470    {
471        $sql = $this->getConf('select-user');
472
473        $result = $this->query($sql, [':user' => $user]);
474        if (!$result) return false;
475
476        if (count($result) > 1) {
477            $this->debugMsg('Found more than one matching user', -1, __LINE__);
478            return false;
479        }
480
481        $data = array_shift($result);
482        $dataok = true;
483
484        if (!isset($data['user'])) {
485            $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__);
486            $dataok = false;
487        }
488        if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(['check-pass'])) {
489            $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__);
490            $dataok = false;
491        }
492        if (!isset($data['name'])) {
493            $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__);
494            $dataok = false;
495        }
496        if (!isset($data['mail'])) {
497            $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__);
498            $dataok = false;
499        }
500
501        if (!$dataok) return false;
502        return $data;
503    }
504
505    /**
506     * Delete a user after removing all their group memberships
507     *
508     * @param string $user
509     * @return bool true when the user was deleted
510     */
511    protected function deleteUser($user)
512    {
513        $this->pdo->beginTransaction();
514        {
515            $userdata = $this->getUserData($user);
516            if ($userdata === false) goto FAIL;
517            $allgroups = $this->selectGroups();
518
519            // remove group memberships (ignore errors)
520        foreach ($userdata['grps'] as $group) {
521            if (isset($allgroups[$group])) {
522                $this->leaveGroup($userdata, $allgroups[$group]);
523            }
524        }
525
526            $ok = $this->query($this->getConf('delete-user'), $userdata);
527            if ($ok === false) goto FAIL;
528        }
529        $this->pdo->commit();
530        return true;
531
532        FAIL:
533        $this->pdo->rollBack();
534        return false;
535    }
536
537    /**
538     * Select all groups of a user
539     *
540     * @param array $userdata The userdata as returned by _selectUser()
541     * @return array|bool list of group names, false on error
542     */
543    protected function selectUserGroups($userdata)
544    {
545        global $conf;
546        $sql = $this->getConf('select-user-groups');
547        $result = $this->query($sql, $userdata);
548        if ($result === false) return false;
549
550        $groups = [$conf['defaultgroup']]; // always add default config
551        if (is_array($result)) {
552            foreach ($result as $row) {
553                if (!isset($row['group'])) {
554                    $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__);
555                    return false;
556                }
557                $groups[] = $row['group'];
558            }
559        } else {
560            $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__);
561        }
562
563        $groups = array_unique($groups);
564        Sort::sort($groups);
565        return $groups;
566    }
567
568    /**
569     * Select all available groups
570     *
571     * @return array|bool list of all available groups and their properties
572     */
573    protected function selectGroups()
574    {
575        if ($this->groupcache) return $this->groupcache;
576
577        $sql = $this->getConf('select-groups');
578        $result = $this->query($sql);
579        if ($result === false) return false;
580
581        $groups = [];
582        if (is_array($result)) {
583            foreach ($result as $row) {
584                if (!isset($row['group'])) {
585                    $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__);
586                    return false;
587                }
588
589                // relayout result with group name as key
590                $group = $row['group'];
591                $groups[$group] = $row;
592            }
593        } else {
594            $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__);
595        }
596
597        Sort::ksort($groups);
598        return $groups;
599    }
600
601    /**
602     * Remove all entries from the group cache
603     */
604    protected function clearGroupCache()
605    {
606        $this->groupcache = null;
607    }
608
609    /**
610     * Adds the user to the group
611     *
612     * @param array $userdata all the user data
613     * @param array $groupdata all the group data
614     * @return bool
615     */
616    protected function joinGroup($userdata, $groupdata)
617    {
618        $data = array_merge($userdata, $groupdata);
619        $sql = $this->getConf('join-group');
620        $result = $this->query($sql, $data);
621        if ($result === false) return false;
622        return true;
623    }
624
625    /**
626     * Removes the user from the group
627     *
628     * @param array $userdata all the user data
629     * @param array $groupdata all the group data
630     * @return bool
631     */
632    protected function leaveGroup($userdata, $groupdata)
633    {
634        $data = array_merge($userdata, $groupdata);
635        $sql = $this->getConf('leave-group');
636        $result = $this->query($sql, $data);
637        if ($result === false) return false;
638        return true;
639    }
640
641    /**
642     * Executes a query
643     *
644     * @param string $sql The SQL statement to execute
645     * @param array $arguments Named parameters to be used in the statement
646     * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error
647     */
648    protected function query($sql, $arguments = [])
649    {
650        $sql = trim($sql);
651        if (empty($sql)) {
652            $this->debugMsg('No SQL query given', -1, __LINE__);
653            return false;
654        }
655
656        // execute
657        $params = [];
658        $sth = $this->pdo->prepare($sql);
659        $result = false;
660        try {
661            // prepare parameters - we only use those that exist in the SQL
662            foreach ($arguments as $key => $value) {
663                if (is_array($value)) continue;
664                if (is_object($value)) continue;
665                if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed
666                if (strpos($sql, (string) $key) === false) continue; // skip if parameter is missing
667
668                if (is_int($value)) {
669                    $sth->bindValue($key, $value, PDO::PARAM_INT);
670                } else {
671                    $sth->bindValue($key, $value);
672                }
673                $params[$key] = $value; //remember for debugging
674            }
675
676            $sth->execute();
677            // only report last line's result
678            $hasnextrowset = true;
679            $currentsql = $sql;
680            while ($hasnextrowset) {
681                if (str_starts_with(strtolower($currentsql), 'select')) {
682                    $result = $sth->fetchAll();
683                } else {
684                    $result = $sth->rowCount();
685                }
686                $semi_pos = strpos($currentsql, ';');
687                if ($semi_pos) {
688                    $currentsql = trim(substr($currentsql, $semi_pos + 1));
689                }
690                try {
691                    $hasnextrowset = $sth->nextRowset(); // run next rowset
692                } catch (PDOException $rowset_e) {
693                    $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time
694                }
695            }
696        } catch (Exception $e) {
697            // report the caller's line
698            $trace = debug_backtrace();
699            $line = $trace[0]['line'];
700            $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
701            $this->debugMsg($e, -1, $line);
702            $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line);
703        }
704        $sth->closeCursor();
705
706        return $result;
707    }
708
709    /**
710     * Wrapper around msg() but outputs only when debug is enabled
711     *
712     * @param string|Exception $message
713     * @param int $err
714     * @param int $line
715     */
716    protected function debugMsg($message, $err = 0, $line = 0)
717    {
718        if (!$this->getConf('debug')) return;
719        if (is_a($message, 'Exception')) {
720            $err = -1;
721            $msg = $message->getMessage();
722            if (!$line) $line = $message->getLine();
723        } else {
724            $msg = $message;
725        }
726
727        if (defined('DOKU_UNITTEST')) {
728            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
729        } else {
730            msg('authpdo: ' . $msg, $err, $line, __FILE__);
731        }
732    }
733
734    /**
735     * Check if the given config strings are set
736     *
737     * @param string[] $keys
738     * @return  bool
739     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
740     *
741     */
742    protected function checkConfig($keys)
743    {
744        foreach ($keys as $key) {
745            $params = explode(':', $key);
746            $key = array_shift($params);
747            $sql = trim($this->getConf($key));
748
749            // check if sql is set
750            if (!$sql) return false;
751            // check if needed params are there
752            foreach ($params as $param) {
753                if (strpos($sql, ":$param") === false) return false;
754            }
755        }
756
757        return true;
758    }
759
760    /**
761     * create an approximation of the SQL string with parameters replaced
762     *
763     * @param string $sql
764     * @param array $params
765     * @param bool $htmlescape Should the result be escaped for output in HTML?
766     * @return string
767     */
768    protected function debugSQL($sql, $params, $htmlescape = true)
769    {
770        foreach ($params as $key => $val) {
771            if (is_int($val)) {
772                $val = $this->pdo->quote($val, PDO::PARAM_INT);
773            } elseif (is_bool($val)) {
774                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
775            } elseif (is_null($val)) {
776                $val = 'NULL';
777            } else {
778                $val = $this->pdo->quote($val);
779            }
780            $sql = str_replace($key, $val, $sql);
781        }
782        if ($htmlescape) $sql = hsc($sql);
783        return $sql;
784    }
785}
786
787// vim:ts=4:sw=4:et:
788