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