xref: /dokuwiki/lib/plugins/authpdo/auth.php (revision 6459f496c8689d7960a4c338677ae020055d1038)
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->addGroup($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->addGroup($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        if($limit < 0) $limit = 10000; // we don't support no limit
307        if(is_null($filter)) $filter = array();
308
309        foreach(array('user','name','mail','group') as $key) {
310            if(!isset($filter[$key])) {
311                $filter[$key] = '%';
312            } else {
313                $filter[$key] = '%'.$filter[$key].'%';
314            }
315        }
316        $filter['start'] = $start;
317        $filter['end']   = $start + $limit;
318        $filter['limit'] = $limit;
319
320        $result = $this->_query($this->getConf('list-users'), $filter);
321        if(!$result) return array();
322        $users = array();
323        foreach($result as $row) {
324            if(!isset($row['user'])) {
325                $this->_debug("Statement did not return 'user' attribute", -1, __LINE__);
326                return array();
327            }
328            $users[] = $row['user'];
329        }
330        return $users;
331    }
332
333    /**
334     * Return a count of the number of user which meet $filter criteria
335     *
336     * @param  array $filter array of field/pattern pairs, empty array for no filter
337     * @return int
338     */
339    public function getUserCount($filter = array()) {
340        if(is_null($filter)) $filter = array();
341
342        foreach(array('user','name','mail','group') as $key) {
343            if(!isset($filter[$key])) {
344                $filter[$key] = '%';
345            } else {
346                $filter[$key] = '%'.$filter[$key].'%';
347            }
348        }
349
350        $result = $this->_query($this->getConf('count-users'), $filter);
351        if(!$result || !isset($result[0]['count'])) {
352            $this->_debug("Statement did not return 'count' attribute", -1, __LINE__);
353        }
354        return isset($result[0]['count']);
355    }
356
357    /**
358     * Create a new group with the given name
359     *
360     * @param string $group
361     * @return bool
362     */
363    public function addGroup($group) {
364        $sql = $this->getConf('insert-group');
365
366        $result = $this->_query($sql, array(':group' => $group));
367        if($result === false) return false;
368        return true;
369    }
370
371    /**
372     * Retrieve groups
373     *
374     * Set getGroups capability when implemented
375     *
376     * @param   int $start
377     * @param   int $limit
378     * @return  array
379     */
380    public function retrieveGroups($start = 0, $limit = 0) {
381        $groups = array_keys($this->_selectGroups());
382        if($groups === false) return array();
383
384        if(!$limit) {
385            return array_splice($groups, $start);
386        } else {
387            return array_splice($groups, $start, $limit);
388        }
389    }
390
391    /**
392     * Select data of a specified user
393     *
394     * @param string $user the user name
395     * @return bool|array user data, false on error
396     */
397    protected function _selectUser($user) {
398        $sql = $this->getConf('select-user');
399
400        $result = $this->_query($sql, array(':user' => $user));
401        if(!$result) return false;
402
403        if(count($result) > 1) {
404            $this->_debug('Found more than one matching user', -1, __LINE__);
405            return false;
406        }
407
408        $data = array_shift($result);
409        $dataok = true;
410
411        if(!isset($data['user'])) {
412            $this->_debug("Statement did not return 'user' attribute", -1, __LINE__);
413            $dataok = false;
414        }
415        if(!isset($data['hash']) && !isset($data['clear'])) {
416            $this->_debug("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__);
417            $dataok = false;
418        }
419        if(!isset($data['name'])) {
420            $this->_debug("Statement did not return 'name' attribute", -1, __LINE__);
421            $dataok = false;
422        }
423        if(!isset($data['mail'])) {
424            $this->_debug("Statement did not return 'mail' attribute", -1, __LINE__);
425            $dataok = false;
426        }
427
428        if(!$dataok) return false;
429        return $data;
430    }
431
432    /**
433     * Delete a user after removing all their group memberships
434     *
435     * @param string $user
436     * @return bool true when the user was deleted
437     */
438    protected function _deleteUser($user) {
439        $this->pdo->beginTransaction();
440        {
441            $userdata = $this->getUserData($user);
442            if($userdata === false) goto FAIL;
443            $allgroups = $this->_selectGroups();
444
445            // remove group memberships (ignore errors)
446            foreach($userdata['grps'] as $group) {
447                $this->_leaveGroup($userdata, $allgroups[$group]);
448            }
449
450            $ok = $this->_query($this->getConf('delete-user'), $userdata);
451            if($ok === false) goto FAIL;
452        }
453        $this->pdo->commit();
454        return true;
455
456        FAIL:
457        $this->pdo->rollBack();
458        return false;
459    }
460
461    /**
462     * Select all groups of a user
463     *
464     * @param array $userdata The userdata as returned by _selectUser()
465     * @return array|bool list of group names, false on error
466     */
467    protected function _selectUserGroups($userdata) {
468        global $conf;
469        $sql = $this->getConf('select-user-groups');
470        $result = $this->_query($sql, $userdata);
471        if($result === false) return false;
472
473        $groups = array($conf['defaultgroup']); // always add default config
474        foreach($result as $row) {
475            if(!isset($row['group'])) {
476                $this->_debug("No 'group' field returned in select-user-groups statement");
477                return false;
478            }
479            $groups[] = $row['group'];
480        }
481
482        $groups = array_unique($groups);
483        sort($groups);
484        return $groups;
485    }
486
487    /**
488     * Select all available groups
489     *
490     * @todo this should be cached
491     * @return array|bool list of all available groups and their properties
492     */
493    protected function _selectGroups() {
494        $sql = $this->getConf('select-groups');
495        $result = $this->_query($sql);
496        if($result === false) return false;
497
498        $groups = array();
499        foreach($result as $row) {
500            if(!isset($row['group'])) {
501                $this->_debug("No 'group' field returned from select-groups statement", -1, __LINE__);
502                return false;
503            }
504
505            // relayout result with group name as key
506            $group = $row['group'];
507            $groups[$group] = $row;
508        }
509
510        ksort($groups);
511        return $groups;
512    }
513
514
515    /**
516     * Adds the user to the group
517     *
518     * @param array $userdata all the user data
519     * @param array $groupdata all the group data
520     * @return bool
521     */
522    protected function _joinGroup($userdata, $groupdata) {
523        $data = array_merge($userdata, $groupdata);
524        $sql = $this->getConf('join-group');
525        $result = $this->_query($sql, $data);
526        if($result === false) return false;
527        return true;
528    }
529
530    /**
531     * Removes the user from the group
532     *
533     * @param array $userdata all the user data
534     * @param array $groupdata all the group data
535     * @return bool
536     */
537    protected function _leaveGroup($userdata, $groupdata) {
538        $data = array_merge($userdata, $groupdata);
539        $sql = $this->getConf('leave-group');
540        $result = $this->_query($sql, $data);
541        if($result === false) return false;
542        return true;
543    }
544
545    /**
546     * Executes a query
547     *
548     * @param string $sql The SQL statement to execute
549     * @param array $arguments Named parameters to be used in the statement
550     * @return array|bool The result as associative array, false on error
551     */
552    protected function _query($sql, $arguments = array()) {
553        if(empty($sql)) {
554            $this->_debug('No SQL query given', -1, __LINE__);
555            return false;
556        }
557
558        // prepare parameters - we only use those that exist in the SQL
559        $params = array();
560        foreach($arguments as $key => $value) {
561            if(is_array($value)) continue;
562            if(is_object($value)) continue;
563            if($key[0] != ':') $key = ":$key"; // prefix with colon if needed
564            if(strpos($sql, $key) !== false) $params[$key] = $value;
565        }
566
567        // execute
568        $sth = $this->pdo->prepare($sql);
569        try {
570            $sth->execute($params);
571            $result = $sth->fetchAll();
572        } catch(Exception $e) {
573            // report the caller's line
574            $trace = debug_backtrace();
575            $line = $trace[0]['line'];
576            $dsql = $this->_debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
577            $this->_debug($e, -1, $line);
578            $this->_debug("SQL: <pre>$dsql</pre>", -1, $line);
579            $result = false;
580        } finally {
581            $sth->closeCursor();
582            $sth = null;
583        }
584
585        return $result;
586    }
587
588    /**
589     * Wrapper around msg() but outputs only when debug is enabled
590     *
591     * @param string|Exception $message
592     * @param int $err
593     * @param int $line
594     */
595    protected function _debug($message, $err = 0, $line = 0) {
596        if(!$this->getConf('debug')) return;
597        if(is_a($message, 'Exception')) {
598            $err = -1;
599            $msg = $message->getMessage();
600            if(!$line) $line = $message->getLine();
601        } else {
602            $msg = $message;
603        }
604
605        if(defined('DOKU_UNITTEST')) {
606            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
607        } else {
608            msg('authpdo: ' . $msg, $err, $line, __FILE__);
609        }
610    }
611
612    /**
613     * Check if the given config strings are set
614     *
615     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
616     *
617     * @param   string[] $keys
618     * @return  bool
619     */
620    protected function _chkcnf($keys) {
621        foreach($keys as $key) {
622            if(!$this->getConf($key)) return false;
623        }
624
625        return true;
626    }
627
628    /**
629     * create an approximation of the SQL string with parameters replaced
630     *
631     * @param string $sql
632     * @param array $params
633     * @param bool $htmlescape Should the result be escaped for output in HTML?
634     * @return string
635     */
636    protected function _debugSQL($sql, $params, $htmlescape = true) {
637        foreach($params as $key => $val) {
638            if(is_int($val)) {
639                $val = $this->pdo->quote($val, PDO::PARAM_INT);
640            } elseif(is_bool($val)) {
641                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
642            } elseif(is_null($val)) {
643                $val = 'NULL';
644            } else {
645                $val = $this->pdo->quote($val);
646            }
647            $sql = str_replace($key, $val, $sql);
648        }
649        if($htmlescape) $sql = hsc($sql);
650        return $sql;
651    }
652}
653
654// vim:ts=4:sw=4:et:
655