xref: /dokuwiki/lib/plugins/authpdo/auth.php (revision 358942b501036d5173ce50a7c00ee30ef6e8de6f)
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|bool 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']) && isset($allgroups[$group])) {
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'] = (int) $start;
389        $filter['end'] = (int) $start + $limit;
390        $filter['limit'] = (int) $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                if(isset($allgroups[$group])) {
520                    $this->_leaveGroup($userdata, $allgroups[$group]);
521                }
522            }
523
524            $ok = $this->_query($this->getConf('delete-user'), $userdata);
525            if($ok === false) goto FAIL;
526        }
527        $this->pdo->commit();
528        return true;
529
530        FAIL:
531        $this->pdo->rollBack();
532        return false;
533    }
534
535    /**
536     * Select all groups of a user
537     *
538     * @param array $userdata The userdata as returned by _selectUser()
539     * @return array|bool list of group names, false on error
540     */
541    protected function _selectUserGroups($userdata) {
542        global $conf;
543        $sql = $this->getConf('select-user-groups');
544        $result = $this->_query($sql, $userdata);
545        if($result === false) return false;
546
547        $groups = array($conf['defaultgroup']); // always add default config
548        foreach($result as $row) {
549            if(!isset($row['group'])) {
550                $this->_debug("No 'group' field returned in select-user-groups statement");
551                return false;
552            }
553            $groups[] = $row['group'];
554        }
555
556        $groups = array_unique($groups);
557        sort($groups);
558        return $groups;
559    }
560
561    /**
562     * Select all available groups
563     *
564     * @todo this should be cached
565     * @return array|bool list of all available groups and their properties
566     */
567    protected function _selectGroups() {
568        $sql = $this->getConf('select-groups');
569        $result = $this->_query($sql);
570        if($result === false) return false;
571
572        $groups = array();
573        foreach($result as $row) {
574            if(!isset($row['group'])) {
575                $this->_debug("No 'group' field returned from select-groups statement", -1, __LINE__);
576                return false;
577            }
578
579            // relayout result with group name as key
580            $group = $row['group'];
581            $groups[$group] = $row;
582        }
583
584        ksort($groups);
585        return $groups;
586    }
587
588    /**
589     * Adds the user to the group
590     *
591     * @param array $userdata all the user data
592     * @param array $groupdata all the group data
593     * @return bool
594     */
595    protected function _joinGroup($userdata, $groupdata) {
596        $data = array_merge($userdata, $groupdata);
597        $sql = $this->getConf('join-group');
598        $result = $this->_query($sql, $data);
599        if($result === false) return false;
600        return true;
601    }
602
603    /**
604     * Removes the user from the group
605     *
606     * @param array $userdata all the user data
607     * @param array $groupdata all the group data
608     * @return bool
609     */
610    protected function _leaveGroup($userdata, $groupdata) {
611        $data = array_merge($userdata, $groupdata);
612        $sql = $this->getConf('leave-group');
613        $result = $this->_query($sql, $data);
614        if($result === false) return false;
615        return true;
616    }
617
618    /**
619     * Executes a query
620     *
621     * @param string $sql The SQL statement to execute
622     * @param array $arguments Named parameters to be used in the statement
623     * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error
624     */
625    protected function _query($sql, $arguments = array()) {
626        $sql = trim($sql);
627        if(empty($sql)) {
628            $this->_debug('No SQL query given', -1, __LINE__);
629            return false;
630        }
631
632        // execute
633        $params = array();
634        $sth = $this->pdo->prepare($sql);
635        try {
636            // prepare parameters - we only use those that exist in the SQL
637            foreach($arguments as $key => $value) {
638                if(is_array($value)) continue;
639                if(is_object($value)) continue;
640                if($key[0] != ':') $key = ":$key"; // prefix with colon if needed
641                if(strpos($sql, $key) === false) continue; // skip if parameter is missing
642
643                if(is_int($value)) {
644                    $sth->bindValue($key, $value, PDO::PARAM_INT);
645                } else {
646                    $sth->bindValue($key, $value);
647                }
648                $param[$key] = $value; //remember for debugging
649            }
650
651            $sth->execute();
652            if(strtolower(substr($sql, 0, 6)) == 'select') {
653                $result = $sth->fetchAll();
654            } else {
655                $result = $sth->rowCount();
656            }
657        } catch(Exception $e) {
658            // report the caller's line
659            $trace = debug_backtrace();
660            $line = $trace[0]['line'];
661            $dsql = $this->_debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
662            $this->_debug($e, -1, $line);
663            $this->_debug("SQL: <pre>$dsql</pre>", -1, $line);
664            $result = false;
665        }
666        $sth->closeCursor();
667        $sth = null;
668
669        return $result;
670    }
671
672    /**
673     * Wrapper around msg() but outputs only when debug is enabled
674     *
675     * @param string|Exception $message
676     * @param int $err
677     * @param int $line
678     */
679    protected function _debug($message, $err = 0, $line = 0) {
680        if(!$this->getConf('debug')) return;
681        if(is_a($message, 'Exception')) {
682            $err = -1;
683            $msg = $message->getMessage();
684            if(!$line) $line = $message->getLine();
685        } else {
686            $msg = $message;
687        }
688
689        if(defined('DOKU_UNITTEST')) {
690            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
691        } else {
692            msg('authpdo: ' . $msg, $err, $line, __FILE__);
693        }
694    }
695
696    /**
697     * Check if the given config strings are set
698     *
699     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
700     *
701     * @param   string[] $keys
702     * @return  bool
703     */
704    protected function _chkcnf($keys) {
705        foreach($keys as $key) {
706            if(!trim($this->getConf($key))) return false;
707        }
708
709        return true;
710    }
711
712    /**
713     * create an approximation of the SQL string with parameters replaced
714     *
715     * @param string $sql
716     * @param array $params
717     * @param bool $htmlescape Should the result be escaped for output in HTML?
718     * @return string
719     */
720    protected function _debugSQL($sql, $params, $htmlescape = true) {
721        foreach($params as $key => $val) {
722            if(is_int($val)) {
723                $val = $this->pdo->quote($val, PDO::PARAM_INT);
724            } elseif(is_bool($val)) {
725                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
726            } elseif(is_null($val)) {
727                $val = 'NULL';
728            } else {
729                $val = $this->pdo->quote($val);
730            }
731            $sql = str_replace($key, $val, $sql);
732        }
733        if($htmlescape) $sql = hsc($sql);
734        return $sql;
735    }
736}
737
738// vim:ts=4:sw=4:et:
739