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