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