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