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