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