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