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