1 <?php
2 
3 /**
4  * Authentication library
5  *
6  * Including this file will automatically try to login
7  * a user by calling auth_login()
8  *
9  * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
10  * @author     Andreas Gohr <andi@splitbrain.org>
11  */
12 
13 use dokuwiki\ErrorHandler;
14 use dokuwiki\JWT;
15 use dokuwiki\Utf8\PhpString;
16 use dokuwiki\Extension\AuthPlugin;
17 use dokuwiki\Extension\Event;
18 use dokuwiki\Extension\PluginController;
19 use dokuwiki\PassHash;
20 use dokuwiki\Subscriptions\RegistrationSubscriptionSender;
21 use phpseclib3\Crypt\AES;
22 use phpseclib3\Crypt\Common\SymmetricKey;
23 use phpseclib3\Exception\BadDecryptionException;
24 
25 /**
26  * Initialize the auth system.
27  *
28  * This function is automatically called at the end of init.php
29  *
30  * This used to be the main() of the auth.php
31  *
32  * @todo backend loading maybe should be handled by the class autoloader
33  * @todo maybe split into multiple functions at the XXX marked positions
34  * @triggers AUTH_LOGIN_CHECK
35  * @return bool
36  */
37 function auth_setup()
38 {
39     global $conf;
40     /* @var AuthPlugin $auth */
41     global $auth;
42     /* @var Input $INPUT */
43     global $INPUT;
44     global $AUTH_ACL;
45     global $lang;
46     /* @var PluginController $plugin_controller */
47     global $plugin_controller;
48     $AUTH_ACL = [];
49 
50     // unset REMOTE_USER if empty
51     if ($INPUT->server->str('REMOTE_USER') === '') {
52         $INPUT->server->remove('REMOTE_USER');
53     }
54 
55     if (!$conf['useacl']) return false;
56 
57     // try to load auth backend from plugins
58     foreach ($plugin_controller->getList('auth') as $plugin) {
59         if ($conf['authtype'] === $plugin) {
60             $auth = $plugin_controller->load('auth', $plugin);
61             break;
62         }
63     }
64 
65     if (!$auth instanceof AuthPlugin) {
66         msg($lang['authtempfail'], -1);
67         return false;
68     }
69 
70     if ($auth->success == false) {
71         // degrade to unauthenticated user
72         $auth = null;
73         auth_logoff();
74         msg($lang['authtempfail'], -1);
75         return false;
76     }
77 
78     // do the login either by cookie or provided credentials XXX
79     $INPUT->set('http_credentials', false);
80     if (!$conf['rememberme']) $INPUT->set('r', false);
81 
82     // Populate Basic Auth user/password from Authorization header
83     // Note: with FastCGI, data is in REDIRECT_HTTP_AUTHORIZATION instead of HTTP_AUTHORIZATION
84     $header = $INPUT->server->str('HTTP_AUTHORIZATION') ?: $INPUT->server->str('REDIRECT_HTTP_AUTHORIZATION');
85     if (preg_match('~^Basic ([a-z\d/+]*={0,2})$~i', $header, $matches)) {
86         $userpass = explode(':', base64_decode($matches[1]));
87         [$_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']] = $userpass;
88     }
89 
90     // if no credentials were given try to use HTTP auth (for SSO)
91     if (!$INPUT->str('u') && empty($_COOKIE[DOKU_COOKIE]) && !empty($INPUT->server->str('PHP_AUTH_USER'))) {
92         $INPUT->set('u', $INPUT->server->str('PHP_AUTH_USER'));
93         $INPUT->set('p', $INPUT->server->str('PHP_AUTH_PW'));
94         $INPUT->set('http_credentials', true);
95     }
96 
97     // apply cleaning (auth specific user names, remove control chars)
98     if (true === $auth->success) {
99         $INPUT->set('u', $auth->cleanUser(stripctl($INPUT->str('u'))));
100         $INPUT->set('p', stripctl($INPUT->str('p')));
101     }
102 
103     if (!auth_tokenlogin()) {
104         $ok = null;
105 
106         if ($auth->canDo('external')) {
107             $ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r'));
108         }
109 
110         if ($ok === null) {
111             // external trust mechanism not in place, or returns no result,
112             // then attempt auth_login
113             $evdata = [
114                 'user' => $INPUT->str('u'),
115                 'password' => $INPUT->str('p'),
116                 'sticky' => $INPUT->bool('r'),
117                 'silent' => $INPUT->bool('http_credentials')
118             ];
119             Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper');
120         }
121     }
122 
123     //load ACL into a global array XXX
124     $AUTH_ACL = auth_loadACL();
125 
126     return true;
127 }
128 
129 /**
130  * Loads the ACL setup and handle user wildcards
131  *
132  * @author Andreas Gohr <andi@splitbrain.org>
133  *
134  * @return array
135  */
136 function auth_loadACL()
137 {
138     global $config_cascade;
139     global $USERINFO;
140     /* @var Input $INPUT */
141     global $INPUT;
142 
143     if (!is_readable($config_cascade['acl']['default'])) return [];
144 
145     $acl = file($config_cascade['acl']['default']);
146 
147     $out = [];
148     foreach ($acl as $line) {
149         $line = trim($line);
150         if (empty($line) || ($line[0] == '#')) continue; // skip blank lines & comments
151         [$id, $rest] = preg_split('/[ \t]+/', $line, 2);
152 
153         // substitute user wildcard first (its 1:1)
154         if (strstr($line, '%USER%')) {
155             // if user is not logged in, this ACL line is meaningless - skip it
156             if (!$INPUT->server->has('REMOTE_USER')) continue;
157 
158             $id   = str_replace('%USER%', cleanID($INPUT->server->str('REMOTE_USER')), $id);
159             $rest = str_replace('%USER%', auth_nameencode($INPUT->server->str('REMOTE_USER')), $rest);
160         }
161 
162         // substitute group wildcard (its 1:m)
163         if (strstr($line, '%GROUP%')) {
164             // if user is not logged in, grps is empty, no output will be added (i.e. skipped)
165             if (isset($USERINFO['grps'])) {
166                 foreach ((array) $USERINFO['grps'] as $grp) {
167                     $nid   = str_replace('%GROUP%', cleanID($grp), $id);
168                     $nrest = str_replace('%GROUP%', '@' . auth_nameencode($grp), $rest);
169                     $out[] = "$nid\t$nrest";
170                 }
171             }
172         } else {
173             $out[] = "$id\t$rest";
174         }
175     }
176 
177     return $out;
178 }
179 
180 /**
181  * Try a token login
182  *
183  * @return bool true if token login succeeded
184  */
185 function auth_tokenlogin()
186 {
187     global $USERINFO;
188     global $INPUT;
189     /** @var DokuWiki_Auth_Plugin $auth */
190     global $auth;
191     if (!$auth) return false;
192 
193     $headers = [];
194 
195     // try to get the headers from Apache
196     if (function_exists('getallheaders')) {
197         $headers = getallheaders();
198         if (is_array($headers)) {
199             $headers = array_change_key_case($headers);
200         }
201     }
202 
203     // get the headers from $_SERVER
204     if (!$headers) {
205         foreach ($_SERVER as $key => $value) {
206             if (substr($key, 0, 5) === 'HTTP_') {
207                 $headers[strtolower(substr($key, 5))] = $value;
208             }
209         }
210     }
211 
212     // check authorization header
213     if (isset($headers['authorization'])) {
214         [$type, $token] = sexplode(' ', $headers['authorization'], 2);
215         if ($type !== 'Bearer') $token = ''; // not the token we want
216     }
217 
218     // check x-dokuwiki-token header
219     if (isset($headers['x-dokuwiki-token'])) {
220         $token = $headers['x-dokuwiki-token'];
221     }
222 
223     if (empty($token)) return false;
224 
225     // check token
226     try {
227         $authtoken = JWT::validate($token);
228     } catch (Exception $e) {
229         msg(hsc($e->getMessage()), -1);
230         return false;
231     }
232 
233     // fetch user info from backend
234     $user = $authtoken->getUser();
235     $USERINFO = $auth->getUserData($user);
236     if (!$USERINFO) return false;
237 
238     // the code is correct, set up user
239     $INPUT->server->set('REMOTE_USER', $user);
240     $_SESSION[DOKU_COOKIE]['auth']['user'] = $user;
241     $_SESSION[DOKU_COOKIE]['auth']['pass'] = 'nope';
242     $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
243 
244     return true;
245 }
246 
247 /**
248  * Event hook callback for AUTH_LOGIN_CHECK
249  *
250  * @param array $evdata
251  * @return bool
252  * @throws Exception
253  */
254 function auth_login_wrapper($evdata)
255 {
256     return auth_login(
257         $evdata['user'],
258         $evdata['password'],
259         $evdata['sticky'],
260         $evdata['silent']
261     );
262 }
263 
264 /**
265  * This tries to login the user based on the sent auth credentials
266  *
267  * The authentication works like this: if a username was given
268  * a new login is assumed and user/password are checked. If they
269  * are correct the password is encrypted with blowfish and stored
270  * together with the username in a cookie - the same info is stored
271  * in the session, too. Additonally a browserID is stored in the
272  * session.
273  *
274  * If no username was given the cookie is checked: if the username,
275  * crypted password and browserID match between session and cookie
276  * no further testing is done and the user is accepted
277  *
278  * If a cookie was found but no session info was availabe the
279  * blowfish encrypted password from the cookie is decrypted and
280  * together with username rechecked by calling this function again.
281  *
282  * On a successful login $_SERVER[REMOTE_USER] and $USERINFO
283  * are set.
284  *
285  * @param string $user Username
286  * @param string $pass Cleartext Password
287  * @param bool $sticky Cookie should not expire
288  * @param bool $silent Don't show error on bad auth
289  * @return bool true on successful auth
290  * @throws Exception
291  *
292  * @author  Andreas Gohr <andi@splitbrain.org>
293  */
294 function auth_login($user, $pass, $sticky = false, $silent = false)
295 {
296     global $USERINFO;
297     global $conf;
298     global $lang;
299     /* @var AuthPlugin $auth */
300     global $auth;
301     /* @var Input $INPUT */
302     global $INPUT;
303 
304     if (!$auth instanceof AuthPlugin) return false;
305 
306     if (!empty($user)) {
307         //usual login
308         if (!empty($pass) && $auth->checkPass($user, $pass)) {
309             // make logininfo globally available
310             $INPUT->server->set('REMOTE_USER', $user);
311             $secret                 = auth_cookiesalt(!$sticky, true); //bind non-sticky to session
312             auth_setCookie($user, auth_encrypt($pass, $secret), $sticky);
313             return true;
314         } else {
315             //invalid credentials - log off
316             if (!$silent) {
317                 http_status(403, 'Login failed');
318                 msg($lang['badlogin'], -1);
319             }
320             auth_logoff();
321             return false;
322         }
323     } else {
324         // read cookie information
325         [$user, $sticky, $pass] = auth_getCookie();
326         if ($user && $pass) {
327             // we got a cookie - see if we can trust it
328 
329             // get session info
330             if (isset($_SESSION[DOKU_COOKIE])) {
331                 $session = $_SESSION[DOKU_COOKIE]['auth'];
332                 if (
333                     isset($session) &&
334                     $auth->useSessionCache($user) &&
335                     ($session['time'] >= time() - $conf['auth_security_timeout']) &&
336                     ($session['user'] == $user) &&
337                     ($session['pass'] == sha1($pass)) && //still crypted
338                     ($session['buid'] == auth_browseruid())
339                 ) {
340                     // he has session, cookie and browser right - let him in
341                     $INPUT->server->set('REMOTE_USER', $user);
342                     $USERINFO = $session['info']; //FIXME move all references to session
343                     return true;
344                 }
345             }
346             // no we don't trust it yet - recheck pass but silent
347             $secret = auth_cookiesalt(!$sticky, true); //bind non-sticky to session
348             $pass   = auth_decrypt($pass, $secret);
349             return auth_login($user, $pass, $sticky, true);
350         }
351     }
352     //just to be sure
353     auth_logoff(true);
354     return false;
355 }
356 
357 /**
358  * Builds a pseudo UID from browser and IP data
359  *
360  * This is neither unique nor unfakable - still it adds some
361  * security. Using the first part of the IP makes sure
362  * proxy farms like AOLs are still okay.
363  *
364  * @author  Andreas Gohr <andi@splitbrain.org>
365  *
366  * @return  string  a SHA256 sum of various browser headers
367  */
368 function auth_browseruid()
369 {
370     /* @var Input $INPUT */
371     global $INPUT;
372 
373     $ip = clientIP(true);
374     // convert IP string to packed binary representation
375     $pip = inet_pton($ip);
376 
377     $uid = implode("\n", [
378         $INPUT->server->str('HTTP_USER_AGENT'),
379         $INPUT->server->str('HTTP_ACCEPT_LANGUAGE'),
380         substr($pip, 0, strlen($pip) / 2), // use half of the IP address (works for both IPv4 and IPv6)
381     ]);
382     return hash('sha256', $uid);
383 }
384 
385 /**
386  * Creates a random key to encrypt the password in cookies
387  *
388  * This function tries to read the password for encrypting
389  * cookies from $conf['metadir'].'/_htcookiesalt'
390  * if no such file is found a random key is created and
391  * and stored in this file.
392  *
393  * @param bool $addsession if true, the sessionid is added to the salt
394  * @param bool $secure if security is more important than keeping the old value
395  * @return  string
396  * @throws Exception
397  *
398  * @author  Andreas Gohr <andi@splitbrain.org>
399  */
400 function auth_cookiesalt($addsession = false, $secure = false)
401 {
402     if (defined('SIMPLE_TEST')) {
403         return 'test';
404     }
405     global $conf;
406     $file = $conf['metadir'] . '/_htcookiesalt';
407     if ($secure || !file_exists($file)) {
408         $file = $conf['metadir'] . '/_htcookiesalt2';
409     }
410     $salt = io_readFile($file);
411     if (empty($salt)) {
412         $salt = bin2hex(auth_randombytes(64));
413         io_saveFile($file, $salt);
414     }
415     if ($addsession) {
416         $salt .= session_id();
417     }
418     return $salt;
419 }
420 
421 /**
422  * Return cryptographically secure random bytes.
423  *
424  * @param int $length number of bytes
425  * @return string cryptographically secure random bytes
426  * @throws Exception
427  *
428  * @author Niklas Keller <me@kelunik.com>
429  */
430 function auth_randombytes($length)
431 {
432     return random_bytes($length);
433 }
434 
435 /**
436  * Cryptographically secure random number generator.
437  *
438  * @param int $min
439  * @param int $max
440  * @return int
441  * @throws Exception
442  *
443  * @author Niklas Keller <me@kelunik.com>
444  */
445 function auth_random($min, $max)
446 {
447     return random_int($min, $max);
448 }
449 
450 /**
451  * Encrypt data using the given secret using AES
452  *
453  * The mode is CBC with a random initialization vector, the key is derived
454  * using pbkdf2.
455  *
456  * @param string $data The data that shall be encrypted
457  * @param string $secret The secret/password that shall be used
458  * @return string The ciphertext
459  * @throws Exception
460  */
461 function auth_encrypt($data, $secret)
462 {
463     $iv     = auth_randombytes(16);
464     $cipher = new AES('cbc');
465     $cipher->setPassword($secret, 'pbkdf2', 'sha1', 'phpseclib');
466     $cipher->setIV($iv);
467 
468     /*
469     this uses the encrypted IV as IV as suggested in
470     http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf, Appendix C
471     for unique but necessarily random IVs. The resulting ciphertext is
472     compatible to ciphertext that was created using a "normal" IV.
473     */
474     return $cipher->encrypt($iv . $data);
475 }
476 
477 /**
478  * Decrypt the given AES ciphertext
479  *
480  * The mode is CBC, the key is derived using pbkdf2
481  *
482  * @param string $ciphertext The encrypted data
483  * @param string $secret     The secret/password that shall be used
484  * @return string|null The decrypted data
485  */
486 function auth_decrypt($ciphertext, $secret)
487 {
488     $iv     = substr($ciphertext, 0, 16);
489     $cipher = new AES('cbc');
490     $cipher->setPassword($secret, 'pbkdf2', 'sha1', 'phpseclib');
491     $cipher->setIV($iv);
492 
493     try {
494         return $cipher->decrypt(substr($ciphertext, 16));
495     } catch (BadDecryptionException $e) {
496         ErrorHandler::logException($e);
497         return null;
498     }
499 }
500 
501 /**
502  * Log out the current user
503  *
504  * This clears all authentication data and thus log the user
505  * off. It also clears session data.
506  *
507  * @author  Andreas Gohr <andi@splitbrain.org>
508  *
509  * @param bool $keepbc - when true, the breadcrumb data is not cleared
510  */
511 function auth_logoff($keepbc = false)
512 {
513     global $conf;
514     global $USERINFO;
515     /* @var AuthPlugin $auth */
516     global $auth;
517     /* @var Input $INPUT */
518     global $INPUT;
519 
520     // make sure the session is writable (it usually is)
521     @session_start();
522 
523     if (isset($_SESSION[DOKU_COOKIE]['auth']['user']))
524         unset($_SESSION[DOKU_COOKIE]['auth']['user']);
525     if (isset($_SESSION[DOKU_COOKIE]['auth']['pass']))
526         unset($_SESSION[DOKU_COOKIE]['auth']['pass']);
527     if (isset($_SESSION[DOKU_COOKIE]['auth']['info']))
528         unset($_SESSION[DOKU_COOKIE]['auth']['info']);
529     if (!$keepbc && isset($_SESSION[DOKU_COOKIE]['bc']))
530         unset($_SESSION[DOKU_COOKIE]['bc']);
531     $INPUT->server->remove('REMOTE_USER');
532     $USERINFO = null; //FIXME
533 
534     $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
535     setcookie(DOKU_COOKIE, '', [
536         'expires' => time() - 600000,
537         'path' => $cookieDir,
538         'secure' => ($conf['securecookie'] && is_ssl()),
539         'httponly' => true,
540         'samesite' => $conf['samesitecookie'] ?: null, // null means browser default
541     ]);
542 
543     if ($auth instanceof AuthPlugin) {
544         $auth->logOff();
545     }
546 }
547 
548 /**
549  * Check if a user is a manager
550  *
551  * Should usually be called without any parameters to check the current
552  * user.
553  *
554  * The info is available through $INFO['ismanager'], too
555  *
556  * @param string $user Username
557  * @param array $groups List of groups the user is in
558  * @param bool $adminonly when true checks if user is admin
559  * @param bool $recache set to true to refresh the cache
560  * @return bool
561  * @see    auth_isadmin
562  *
563  * @author Andreas Gohr <andi@splitbrain.org>
564  */
565 function auth_ismanager($user = null, $groups = null, $adminonly = false, $recache = false)
566 {
567     global $conf;
568     global $USERINFO;
569     /* @var AuthPlugin $auth */
570     global $auth;
571     /* @var Input $INPUT */
572     global $INPUT;
573 
574 
575     if (!$auth instanceof AuthPlugin) return false;
576     if (is_null($user)) {
577         if (!$INPUT->server->has('REMOTE_USER')) {
578             return false;
579         } else {
580             $user = $INPUT->server->str('REMOTE_USER');
581         }
582     }
583     if (is_null($groups)) {
584         // checking the logged in user, or another one?
585         if ($USERINFO && $user === $INPUT->server->str('REMOTE_USER')) {
586             $groups =  (array) $USERINFO['grps'];
587         } else {
588             $groups = $auth->getUserData($user);
589             $groups = $groups ? $groups['grps'] : [];
590         }
591     }
592 
593     // prefer cached result
594     static $cache = [];
595     $cachekey = serialize([$user, $adminonly, $groups]);
596     if (!isset($cache[$cachekey]) || $recache) {
597         // check superuser match
598         $ok = auth_isMember($conf['superuser'], $user, $groups);
599 
600         // check managers
601         if (!$ok && !$adminonly) {
602             $ok = auth_isMember($conf['manager'], $user, $groups);
603         }
604 
605         $cache[$cachekey] = $ok;
606     }
607 
608     return $cache[$cachekey];
609 }
610 
611 /**
612  * Check if a user is admin
613  *
614  * Alias to auth_ismanager with adminonly=true
615  *
616  * The info is available through $INFO['isadmin'], too
617  *
618  * @param string $user Username
619  * @param array $groups List of groups the user is in
620  * @param bool $recache set to true to refresh the cache
621  * @return bool
622  * @author Andreas Gohr <andi@splitbrain.org>
623  * @see auth_ismanager()
624  *
625  */
626 function auth_isadmin($user = null, $groups = null, $recache = false)
627 {
628     return auth_ismanager($user, $groups, true, $recache);
629 }
630 
631 /**
632  * Match a user and his groups against a comma separated list of
633  * users and groups to determine membership status
634  *
635  * Note: all input should NOT be nameencoded.
636  *
637  * @param string $memberlist commaseparated list of allowed users and groups
638  * @param string $user       user to match against
639  * @param array  $groups     groups the user is member of
640  * @return bool       true for membership acknowledged
641  */
642 function auth_isMember($memberlist, $user, array $groups)
643 {
644     /* @var AuthPlugin $auth */
645     global $auth;
646     if (!$auth instanceof AuthPlugin) return false;
647 
648     // clean user and groups
649     if (!$auth->isCaseSensitive()) {
650         $user   = PhpString::strtolower($user);
651         $groups = array_map([PhpString::class, 'strtolower'], $groups);
652     }
653     $user   = $auth->cleanUser($user);
654     $groups = array_map([$auth, 'cleanGroup'], $groups);
655 
656     // extract the memberlist
657     $members = explode(',', $memberlist);
658     $members = array_map('trim', $members);
659     $members = array_unique($members);
660     $members = array_filter($members);
661 
662     // compare cleaned values
663     foreach ($members as $member) {
664         if ($member == '@ALL') return true;
665         if (!$auth->isCaseSensitive()) $member = PhpString::strtolower($member);
666         if ($member[0] == '@') {
667             $member = $auth->cleanGroup(substr($member, 1));
668             if (in_array($member, $groups)) return true;
669         } else {
670             $member = $auth->cleanUser($member);
671             if ($member == $user) return true;
672         }
673     }
674 
675     // still here? not a member!
676     return false;
677 }
678 
679 /**
680  * Convinience function for auth_aclcheck()
681  *
682  * This checks the permissions for the current user
683  *
684  * @author  Andreas Gohr <andi@splitbrain.org>
685  *
686  * @param  string  $id  page ID (needs to be resolved and cleaned)
687  * @return int          permission level
688  */
689 function auth_quickaclcheck($id)
690 {
691     global $conf;
692     global $USERINFO;
693     /* @var Input $INPUT */
694     global $INPUT;
695     # if no ACL is used always return upload rights
696     if (!$conf['useacl']) return AUTH_UPLOAD;
697     return auth_aclcheck($id, $INPUT->server->str('REMOTE_USER'), is_array($USERINFO) ? $USERINFO['grps'] : []);
698 }
699 
700 /**
701  * Returns the maximum rights a user has for the given ID or its namespace
702  *
703  * @author  Andreas Gohr <andi@splitbrain.org>
704  *
705  * @triggers AUTH_ACL_CHECK
706  * @param  string       $id     page ID (needs to be resolved and cleaned)
707  * @param  string       $user   Username
708  * @param  array|null   $groups Array of groups the user is in
709  * @return int             permission level
710  */
711 function auth_aclcheck($id, $user, $groups)
712 {
713     $data = [
714         'id'     => $id ?? '',
715         'user'   => $user,
716         'groups' => $groups
717     ];
718 
719     return Event::createAndTrigger('AUTH_ACL_CHECK', $data, 'auth_aclcheck_cb');
720 }
721 
722 /**
723  * default ACL check method
724  *
725  * DO NOT CALL DIRECTLY, use auth_aclcheck() instead
726  *
727  * @author  Andreas Gohr <andi@splitbrain.org>
728  *
729  * @param  array $data event data
730  * @return int   permission level
731  */
732 function auth_aclcheck_cb($data)
733 {
734     $id     =& $data['id'];
735     $user   =& $data['user'];
736     $groups =& $data['groups'];
737 
738     global $conf;
739     global $AUTH_ACL;
740     /* @var AuthPlugin $auth */
741     global $auth;
742 
743     // if no ACL is used always return upload rights
744     if (!$conf['useacl']) return AUTH_UPLOAD;
745     if (!$auth instanceof AuthPlugin) return AUTH_NONE;
746     if (!is_array($AUTH_ACL)) return AUTH_NONE;
747 
748     //make sure groups is an array
749     if (!is_array($groups)) $groups = [];
750 
751     //if user is superuser or in superusergroup return 255 (acl_admin)
752     if (auth_isadmin($user, $groups)) {
753         return AUTH_ADMIN;
754     }
755 
756     if (!$auth->isCaseSensitive()) {
757         $user   = PhpString::strtolower($user);
758         $groups = array_map([PhpString::class, 'strtolower'], $groups);
759     }
760     $user   = auth_nameencode($auth->cleanUser($user));
761     $groups = array_map([$auth, 'cleanGroup'], $groups);
762 
763     //prepend groups with @ and nameencode
764     foreach ($groups as &$group) {
765         $group = '@' . auth_nameencode($group);
766     }
767 
768     $ns   = getNS($id);
769     $perm = -1;
770 
771     //add ALL group
772     $groups[] = '@ALL';
773 
774     //add User
775     if ($user) $groups[] = $user;
776 
777     //check exact match first
778     $matches = preg_grep('/^' . preg_quote($id, '/') . '[ \t]+([^ \t]+)[ \t]+/', $AUTH_ACL);
779     if (count($matches)) {
780         foreach ($matches as $match) {
781             $match = preg_replace('/#.*$/', '', $match); //ignore comments
782             $acl   = preg_split('/[ \t]+/', $match);
783             if (!$auth->isCaseSensitive() && $acl[1] !== '@ALL') {
784                 $acl[1] = PhpString::strtolower($acl[1]);
785             }
786             if (!in_array($acl[1], $groups)) {
787                 continue;
788             }
789             if ($acl[2] > AUTH_DELETE) $acl[2] = AUTH_DELETE; //no admins in the ACL!
790             if ($acl[2] > $perm) {
791                 $perm = $acl[2];
792             }
793         }
794         if ($perm > -1) {
795             //we had a match - return it
796             return (int) $perm;
797         }
798     }
799 
800     //still here? do the namespace checks
801     if ($ns) {
802         $path = $ns . ':*';
803     } else {
804         $path = '*'; //root document
805     }
806 
807     do {
808         $matches = preg_grep('/^' . preg_quote($path, '/') . '[ \t]+([^ \t]+)[ \t]+/', $AUTH_ACL);
809         if (count($matches)) {
810             foreach ($matches as $match) {
811                 $match = preg_replace('/#.*$/', '', $match); //ignore comments
812                 $acl   = preg_split('/[ \t]+/', $match);
813                 if (!$auth->isCaseSensitive() && $acl[1] !== '@ALL') {
814                     $acl[1] = PhpString::strtolower($acl[1]);
815                 }
816                 if (!in_array($acl[1], $groups)) {
817                     continue;
818                 }
819                 if ($acl[2] > AUTH_DELETE) $acl[2] = AUTH_DELETE; //no admins in the ACL!
820                 if ($acl[2] > $perm) {
821                     $perm = $acl[2];
822                 }
823             }
824             //we had a match - return it
825             if ($perm != -1) {
826                 return (int) $perm;
827             }
828         }
829         //get next higher namespace
830         $ns = getNS($ns);
831 
832         if ($path != '*') {
833             $path = $ns . ':*';
834             if ($path == ':*') $path = '*';
835         } else {
836             //we did this already
837             //looks like there is something wrong with the ACL
838             //break here
839             msg('No ACL setup yet! Denying access to everyone.');
840             return AUTH_NONE;
841         }
842     } while (1); //this should never loop endless
843     return AUTH_NONE;
844 }
845 
846 /**
847  * Encode ASCII special chars
848  *
849  * Some auth backends allow special chars in their user and groupnames
850  * The special chars are encoded with this function. Only ASCII chars
851  * are encoded UTF-8 multibyte are left as is (different from usual
852  * urlencoding!).
853  *
854  * Decoding can be done with rawurldecode
855  *
856  * @author Andreas Gohr <gohr@cosmocode.de>
857  * @see rawurldecode()
858  *
859  * @param string $name
860  * @param bool $skip_group
861  * @return string
862  */
863 function auth_nameencode($name, $skip_group = false)
864 {
865     global $cache_authname;
866     $cache =& $cache_authname;
867     $name  = (string) $name;
868 
869     // never encode wildcard FS#1955
870     if ($name == '%USER%') return $name;
871     if ($name == '%GROUP%') return $name;
872 
873     if (!isset($cache[$name][$skip_group])) {
874         if ($skip_group && $name[0] == '@') {
875             $cache[$name][$skip_group] = '@' . preg_replace_callback(
876                 '/([\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f])/',
877                 'auth_nameencode_callback',
878                 substr($name, 1)
879             );
880         } else {
881             $cache[$name][$skip_group] = preg_replace_callback(
882                 '/([\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f])/',
883                 'auth_nameencode_callback',
884                 $name
885             );
886         }
887     }
888 
889     return $cache[$name][$skip_group];
890 }
891 
892 /**
893  * callback encodes the matches
894  *
895  * @param array $matches first complete match, next matching subpatterms
896  * @return string
897  */
898 function auth_nameencode_callback($matches)
899 {
900     return '%' . dechex(ord(substr($matches[1], -1)));
901 }
902 
903 /**
904  * Create a pronouncable password
905  *
906  * The $foruser variable might be used by plugins to run additional password
907  * policy checks, but is not used by the default implementation
908  *
909  * @param string $foruser username for which the password is generated
910  * @return string  pronouncable password
911  * @throws Exception
912  *
913  * @link     http://www.phpbuilder.com/annotate/message.php3?id=1014451
914  * @triggers AUTH_PASSWORD_GENERATE
915  *
916  * @author   Andreas Gohr <andi@splitbrain.org>
917  */
918 function auth_pwgen($foruser = '')
919 {
920     $data = [
921         'password' => '',
922         'foruser'  => $foruser
923     ];
924 
925     $evt = new Event('AUTH_PASSWORD_GENERATE', $data);
926     if ($evt->advise_before(true)) {
927         $c = 'bcdfghjklmnprstvwz'; //consonants except hard to speak ones
928         $v = 'aeiou'; //vowels
929         $a = $c . $v; //both
930         $s = '!$%&?+*~#-_:.;,'; // specials
931 
932         //use thre syllables...
933         for ($i = 0; $i < 3; $i++) {
934             $data['password'] .= $c[auth_random(0, strlen($c) - 1)];
935             $data['password'] .= $v[auth_random(0, strlen($v) - 1)];
936             $data['password'] .= $a[auth_random(0, strlen($a) - 1)];
937         }
938         //... and add a nice number and special
939         $data['password'] .= $s[auth_random(0, strlen($s) - 1)] . auth_random(10, 99);
940     }
941     $evt->advise_after();
942 
943     return $data['password'];
944 }
945 
946 /**
947  * Sends a password to the given user
948  *
949  * @author  Andreas Gohr <andi@splitbrain.org>
950  *
951  * @param string $user Login name of the user
952  * @param string $password The new password in clear text
953  * @return bool  true on success
954  */
955 function auth_sendPassword($user, $password)
956 {
957     global $lang;
958     /* @var AuthPlugin $auth */
959     global $auth;
960     if (!$auth instanceof AuthPlugin) return false;
961 
962     $user     = $auth->cleanUser($user);
963     $userinfo = $auth->getUserData($user, false);
964 
965     if (!$userinfo['mail']) return false;
966 
967     $text = rawLocale('password');
968     $trep = [
969         'FULLNAME' => $userinfo['name'],
970         'LOGIN'    => $user,
971         'PASSWORD' => $password
972     ];
973 
974     $mail = new Mailer();
975     $mail->to($mail->getCleanName($userinfo['name']) . ' <' . $userinfo['mail'] . '>');
976     $mail->subject($lang['regpwmail']);
977     $mail->setBody($text, $trep);
978     return $mail->send();
979 }
980 
981 /**
982  * Register a new user
983  *
984  * This registers a new user - Data is read directly from $_POST
985  *
986  * @return bool  true on success, false on any error
987  * @throws Exception
988  *
989  * @author  Andreas Gohr <andi@splitbrain.org>
990  */
991 function register()
992 {
993     global $lang;
994     global $conf;
995     /* @var AuthPlugin $auth */
996     global $auth;
997     global $INPUT;
998 
999     if (!$INPUT->post->bool('save')) return false;
1000     if (!actionOK('register')) return false;
1001 
1002     // gather input
1003     $login    = trim($auth->cleanUser($INPUT->post->str('login')));
1004     $fullname = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $INPUT->post->str('fullname')));
1005     $email    = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $INPUT->post->str('email')));
1006     $pass     = $INPUT->post->str('pass');
1007     $passchk  = $INPUT->post->str('passchk');
1008 
1009     if (empty($login) || empty($fullname) || empty($email)) {
1010         msg($lang['regmissing'], -1);
1011         return false;
1012     }
1013 
1014     if ($conf['autopasswd']) {
1015         $pass = auth_pwgen($login); // automatically generate password
1016     } elseif (empty($pass) || empty($passchk)) {
1017         msg($lang['regmissing'], -1); // complain about missing passwords
1018         return false;
1019     } elseif ($pass != $passchk) {
1020         msg($lang['regbadpass'], -1); // complain about misspelled passwords
1021         return false;
1022     }
1023 
1024     //check mail
1025     if (!mail_isvalid($email)) {
1026         msg($lang['regbadmail'], -1);
1027         return false;
1028     }
1029 
1030     //okay try to create the user
1031     if (!$auth->triggerUserMod('create', [$login, $pass, $fullname, $email])) {
1032         msg($lang['regfail'], -1);
1033         return false;
1034     }
1035 
1036     // send notification about the new user
1037     $subscription = new RegistrationSubscriptionSender();
1038     $subscription->sendRegister($login, $fullname, $email);
1039 
1040     // are we done?
1041     if (!$conf['autopasswd']) {
1042         msg($lang['regsuccess2'], 1);
1043         return true;
1044     }
1045 
1046     // autogenerated password? then send password to user
1047     if (auth_sendPassword($login, $pass)) {
1048         msg($lang['regsuccess'], 1);
1049         return true;
1050     } else {
1051         msg($lang['regmailfail'], -1);
1052         return false;
1053     }
1054 }
1055 
1056 /**
1057  * Update user profile
1058  *
1059  * @throws Exception
1060  *
1061  * @author    Christopher Smith <chris@jalakai.co.uk>
1062  */
1063 function updateprofile()
1064 {
1065     global $conf;
1066     global $lang;
1067     /* @var AuthPlugin $auth */
1068     global $auth;
1069     /* @var Input $INPUT */
1070     global $INPUT;
1071 
1072     if (!$INPUT->post->bool('save')) return false;
1073     if (!checkSecurityToken()) return false;
1074 
1075     if (!actionOK('profile')) {
1076         msg($lang['profna'], -1);
1077         return false;
1078     }
1079 
1080     $changes         = [];
1081     $changes['pass'] = $INPUT->post->str('newpass');
1082     $changes['name'] = $INPUT->post->str('fullname');
1083     $changes['mail'] = $INPUT->post->str('email');
1084 
1085     // check misspelled passwords
1086     if ($changes['pass'] != $INPUT->post->str('passchk')) {
1087         msg($lang['regbadpass'], -1);
1088         return false;
1089     }
1090 
1091     // clean fullname and email
1092     $changes['name'] = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $changes['name']));
1093     $changes['mail'] = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $changes['mail']));
1094 
1095     // no empty name and email (except the backend doesn't support them)
1096     if (
1097         (empty($changes['name']) && $auth->canDo('modName')) ||
1098         (empty($changes['mail']) && $auth->canDo('modMail'))
1099     ) {
1100         msg($lang['profnoempty'], -1);
1101         return false;
1102     }
1103     if (!mail_isvalid($changes['mail']) && $auth->canDo('modMail')) {
1104         msg($lang['regbadmail'], -1);
1105         return false;
1106     }
1107 
1108     $changes = array_filter($changes);
1109 
1110     // check for unavailable capabilities
1111     if (!$auth->canDo('modName')) unset($changes['name']);
1112     if (!$auth->canDo('modMail')) unset($changes['mail']);
1113     if (!$auth->canDo('modPass')) unset($changes['pass']);
1114 
1115     // anything to do?
1116     if ($changes === []) {
1117         msg($lang['profnochange'], -1);
1118         return false;
1119     }
1120 
1121     if ($conf['profileconfirm']) {
1122         if (!$auth->checkPass($INPUT->server->str('REMOTE_USER'), $INPUT->post->str('oldpass'))) {
1123             msg($lang['badpassconfirm'], -1);
1124             return false;
1125         }
1126     }
1127 
1128     if (!$auth->triggerUserMod('modify', [$INPUT->server->str('REMOTE_USER'), &$changes])) {
1129         msg($lang['proffail'], -1);
1130         return false;
1131     }
1132 
1133     if (array_key_exists('pass', $changes) && $changes['pass']) {
1134         // update cookie and session with the changed data
1135         [/* user */, $sticky, /* pass */] = auth_getCookie();
1136         $pass = auth_encrypt($changes['pass'], auth_cookiesalt(!$sticky, true));
1137         auth_setCookie($INPUT->server->str('REMOTE_USER'), $pass, (bool) $sticky);
1138     } else {
1139         // make sure the session is writable
1140         @session_start();
1141         // invalidate session cache
1142         $_SESSION[DOKU_COOKIE]['auth']['time'] = 0;
1143         session_write_close();
1144     }
1145 
1146     return true;
1147 }
1148 
1149 /**
1150  * Delete the current logged-in user
1151  *
1152  * @return bool true on success, false on any error
1153  */
1154 function auth_deleteprofile()
1155 {
1156     global $conf;
1157     global $lang;
1158     /* @var AuthPlugin $auth */
1159     global $auth;
1160     /* @var Input $INPUT */
1161     global $INPUT;
1162 
1163     if (!$INPUT->post->bool('delete')) return false;
1164     if (!checkSecurityToken()) return false;
1165 
1166     // action prevented or auth module disallows
1167     if (!actionOK('profile_delete') || !$auth->canDo('delUser')) {
1168         msg($lang['profnodelete'], -1);
1169         return false;
1170     }
1171 
1172     if (!$INPUT->post->bool('confirm_delete')) {
1173         msg($lang['profconfdeletemissing'], -1);
1174         return false;
1175     }
1176 
1177     if ($conf['profileconfirm']) {
1178         if (!$auth->checkPass($INPUT->server->str('REMOTE_USER'), $INPUT->post->str('oldpass'))) {
1179             msg($lang['badpassconfirm'], -1);
1180             return false;
1181         }
1182     }
1183 
1184     $deleted = [];
1185     $deleted[] = $INPUT->server->str('REMOTE_USER');
1186     if ($auth->triggerUserMod('delete', [$deleted])) {
1187         // force and immediate logout including removing the sticky cookie
1188         auth_logoff();
1189         return true;
1190     }
1191 
1192     return false;
1193 }
1194 
1195 /**
1196  * Send a  new password
1197  *
1198  * This function handles both phases of the password reset:
1199  *
1200  *   - handling the first request of password reset
1201  *   - validating the password reset auth token
1202  *
1203  * @return bool true on success, false on any error
1204  * @throws Exception
1205  *
1206  * @author Andreas Gohr <andi@splitbrain.org>
1207  * @author Benoit Chesneau <benoit@bchesneau.info>
1208  * @author Chris Smith <chris@jalakai.co.uk>
1209  */
1210 function act_resendpwd()
1211 {
1212     global $lang;
1213     global $conf;
1214     /* @var AuthPlugin $auth */
1215     global $auth;
1216     /* @var Input $INPUT */
1217     global $INPUT;
1218 
1219     if (!actionOK('resendpwd')) {
1220         msg($lang['resendna'], -1);
1221         return false;
1222     }
1223 
1224     $token = preg_replace('/[^a-f0-9]+/', '', $INPUT->str('pwauth'));
1225 
1226     if ($token) {
1227         // we're in token phase - get user info from token
1228 
1229         $tfile = $conf['cachedir'] . '/' . $token[0] . '/' . $token . '.pwauth';
1230         if (!file_exists($tfile)) {
1231             msg($lang['resendpwdbadauth'], -1);
1232             $INPUT->remove('pwauth');
1233             return false;
1234         }
1235         // token is only valid for 3 days
1236         if ((time() - filemtime($tfile)) > (3 * 60 * 60 * 24)) {
1237             msg($lang['resendpwdbadauth'], -1);
1238             $INPUT->remove('pwauth');
1239             @unlink($tfile);
1240             return false;
1241         }
1242 
1243         $user     = io_readfile($tfile);
1244         $userinfo = $auth->getUserData($user, false);
1245         if (!$userinfo['mail']) {
1246             msg($lang['resendpwdnouser'], -1);
1247             return false;
1248         }
1249 
1250         if (!$conf['autopasswd']) { // we let the user choose a password
1251             $pass = $INPUT->str('pass');
1252 
1253             // password given correctly?
1254             if (!$pass) return false;
1255             if ($pass != $INPUT->str('passchk')) {
1256                 msg($lang['regbadpass'], -1);
1257                 return false;
1258             }
1259 
1260             // change it
1261             if (!$auth->triggerUserMod('modify', [$user, ['pass' => $pass]])) {
1262                 msg($lang['proffail'], -1);
1263                 return false;
1264             }
1265         } else { // autogenerate the password and send by mail
1266             $pass = auth_pwgen($user);
1267             if (!$auth->triggerUserMod('modify', [$user, ['pass' => $pass]])) {
1268                 msg($lang['proffail'], -1);
1269                 return false;
1270             }
1271 
1272             if (auth_sendPassword($user, $pass)) {
1273                 msg($lang['resendpwdsuccess'], 1);
1274             } else {
1275                 msg($lang['regmailfail'], -1);
1276             }
1277         }
1278 
1279         @unlink($tfile);
1280         return true;
1281     } else {
1282         // we're in request phase
1283 
1284         if (!$INPUT->post->bool('save')) return false;
1285 
1286         if (!$INPUT->post->str('login')) {
1287             msg($lang['resendpwdmissing'], -1);
1288             return false;
1289         } else {
1290             $user = trim($auth->cleanUser($INPUT->post->str('login')));
1291         }
1292 
1293         $userinfo = $auth->getUserData($user, false);
1294         if (!$userinfo['mail']) {
1295             msg($lang['resendpwdnouser'], -1);
1296             return false;
1297         }
1298 
1299         // generate auth token
1300         $token = md5(auth_randombytes(16)); // random secret
1301         $tfile = $conf['cachedir'] . '/' . $token[0] . '/' . $token . '.pwauth';
1302         $url   = wl('', ['do' => 'resendpwd', 'pwauth' => $token], true, '&');
1303 
1304         io_saveFile($tfile, $user);
1305 
1306         $text = rawLocale('pwconfirm');
1307         $trep = ['FULLNAME' => $userinfo['name'], 'LOGIN'    => $user, 'CONFIRM'  => $url];
1308 
1309         $mail = new Mailer();
1310         $mail->to($userinfo['name'] . ' <' . $userinfo['mail'] . '>');
1311         $mail->subject($lang['regpwmail']);
1312         $mail->setBody($text, $trep);
1313         if ($mail->send()) {
1314             msg($lang['resendpwdconfirm'], 1);
1315         } else {
1316             msg($lang['regmailfail'], -1);
1317         }
1318         return true;
1319     }
1320     // never reached
1321 }
1322 
1323 /**
1324  * Encrypts a password using the given method and salt
1325  *
1326  * If the selected method needs a salt and none was given, a random one
1327  * is chosen.
1328  *
1329  * You can pass null as the password to create an unusable hash.
1330  *
1331  * @author  Andreas Gohr <andi@splitbrain.org>
1332  *
1333  * @param string $clear The clear text password
1334  * @param string $method The hashing method
1335  * @param string $salt A salt, null for random
1336  * @return  string  The crypted password
1337  */
1338 function auth_cryptPassword($clear, $method = '', $salt = null)
1339 {
1340     global $conf;
1341 
1342     if ($clear === null) {
1343         return DOKU_UNUSABLE_PASSWORD;
1344     }
1345 
1346     if (empty($method)) $method = $conf['passcrypt'];
1347 
1348     $pass = new PassHash();
1349     $call = 'hash_' . $method;
1350 
1351     if (!method_exists($pass, $call)) {
1352         msg("Unsupported crypt method $method", -1);
1353         return false;
1354     }
1355 
1356     return $pass->$call($clear, $salt);
1357 }
1358 
1359 /**
1360  * Verifies a cleartext password against a crypted hash
1361  *
1362  * @param string $clear The clear text password
1363  * @param string $crypt The hash to compare with
1364  * @return bool true if both match
1365  * @throws Exception
1366  *
1367  * @author Andreas Gohr <andi@splitbrain.org>
1368  */
1369 function auth_verifyPassword($clear, $crypt)
1370 {
1371     if ($crypt === DOKU_UNUSABLE_PASSWORD) {
1372         return false;
1373     }
1374 
1375     $pass = new PassHash();
1376     return $pass->verify_hash($clear, $crypt);
1377 }
1378 
1379 /**
1380  * Set the authentication cookie and add user identification data to the session
1381  *
1382  * @param string  $user       username
1383  * @param string  $pass       encrypted password
1384  * @param bool    $sticky     whether or not the cookie will last beyond the session
1385  * @return bool
1386  */
1387 function auth_setCookie($user, $pass, $sticky)
1388 {
1389     global $conf;
1390     /* @var AuthPlugin $auth */
1391     global $auth;
1392     global $USERINFO;
1393 
1394     if (!$auth instanceof AuthPlugin) return false;
1395     $USERINFO = $auth->getUserData($user);
1396 
1397     // set cookie
1398     $cookie    = base64_encode($user) . '|' . ((int) $sticky) . '|' . base64_encode($pass);
1399     $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
1400     $time      = $sticky ? (time() + 60 * 60 * 24 * 365) : 0; //one year
1401     setcookie(DOKU_COOKIE, $cookie, [
1402         'expires' => $time,
1403         'path' => $cookieDir,
1404         'secure' => ($conf['securecookie'] && is_ssl()),
1405         'httponly' => true,
1406         'samesite' => $conf['samesitecookie'] ?: null, // null means browser default
1407     ]);
1408 
1409     // set session
1410     $_SESSION[DOKU_COOKIE]['auth']['user'] = $user;
1411     $_SESSION[DOKU_COOKIE]['auth']['pass'] = sha1($pass);
1412     $_SESSION[DOKU_COOKIE]['auth']['buid'] = auth_browseruid();
1413     $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
1414     $_SESSION[DOKU_COOKIE]['auth']['time'] = time();
1415 
1416     return true;
1417 }
1418 
1419 /**
1420  * Returns the user, (encrypted) password and sticky bit from cookie
1421  *
1422  * @returns array
1423  */
1424 function auth_getCookie()
1425 {
1426     if (!isset($_COOKIE[DOKU_COOKIE])) {
1427         return [null, null, null];
1428     }
1429     [$user, $sticky, $pass] = sexplode('|', $_COOKIE[DOKU_COOKIE], 3, '');
1430     $sticky = (bool) $sticky;
1431     $pass   = base64_decode($pass);
1432     $user   = base64_decode($user);
1433     return [$user, $sticky, $pass];
1434 }
1435 
1436 //Setup VIM: ex: et ts=2 :
1437