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