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