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