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