1<?php
2use dokuwiki\Utf8\Sort;
3
4/**
5 * DokuWiki Plugin authpdo (Auth Component)
6 *
7 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
8 * @author  Andreas Gohr <andi@splitbrain.org>
9 */
10
11/**
12 * Class auth_plugin_authpdo
13 */
14class auth_plugin_authpdo extends DokuWiki_Auth_Plugin
15{
16
17    /** @var PDO */
18    protected $pdo;
19
20    /** @var null|array The list of all groups */
21    protected $groupcache = null;
22
23    /**
24     * Constructor.
25     */
26    public function __construct()
27    {
28        parent::__construct(); // for compatibility
29
30        if (!class_exists('PDO')) {
31            $this->debugMsg('PDO extension for PHP not found.', -1, __LINE__);
32            $this->success = false;
33            return;
34        }
35
36        if (!$this->getConf('dsn')) {
37            $this->debugMsg('No DSN specified', -1, __LINE__);
38            $this->success = false;
39            return;
40        }
41
42        try {
43            $this->pdo = new PDO(
44                $this->getConf('dsn'),
45                $this->getConf('user'),
46                conf_decodeString($this->getConf('pass')),
47                array(
48                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // always fetch as array
49                    PDO::ATTR_EMULATE_PREPARES => true, // emulating prepares allows us to reuse param names
50                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // we want exceptions, not error codes
51                )
52            );
53        } catch (PDOException $e) {
54            $this->debugMsg($e);
55            msg($this->getLang('connectfail'), -1);
56            $this->success = false;
57            return;
58        }
59
60        // can Users be created?
61        $this->cando['addUser'] = $this->checkConfig(
62            array(
63                'select-user',
64                'select-user-groups',
65                'select-groups',
66                'insert-user',
67                'insert-group',
68                'join-group'
69            )
70        );
71
72        // can Users be deleted?
73        $this->cando['delUser'] = $this->checkConfig(
74            array(
75                'select-user',
76                'select-user-groups',
77                'select-groups',
78                'leave-group',
79                'delete-user'
80            )
81        );
82
83        // can login names be changed?
84        $this->cando['modLogin'] = $this->checkConfig(
85            array(
86                'select-user',
87                'select-user-groups',
88                'update-user-login'
89            )
90        );
91
92        // can passwords be changed?
93        $this->cando['modPass'] = $this->checkConfig(
94            array(
95                'select-user',
96                'select-user-groups',
97                'update-user-pass'
98            )
99        );
100
101        // can real names be changed?
102        $this->cando['modName'] = $this->checkConfig(
103            array(
104                'select-user',
105                'select-user-groups',
106                'update-user-info:name'
107            )
108        );
109
110        // can real email be changed?
111        $this->cando['modMail'] = $this->checkConfig(
112            array(
113                'select-user',
114                'select-user-groups',
115                'update-user-info:mail'
116            )
117        );
118
119        // can groups be changed?
120        $this->cando['modGroups'] = $this->checkConfig(
121            array(
122                'select-user',
123                'select-user-groups',
124                'select-groups',
125                'leave-group',
126                'join-group',
127                'insert-group'
128            )
129        );
130
131        // can a filtered list of users be retrieved?
132        $this->cando['getUsers'] = $this->checkConfig(
133            array(
134                'list-users'
135            )
136        );
137
138        // can the number of users be retrieved?
139        $this->cando['getUserCount'] = $this->checkConfig(
140            array(
141                'count-users'
142            )
143        );
144
145        // can a list of available groups be retrieved?
146        $this->cando['getGroups'] = $this->checkConfig(
147            array(
148                'select-groups'
149            )
150        );
151
152        $this->success = true;
153    }
154
155    /**
156     * Check user+password
157     *
158     * @param string $user the user name
159     * @param string $pass the clear text password
160     * @return  bool
161     */
162    public function checkPass($user, $pass)
163    {
164
165        $userdata = $this->selectUser($user);
166        if ($userdata == false) return false;
167
168        // password checking done in SQL?
169        if ($this->checkConfig(array('check-pass'))) {
170            $userdata['clear'] = $pass;
171            $userdata['hash'] = auth_cryptPassword($pass);
172            $result = $this->query($this->getConf('check-pass'), $userdata);
173            if ($result === false) return false;
174            return (count($result) == 1);
175        }
176
177        // we do password checking on our own
178        if (isset($userdata['hash'])) {
179            // hashed password
180            $passhash = new \dokuwiki\PassHash();
181            return $passhash->verify_hash($pass, $userdata['hash']);
182        } else {
183            // clear text password in the database O_o
184            return ($pass === $userdata['clear']);
185        }
186    }
187
188    /**
189     * Return user info
190     *
191     * Returns info about the given user needs to contain
192     * at least these fields:
193     *
194     * name string  full name of the user
195     * mail string  email addres of the user
196     * grps array   list of groups the user is in
197     *
198     * @param string $user the user name
199     * @param bool $requireGroups whether or not the returned data must include groups
200     * @return array|bool containing user data or false
201     */
202    public function getUserData($user, $requireGroups = true)
203    {
204        $data = $this->selectUser($user);
205        if ($data == false) return false;
206
207        if (isset($data['hash'])) unset($data['hash']);
208        if (isset($data['clean'])) unset($data['clean']);
209
210        if ($requireGroups) {
211            $data['grps'] = $this->selectUserGroups($data);
212            if ($data['grps'] === false) return false;
213        }
214
215        return $data;
216    }
217
218    /**
219     * Create a new User [implement only where required/possible]
220     *
221     * Returns false if the user already exists, null when an error
222     * occurred and true if everything went well.
223     *
224     * The new user HAS TO be added to the default group by this
225     * function!
226     *
227     * Set addUser capability when implemented
228     *
229     * @param string $user
230     * @param string $clear
231     * @param string $name
232     * @param string $mail
233     * @param null|array $grps
234     * @return bool|null
235     */
236    public function createUser($user, $clear, $name, $mail, $grps = null)
237    {
238        global $conf;
239
240        if (($info = $this->getUserData($user, false)) !== false) {
241            msg($this->getLang('userexists'), -1);
242            return false; // user already exists
243        }
244
245        // prepare data
246        if ($grps == null) $grps = array();
247        array_unshift($grps, $conf['defaultgroup']);
248        $grps = array_unique($grps);
249        $hash = auth_cryptPassword($clear);
250        $userdata = compact('user', 'clear', 'hash', 'name', 'mail');
251
252        // action protected by transaction
253        $this->pdo->beginTransaction();
254        {
255            // insert the user
256            $ok = $this->query($this->getConf('insert-user'), $userdata);
257            if ($ok === false) goto FAIL;
258            $userdata = $this->getUserData($user, false);
259            if ($userdata === false) goto FAIL;
260
261            // create all groups that do not exist, the refetch the groups
262            $allgroups = $this->selectGroups();
263            foreach ($grps as $group) {
264                if (!isset($allgroups[$group])) {
265                    $ok = $this->addGroup($group);
266                    if ($ok === false) goto FAIL;
267                }
268            }
269            $allgroups = $this->selectGroups();
270
271            // add user to the groups
272            foreach ($grps as $group) {
273                $ok = $this->joinGroup($userdata, $allgroups[$group]);
274                if ($ok === false) goto FAIL;
275            }
276        }
277        $this->pdo->commit();
278        return true;
279
280        // something went wrong, rollback
281        FAIL:
282        $this->pdo->rollBack();
283        $this->debugMsg('Transaction rolled back', 0, __LINE__);
284        msg($this->getLang('writefail'), -1);
285        return null; // return error
286    }
287
288    /**
289     * Modify user data
290     *
291     * @param string $user nick of the user to be changed
292     * @param array $changes array of field/value pairs to be changed (password will be clear text)
293     * @return  bool
294     */
295    public function modifyUser($user, $changes)
296    {
297        // secure everything in transaction
298        $this->pdo->beginTransaction();
299        {
300            $olddata = $this->getUserData($user);
301            $oldgroups = $olddata['grps'];
302            unset($olddata['grps']);
303
304            // changing the user name?
305            if (isset($changes['user'])) {
306                if ($this->getUserData($changes['user'], false)) goto FAIL;
307                $params = $olddata;
308                $params['newlogin'] = $changes['user'];
309
310                $ok = $this->query($this->getConf('update-user-login'), $params);
311                if ($ok === false) goto FAIL;
312            }
313
314            // changing the password?
315            if (isset($changes['pass'])) {
316                $params = $olddata;
317                $params['clear'] = $changes['pass'];
318                $params['hash'] = auth_cryptPassword($changes['pass']);
319
320                $ok = $this->query($this->getConf('update-user-pass'), $params);
321                if ($ok === false) goto FAIL;
322            }
323
324            // changing info?
325            if (isset($changes['mail']) || isset($changes['name'])) {
326                $params = $olddata;
327                if (isset($changes['mail'])) $params['mail'] = $changes['mail'];
328                if (isset($changes['name'])) $params['name'] = $changes['name'];
329
330                $ok = $this->query($this->getConf('update-user-info'), $params);
331                if ($ok === false) goto FAIL;
332            }
333
334            // changing groups?
335            if (isset($changes['grps'])) {
336                $allgroups = $this->selectGroups();
337
338                // remove membership for previous groups
339                foreach ($oldgroups as $group) {
340                    if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) {
341                        $ok = $this->leaveGroup($olddata, $allgroups[$group]);
342                        if ($ok === false) goto FAIL;
343                    }
344                }
345
346                // create all new groups that are missing
347                $added = 0;
348                foreach ($changes['grps'] as $group) {
349                    if (!isset($allgroups[$group])) {
350                        $ok = $this->addGroup($group);
351                        if ($ok === false) goto FAIL;
352                        $added++;
353                    }
354                }
355                // reload group info
356                if ($added > 0) $allgroups = $this->selectGroups();
357
358                // add membership for new groups
359                foreach ($changes['grps'] as $group) {
360                    if (!in_array($group, $oldgroups)) {
361                        $ok = $this->joinGroup($olddata, $allgroups[$group]);
362                        if ($ok === false) goto FAIL;
363                    }
364                }
365            }
366
367        }
368        $this->pdo->commit();
369        return true;
370
371        // something went wrong, rollback
372        FAIL:
373        $this->pdo->rollBack();
374        $this->debugMsg('Transaction rolled back', 0, __LINE__);
375        msg($this->getLang('writefail'), -1);
376        return false; // return error
377    }
378
379    /**
380     * Delete one or more users
381     *
382     * Set delUser capability when implemented
383     *
384     * @param array $users
385     * @return  int    number of users deleted
386     */
387    public function deleteUsers($users)
388    {
389        $count = 0;
390        foreach ($users as $user) {
391            if ($this->deleteUser($user)) $count++;
392        }
393        return $count;
394    }
395
396    /**
397     * Bulk retrieval of user data [implement only where required/possible]
398     *
399     * Set getUsers capability when implemented
400     *
401     * @param int $start index of first user to be returned
402     * @param int $limit max number of users to be returned
403     * @param array $filter array of field/pattern pairs, null for no filter
404     * @return  array list of userinfo (refer getUserData for internal userinfo details)
405     */
406    public function retrieveUsers($start = 0, $limit = -1, $filter = null)
407    {
408        if ($limit < 0) $limit = 10000; // we don't support no limit
409        if (is_null($filter)) $filter = array();
410
411        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
412        foreach (array('user', 'name', 'mail', 'group') as $key) {
413            if (!isset($filter[$key])) {
414                $filter[$key] = '%';
415            } else {
416                $filter[$key] = '%' . $filter[$key] . '%';
417            }
418        }
419        $filter['start'] = (int)$start;
420        $filter['end'] = (int)$start + $limit;
421        $filter['limit'] = (int)$limit;
422
423        $result = $this->query($this->getConf('list-users'), $filter);
424        if (!$result) return array();
425        $users = array();
426        if (is_array($result)) {
427            foreach ($result as $row) {
428                if (!isset($row['user'])) {
429                    $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__);
430                    return array();
431                }
432                $users[] = $this->getUserData($row['user']);
433            }
434        } else {
435            $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__);
436        }
437        return $users;
438    }
439
440    /**
441     * Return a count of the number of user which meet $filter criteria
442     *
443     * @param array $filter array of field/pattern pairs, empty array for no filter
444     * @return int
445     */
446    public function getUserCount($filter = array())
447    {
448        if (is_null($filter)) $filter = array();
449
450        if (isset($filter['grps'])) $filter['group'] = $filter['grps'];
451        foreach (array('user', 'name', 'mail', 'group') as $key) {
452            if (!isset($filter[$key])) {
453                $filter[$key] = '%';
454            } else {
455                $filter[$key] = '%' . $filter[$key] . '%';
456            }
457        }
458
459        $result = $this->query($this->getConf('count-users'), $filter);
460        if (!$result || !isset($result[0]['count'])) {
461            $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__);
462        }
463        return (int)$result[0]['count'];
464    }
465
466    /**
467     * Create a new group with the given name
468     *
469     * @param string $group
470     * @return bool
471     */
472    public function addGroup($group)
473    {
474        $sql = $this->getConf('insert-group');
475
476        $result = $this->query($sql, array(':group' => $group));
477        $this->clearGroupCache();
478        if ($result === false) return false;
479        return true;
480    }
481
482    /**
483     * Retrieve groups
484     *
485     * Set getGroups capability when implemented
486     *
487     * @param int $start
488     * @param int $limit
489     * @return  array
490     */
491    public function retrieveGroups($start = 0, $limit = 0)
492    {
493        $groups = array_keys($this->selectGroups());
494        if ($groups === false) return array();
495
496        if (!$limit) {
497            return array_splice($groups, $start);
498        } else {
499            return array_splice($groups, $start, $limit);
500        }
501    }
502
503    /**
504     * Select data of a specified user
505     *
506     * @param string $user the user name
507     * @return bool|array user data, false on error
508     */
509    protected function selectUser($user)
510    {
511        $sql = $this->getConf('select-user');
512
513        $result = $this->query($sql, array(':user' => $user));
514        if (!$result) return false;
515
516        if (count($result) > 1) {
517            $this->debugMsg('Found more than one matching user', -1, __LINE__);
518            return false;
519        }
520
521        $data = array_shift($result);
522        $dataok = true;
523
524        if (!isset($data['user'])) {
525            $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__);
526            $dataok = false;
527        }
528        if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(array('check-pass'))) {
529            $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__);
530            $dataok = false;
531        }
532        if (!isset($data['name'])) {
533            $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__);
534            $dataok = false;
535        }
536        if (!isset($data['mail'])) {
537            $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__);
538            $dataok = false;
539        }
540
541        if (!$dataok) return false;
542        return $data;
543    }
544
545    /**
546     * Delete a user after removing all their group memberships
547     *
548     * @param string $user
549     * @return bool true when the user was deleted
550     */
551    protected function deleteUser($user)
552    {
553        $this->pdo->beginTransaction();
554        {
555            $userdata = $this->getUserData($user);
556            if ($userdata === false) goto FAIL;
557            $allgroups = $this->selectGroups();
558
559            // remove group memberships (ignore errors)
560            foreach ($userdata['grps'] as $group) {
561                if (isset($allgroups[$group])) {
562                    $this->leaveGroup($userdata, $allgroups[$group]);
563                }
564            }
565
566            $ok = $this->query($this->getConf('delete-user'), $userdata);
567            if ($ok === false) goto FAIL;
568        }
569        $this->pdo->commit();
570        return true;
571
572        FAIL:
573        $this->pdo->rollBack();
574        return false;
575    }
576
577    /**
578     * Select all groups of a user
579     *
580     * @param array $userdata The userdata as returned by _selectUser()
581     * @return array|bool list of group names, false on error
582     */
583    protected function selectUserGroups($userdata)
584    {
585        global $conf;
586        $sql = $this->getConf('select-user-groups');
587        $result = $this->query($sql, $userdata);
588        if ($result === false) return false;
589
590        $groups = array($conf['defaultgroup']); // always add default config
591        if (is_array($result)) {
592            foreach ($result as $row) {
593                if (!isset($row['group'])) {
594                    $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__);
595                    return false;
596                }
597                $groups[] = $row['group'];
598            }
599        } else {
600            $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__);
601        }
602
603        $groups = array_unique($groups);
604        Sort::sort($groups);
605        return $groups;
606    }
607
608    /**
609     * Select all available groups
610     *
611     * @return array|bool list of all available groups and their properties
612     */
613    protected function selectGroups()
614    {
615        if ($this->groupcache) return $this->groupcache;
616
617        $sql = $this->getConf('select-groups');
618        $result = $this->query($sql);
619        if ($result === false) return false;
620
621        $groups = array();
622        if (is_array($result)) {
623            foreach ($result as $row) {
624                if (!isset($row['group'])) {
625                    $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__);
626                    return false;
627                }
628
629                // relayout result with group name as key
630                $group = $row['group'];
631                $groups[$group] = $row;
632            }
633        } else {
634            $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__);
635        }
636
637        Sort::ksort($groups);
638        return $groups;
639    }
640
641    /**
642     * Remove all entries from the group cache
643     */
644    protected function clearGroupCache()
645    {
646        $this->groupcache = null;
647    }
648
649    /**
650     * Adds the user to the group
651     *
652     * @param array $userdata all the user data
653     * @param array $groupdata all the group data
654     * @return bool
655     */
656    protected function joinGroup($userdata, $groupdata)
657    {
658        $data = array_merge($userdata, $groupdata);
659        $sql = $this->getConf('join-group');
660        $result = $this->query($sql, $data);
661        if ($result === false) return false;
662        return true;
663    }
664
665    /**
666     * Removes the user from the group
667     *
668     * @param array $userdata all the user data
669     * @param array $groupdata all the group data
670     * @return bool
671     */
672    protected function leaveGroup($userdata, $groupdata)
673    {
674        $data = array_merge($userdata, $groupdata);
675        $sql = $this->getConf('leave-group');
676        $result = $this->query($sql, $data);
677        if ($result === false) return false;
678        return true;
679    }
680
681    /**
682     * Executes a query
683     *
684     * @param string $sql The SQL statement to execute
685     * @param array $arguments Named parameters to be used in the statement
686     * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error
687     */
688    protected function query($sql, $arguments = array())
689    {
690        $sql = trim($sql);
691        if (empty($sql)) {
692            $this->debugMsg('No SQL query given', -1, __LINE__);
693            return false;
694        }
695
696        // execute
697        $params = array();
698        $sth = $this->pdo->prepare($sql);
699        $result = false;
700        try {
701            // prepare parameters - we only use those that exist in the SQL
702            foreach ($arguments as $key => $value) {
703                if (is_array($value)) continue;
704                if (is_object($value)) continue;
705                if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed
706                if (strpos($sql, $key) === false) continue; // skip if parameter is missing
707
708                if (is_int($value)) {
709                    $sth->bindValue($key, $value, PDO::PARAM_INT);
710                } else {
711                    $sth->bindValue($key, $value);
712                }
713                $params[$key] = $value; //remember for debugging
714            }
715
716            $sth->execute();
717            // only report last line's result
718            $hasnextrowset = true;
719            $currentsql = $sql;
720            while ($hasnextrowset) {
721                if (strtolower(substr($currentsql, 0, 6)) == 'select') {
722                    $result = $sth->fetchAll();
723                } else {
724                    $result = $sth->rowCount();
725                }
726                $semi_pos = strpos($currentsql, ';');
727                if ($semi_pos) {
728                    $currentsql = trim(substr($currentsql, $semi_pos + 1));
729                }
730                try {
731                    $hasnextrowset = $sth->nextRowset(); // run next rowset
732                } catch (PDOException $rowset_e) {
733                    $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time
734                }
735            }
736        } catch (Exception $e) {
737            // report the caller's line
738            $trace = debug_backtrace();
739            $line = $trace[0]['line'];
740            $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST'));
741            $this->debugMsg($e, -1, $line);
742            $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line);
743        }
744        $sth->closeCursor();
745        $sth = null;
746
747        return $result;
748    }
749
750    /**
751     * Wrapper around msg() but outputs only when debug is enabled
752     *
753     * @param string|Exception $message
754     * @param int $err
755     * @param int $line
756     */
757    protected function debugMsg($message, $err = 0, $line = 0)
758    {
759        if (!$this->getConf('debug')) return;
760        if (is_a($message, 'Exception')) {
761            $err = -1;
762            $msg = $message->getMessage();
763            if (!$line) $line = $message->getLine();
764        } else {
765            $msg = $message;
766        }
767
768        if (defined('DOKU_UNITTEST')) {
769            printf("\n%s, %s:%d\n", $msg, __FILE__, $line);
770        } else {
771            msg('authpdo: ' . $msg, $err, $line, __FILE__);
772        }
773    }
774
775    /**
776     * Check if the given config strings are set
777     *
778     * @param string[] $keys
779     * @return  bool
780     * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
781     *
782     */
783    protected function checkConfig($keys)
784    {
785        foreach ($keys as $key) {
786            $params = explode(':', $key);
787            $key = array_shift($params);
788            $sql = trim($this->getConf($key));
789
790            // check if sql is set
791            if (!$sql) return false;
792            // check if needed params are there
793            foreach ($params as $param) {
794                if (strpos($sql, ":$param") === false) return false;
795            }
796        }
797
798        return true;
799    }
800
801    /**
802     * create an approximation of the SQL string with parameters replaced
803     *
804     * @param string $sql
805     * @param array $params
806     * @param bool $htmlescape Should the result be escaped for output in HTML?
807     * @return string
808     */
809    protected function debugSQL($sql, $params, $htmlescape = true)
810    {
811        foreach ($params as $key => $val) {
812            if (is_int($val)) {
813                $val = $this->pdo->quote($val, PDO::PARAM_INT);
814            } elseif (is_bool($val)) {
815                $val = $this->pdo->quote($val, PDO::PARAM_BOOL);
816            } elseif (is_null($val)) {
817                $val = 'NULL';
818            } else {
819                $val = $this->pdo->quote($val);
820            }
821            $sql = str_replace($key, $val, $sql);
822        }
823        if ($htmlescape) $sql = hsc($sql);
824        return $sql;
825    }
826}
827
828// vim:ts=4:sw=4:et:
829