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