xref: /dokuwiki/lib/plugins/authpdo/auth.php (revision ccc4c71ca88c25bcefb7f42eb01f0c040487e3a9)
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 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        foreach ($result as $row) {
425            if (!isset($row['user'])) {
426                $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__);
427                return array();
428            }
429            $users[] = $this->getUserData($row['user']);
430        }
431        return $users;
432    }
433
434    /**
435     * Return a count of the number of user which meet $filter criteria
436     *
437     * @param  array $filter array of field/pattern pairs, empty array for no filter
438     * @return int
439     */
440    public function getUserCount($filter = array())
441    {
442        if (is_null($filter)) $filter = array();
443
444        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
445        foreach (array('user', 'name', 'mail', 'group') as $key) {
446            if (!isset($filter[$key])) {
447                $filter[$key] = '%';
448            } else {
449                $filter[$key] = '%' . $filter[$key] . '%';
450            }
451        }
452
453        $result = $this->query($this->getConf('count-users'), $filter);
454        if (!$result || !isset($result[0]['count'])) {
455            $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__);
456        }
457        return (int) $result[0]['count'];
458    }
459
460    /**
461     * Create a new group with the given name
462     *
463     * @param string $group
464     * @return bool
465     */
466    public function addGroup($group)
467    {
468        $sql = $this->getConf('insert-group');
469
470        $result = $this->query($sql, array(':group' => $group));
471        $this->clearGroupCache();
472        if ($result === false) return false;
473        return true;
474    }
475
476    /**
477     * Retrieve groups
478     *
479     * Set getGroups capability when implemented
480     *
481     * @param   int $start
482     * @param   int $limit
483     * @return  array
484     */
485    public function retrieveGroups($start = 0, $limit = 0)
486    {
487        $groups = array_keys($this->selectGroups());
488        if ($groups === false) return array();
489
490        if (!$limit) {
491            return array_splice($groups, $start);
492        } else {
493            return array_splice($groups, $start, $limit);
494        }
495    }
496
497    /**
498     * Select data of a specified user
499     *
500     * @param string $user the user name
501     * @return bool|array user data, false on error
502     */
503    protected function selectUser($user)
504    {
505        $sql = $this->getConf('select-user');
506
507        $result = $this->query($sql, array(':user' => $user));
508        if (!$result) return false;
509
510        if (count($result) > 1) {
511            $this->debugMsg('Found more than one matching user', -1, __LINE__);
512            return false;
513        }
514
515        $data = array_shift($result);
516        $dataok = true;
517
518        if (!isset($data['user'])) {
519            $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__);
520            $dataok = false;
521        }
522        if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(array('check-pass'))) {
523            $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__);
524            $dataok = false;
525        }
526        if (!isset($data['name'])) {
527            $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__);
528            $dataok = false;
529        }
530        if (!isset($data['mail'])) {
531            $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__);
532            $dataok = false;
533        }
534
535        if (!$dataok) return false;
536        return $data;
537    }
538
539    /**
540     * Delete a user after removing all their group memberships
541     *
542     * @param string $user
543     * @return bool true when the user was deleted
544     */
545    protected function deleteUser($user)
546    {
547        $this->pdo->beginTransaction();
548        {
549            $userdata = $this->getUserData($user);
550            if ($userdata === false) goto FAIL;
551            $allgroups = $this->selectGroups();
552
553            // remove group memberships (ignore errors)
554        foreach ($userdata['grps'] as $group) {
555            if (isset($allgroups[$group])) {
556                $this->leaveGroup($userdata, $allgroups[$group]);
557            }
558        }
559
560            $ok = $this->query($this->getConf('delete-user'), $userdata);
561            if ($ok === false) goto FAIL;
562        }
563        $this->pdo->commit();
564        return true;
565
566        FAIL:
567        $this->pdo->rollBack();
568        return false;
569    }
570
571    /**
572     * Select all groups of a user
573     *
574     * @param array $userdata The userdata as returned by _selectUser()
575     * @return array|bool list of group names, false on error
576     */
577    protected function selectUserGroups($userdata)
578    {
579        global $conf;
580        $sql = $this->getConf('select-user-groups');
581        $result = $this->query($sql, $userdata);
582        if ($result === false) return false;
583
584        $groups = array($conf['defaultgroup']); // always add default config
585        foreach ($result as $row) {
586            if (!isset($row['group'])) {
587                $this->debugMsg("No 'group' field returned in select-user-groups statement");
588                return false;
589            }
590            $groups[] = $row['group'];
591        }
592
593        $groups = array_unique($groups);
594        sort($groups);
595        return $groups;
596    }
597
598    /**
599     * Select all available groups
600     *
601     * @return array|bool list of all available groups and their properties
602     */
603    protected function selectGroups()
604    {
605        if ($this->groupcache) return $this->groupcache;
606
607        $sql = $this->getConf('select-groups');
608        $result = $this->query($sql);
609        if ($result === false) return false;
610
611        $groups = array();
612        foreach ($result as $row) {
613            if (!isset($row['group'])) {
614                $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__);
615                return false;
616            }
617
618            // relayout result with group name as key
619            $group = $row['group'];
620            $groups[$group] = $row;
621        }
622
623        ksort($groups);
624        return $groups;
625    }
626
627    /**
628     * Remove all entries from the group cache
629     */
630    protected function clearGroupCache()
631    {
632        $this->groupcache = null;
633    }
634
635    /**
636     * Adds the user to the group
637     *
638     * @param array $userdata all the user data
639     * @param array $groupdata all the group data
640     * @return bool
641     */
642    protected function joinGroup($userdata, $groupdata)
643    {
644        $data = array_merge($userdata, $groupdata);
645        $sql = $this->getConf('join-group');
646        $result = $this->query($sql, $data);
647        if ($result === false) return false;
648        return true;
649    }
650
651    /**
652     * Removes the user from the group
653     *
654     * @param array $userdata all the user data
655     * @param array $groupdata all the group data
656     * @return bool
657     */
658    protected function leaveGroup($userdata, $groupdata)
659    {
660        $data = array_merge($userdata, $groupdata);
661        $sql = $this->getConf('leave-group');
662        $result = $this->query($sql, $data);
663        if ($result === false) return false;
664        return true;
665    }
666
667    /**
668     * Executes a query
669     *
670     * @param string $sql The SQL statement to execute
671     * @param array $arguments Named parameters to be used in the statement
672     * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error
673     */
674    protected function query($sql, $arguments = array())
675    {
676        $sql = trim($sql);
677        if (empty($sql)) {
678            $this->debugMsg('No SQL query given', -1, __LINE__);
679            return false;
680        }
681
682        // execute
683        $params = array();
684        $sth = $this->pdo->prepare($sql);
685        try {
686            // prepare parameters - we only use those that exist in the SQL
687            foreach ($arguments as $key => $value) {
688                if (is_array($value)) continue;
689                if (is_object($value)) continue;
690                if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed
691                if (strpos($sql, $key) === false) continue; // skip if parameter is missing
692
693                if (is_int($value)) {
694                    $sth->bindValue($key, $value, PDO::PARAM_INT);
695                } else {
696                    $sth->bindValue($key, $value);
697                }
698                $params[$key] = $value; //remember for debugging
699            }
700
701            $sth->execute();
702            if (strtolower(substr($sql, 0, 6)) == 'select') {
703                $result = $sth->fetchAll();
704            } else {
705                $result = $sth->rowCount();
706            }
707        } catch (Exception $e) {
708            // report the caller's line
709            $trace = debug_backtrace();
710            $line = $trace[0]['line'];
711            $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
712            $this->debugMsg($e, -1, $line);
713            $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line);
714            $result = false;
715        }
716        $sth->closeCursor();
717        $sth = null;
718
719        return $result;
720    }
721
722    /**
723     * Wrapper around msg() but outputs only when debug is enabled
724     *
725     * @param string|Exception $message
726     * @param int $err
727     * @param int $line
728     */
729    protected function debugMsg($message, $err = 0, $line = 0)
730    {
731        if (!$this->getConf('debug')) return;
732        if (is_a($message, 'Exception')) {
733            $err = -1;
734            $msg = $message->getMessage();
735            if (!$line) $line = $message->getLine();
736        } else {
737            $msg = $message;
738        }
739
740        if (defined('DOKU_UNITTEST')) {
741            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
742        } else {
743            msg('authpdo: ' . $msg, $err, $line, __FILE__);
744        }
745    }
746
747    /**
748     * Check if the given config strings are set
749     *
750     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
751     *
752     * @param   string[] $keys
753     * @return  bool
754     */
755    protected function checkConfig($keys)
756    {
757        foreach ($keys as $key) {
758            $params = explode(':', $key);
759            $key = array_shift($params);
760            $sql = trim($this->getConf($key));
761
762            // check if sql is set
763            if (!$sql) return false;
764            // check if needed params are there
765            foreach ($params as $param) {
766                if (strpos($sql, ":$param") === false) return false;
767            }
768        }
769
770        return true;
771    }
772
773    /**
774     * create an approximation of the SQL string with parameters replaced
775     *
776     * @param string $sql
777     * @param array $params
778     * @param bool $htmlescape Should the result be escaped for output in HTML?
779     * @return string
780     */
781    protected function debugSQL($sql, $params, $htmlescape = true)
782    {
783        foreach ($params as $key => $val) {
784            if (is_int($val)) {
785                $val = $this->pdo->quote($val, PDO::PARAM_INT);
786            } elseif (is_bool($val)) {
787                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
788            } elseif (is_null($val)) {
789                $val = 'NULL';
790            } else {
791                $val = $this->pdo->quote($val);
792            }
793            $sql = str_replace($key, $val, $sql);
794        }
795        if ($htmlescape) $sql = hsc($sql);
796        return $sql;
797    }
798}
799
800// vim:ts=4:sw=4:et:
801