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