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