code style: line breaks
[dokuwiki.git] / inc / auth.php
blob9a0279e84f5044282924dc911d5703fbbb4d84ac
1 <?php
3 /**
4 * Authentication library
6 * Including this file will automatically try to login
7 * a user by calling auth_login()
9 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
10 * @author Andreas Gohr <andi@splitbrain.org>
13 use phpseclib\Crypt\AES;
14 use dokuwiki\Utf8\PhpString;
15 use dokuwiki\Extension\AuthPlugin;
16 use dokuwiki\Extension\Event;
17 use dokuwiki\Extension\PluginController;
18 use dokuwiki\PassHash;
19 use dokuwiki\Subscriptions\RegistrationSubscriptionSender;
21 /**
22 * Initialize the auth system.
24 * This function is automatically called at the end of init.php
26 * This used to be the main() of the auth.php
28 * @todo backend loading maybe should be handled by the class autoloader
29 * @todo maybe split into multiple functions at the XXX marked positions
30 * @triggers AUTH_LOGIN_CHECK
31 * @return bool
33 function auth_setup()
35 global $conf;
36 /* @var AuthPlugin $auth */
37 global $auth;
38 /* @var Input $INPUT */
39 global $INPUT;
40 global $AUTH_ACL;
41 global $lang;
42 /* @var PluginController $plugin_controller */
43 global $plugin_controller;
44 $AUTH_ACL = [];
46 if (!$conf['useacl']) return false;
48 // try to load auth backend from plugins
49 foreach ($plugin_controller->getList('auth') as $plugin) {
50 if ($conf['authtype'] === $plugin) {
51 $auth = $plugin_controller->load('auth', $plugin);
52 break;
56 if (!isset($auth) || !$auth) {
57 msg($lang['authtempfail'], -1);
58 return false;
61 if ($auth->success == false) {
62 // degrade to unauthenticated user
63 $auth = null;
64 auth_logoff();
65 msg($lang['authtempfail'], -1);
66 return false;
69 // do the login either by cookie or provided credentials XXX
70 $INPUT->set('http_credentials', false);
71 if (!$conf['rememberme']) $INPUT->set('r', false);
73 // Populate Basic Auth user/password from Authorization header
74 // Note: with FastCGI, data is in REDIRECT_HTTP_AUTHORIZATION instead of HTTP_AUTHORIZATION
75 $header = $INPUT->server->str('HTTP_AUTHORIZATION') ?: $INPUT->server->str('REDIRECT_HTTP_AUTHORIZATION');
76 if (preg_match('~^Basic ([a-z\d/+]*={0,2})$~i', $header, $matches)) {
77 $userpass = explode(':', base64_decode($matches[1]));
78 [$_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']] = $userpass;
81 // if no credentials were given try to use HTTP auth (for SSO)
82 if (!$INPUT->str('u') && empty($_COOKIE[DOKU_COOKIE]) && !empty($INPUT->server->str('PHP_AUTH_USER'))) {
83 $INPUT->set('u', $INPUT->server->str('PHP_AUTH_USER'));
84 $INPUT->set('p', $INPUT->server->str('PHP_AUTH_PW'));
85 $INPUT->set('http_credentials', true);
88 // apply cleaning (auth specific user names, remove control chars)
89 if (true === $auth->success) {
90 $INPUT->set('u', $auth->cleanUser(stripctl($INPUT->str('u'))));
91 $INPUT->set('p', stripctl($INPUT->str('p')));
94 $ok = null;
95 if (!is_null($auth) && $auth->canDo('external')) {
96 $ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r'));
99 if ($ok === null) {
100 // external trust mechanism not in place, or returns no result,
101 // then attempt auth_login
102 $evdata = [
103 'user' => $INPUT->str('u'),
104 'password' => $INPUT->str('p'),
105 'sticky' => $INPUT->bool('r'),
106 'silent' => $INPUT->bool('http_credentials')
108 Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper');
111 //load ACL into a global array XXX
112 $AUTH_ACL = auth_loadACL();
114 return true;
118 * Loads the ACL setup and handle user wildcards
120 * @author Andreas Gohr <andi@splitbrain.org>
122 * @return array
124 function auth_loadACL()
126 global $config_cascade;
127 global $USERINFO;
128 /* @var Input $INPUT */
129 global $INPUT;
131 if (!is_readable($config_cascade['acl']['default'])) return [];
133 $acl = file($config_cascade['acl']['default']);
135 $out = [];
136 foreach ($acl as $line) {
137 $line = trim($line);
138 if (empty($line) || ($line[0] == '#')) continue; // skip blank lines & comments
139 [$id, $rest] = preg_split('/[ \t]+/', $line, 2);
141 // substitute user wildcard first (its 1:1)
142 if (strstr($line, '%USER%')) {
143 // if user is not logged in, this ACL line is meaningless - skip it
144 if (!$INPUT->server->has('REMOTE_USER')) continue;
146 $id = str_replace('%USER%', cleanID($INPUT->server->str('REMOTE_USER')), $id);
147 $rest = str_replace('%USER%', auth_nameencode($INPUT->server->str('REMOTE_USER')), $rest);
150 // substitute group wildcard (its 1:m)
151 if (strstr($line, '%GROUP%')) {
152 // if user is not logged in, grps is empty, no output will be added (i.e. skipped)
153 if (isset($USERINFO['grps'])) {
154 foreach ((array) $USERINFO['grps'] as $grp) {
155 $nid = str_replace('%GROUP%', cleanID($grp), $id);
156 $nrest = str_replace('%GROUP%', '@' . auth_nameencode($grp), $rest);
157 $out[] = "$nid\t$nrest";
160 } else {
161 $out[] = "$id\t$rest";
165 return $out;
169 * Event hook callback for AUTH_LOGIN_CHECK
171 * @param array $evdata
172 * @return bool
174 function auth_login_wrapper($evdata)
176 return auth_login(
177 $evdata['user'],
178 $evdata['password'],
179 $evdata['sticky'],
180 $evdata['silent']
185 * This tries to login the user based on the sent auth credentials
187 * The authentication works like this: if a username was given
188 * a new login is assumed and user/password are checked. If they
189 * are correct the password is encrypted with blowfish and stored
190 * together with the username in a cookie - the same info is stored
191 * in the session, too. Additonally a browserID is stored in the
192 * session.
194 * If no username was given the cookie is checked: if the username,
195 * crypted password and browserID match between session and cookie
196 * no further testing is done and the user is accepted
198 * If a cookie was found but no session info was availabe the
199 * blowfish encrypted password from the cookie is decrypted and
200 * together with username rechecked by calling this function again.
202 * On a successful login $_SERVER[REMOTE_USER] and $USERINFO
203 * are set.
205 * @author Andreas Gohr <andi@splitbrain.org>
207 * @param string $user Username
208 * @param string $pass Cleartext Password
209 * @param bool $sticky Cookie should not expire
210 * @param bool $silent Don't show error on bad auth
211 * @return bool true on successful auth
213 function auth_login($user, $pass, $sticky = false, $silent = false)
215 global $USERINFO;
216 global $conf;
217 global $lang;
218 /* @var AuthPlugin $auth */
219 global $auth;
220 /* @var Input $INPUT */
221 global $INPUT;
223 if (!$auth) return false;
225 if (!empty($user)) {
226 //usual login
227 if (!empty($pass) && $auth->checkPass($user, $pass)) {
228 // make logininfo globally available
229 $INPUT->server->set('REMOTE_USER', $user);
230 $secret = auth_cookiesalt(!$sticky, true); //bind non-sticky to session
231 auth_setCookie($user, auth_encrypt($pass, $secret), $sticky);
232 return true;
233 } else {
234 //invalid credentials - log off
235 if (!$silent) {
236 http_status(403, 'Login failed');
237 msg($lang['badlogin'], -1);
239 auth_logoff();
240 return false;
242 } else {
243 // read cookie information
244 [$user, $sticky, $pass] = auth_getCookie();
245 if ($user && $pass) {
246 // we got a cookie - see if we can trust it
248 // get session info
249 if (isset($_SESSION[DOKU_COOKIE])) {
250 $session = $_SESSION[DOKU_COOKIE]['auth'];
251 if (
252 isset($session) &&
253 $auth->useSessionCache($user) &&
254 ($session['time'] >= time() - $conf['auth_security_timeout']) &&
255 ($session['user'] == $user) &&
256 ($session['pass'] == sha1($pass)) && //still crypted
257 ($session['buid'] == auth_browseruid())
259 // he has session, cookie and browser right - let him in
260 $INPUT->server->set('REMOTE_USER', $user);
261 $USERINFO = $session['info']; //FIXME move all references to session
262 return true;
265 // no we don't trust it yet - recheck pass but silent
266 $secret = auth_cookiesalt(!$sticky, true); //bind non-sticky to session
267 $pass = auth_decrypt($pass, $secret);
268 return auth_login($user, $pass, $sticky, true);
271 //just to be sure
272 auth_logoff(true);
273 return false;
277 * Builds a pseudo UID from browser and IP data
279 * This is neither unique nor unfakable - still it adds some
280 * security. Using the first part of the IP makes sure
281 * proxy farms like AOLs are still okay.
283 * @author Andreas Gohr <andi@splitbrain.org>
285 * @return string a SHA256 sum of various browser headers
287 function auth_browseruid()
289 /* @var Input $INPUT */
290 global $INPUT;
292 $ip = clientIP(true);
293 // convert IP string to packed binary representation
294 $pip = inet_pton($ip);
296 $uid = implode("\n", [
297 $INPUT->server->str('HTTP_USER_AGENT'),
298 $INPUT->server->str('HTTP_ACCEPT_LANGUAGE'),
299 substr($pip, 0, strlen($pip) / 2), // use half of the IP address (works for both IPv4 and IPv6)
301 return hash('sha256', $uid);
305 * Creates a random key to encrypt the password in cookies
307 * This function tries to read the password for encrypting
308 * cookies from $conf['metadir'].'/_htcookiesalt'
309 * if no such file is found a random key is created and
310 * and stored in this file.
312 * @author Andreas Gohr <andi@splitbrain.org>
314 * @param bool $addsession if true, the sessionid is added to the salt
315 * @param bool $secure if security is more important than keeping the old value
316 * @return string
318 function auth_cookiesalt($addsession = false, $secure = false)
320 if (defined('SIMPLE_TEST')) {
321 return 'test';
323 global $conf;
324 $file = $conf['metadir'] . '/_htcookiesalt';
325 if ($secure || !file_exists($file)) {
326 $file = $conf['metadir'] . '/_htcookiesalt2';
328 $salt = io_readFile($file);
329 if (empty($salt)) {
330 $salt = bin2hex(auth_randombytes(64));
331 io_saveFile($file, $salt);
333 if ($addsession) {
334 $salt .= session_id();
336 return $salt;
340 * Return cryptographically secure random bytes.
342 * @author Niklas Keller <me@kelunik.com>
344 * @param int $length number of bytes
345 * @return string cryptographically secure random bytes
347 function auth_randombytes($length)
349 return random_bytes($length);
353 * Cryptographically secure random number generator.
355 * @author Niklas Keller <me@kelunik.com>
357 * @param int $min
358 * @param int $max
359 * @return int
361 function auth_random($min, $max)
363 return random_int($min, $max);
367 * Encrypt data using the given secret using AES
369 * The mode is CBC with a random initialization vector, the key is derived
370 * using pbkdf2.
372 * @param string $data The data that shall be encrypted
373 * @param string $secret The secret/password that shall be used
374 * @return string The ciphertext
376 function auth_encrypt($data, $secret)
378 $iv = auth_randombytes(16);
379 $cipher = new AES();
380 $cipher->setPassword($secret);
383 this uses the encrypted IV as IV as suggested in
384 http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf, Appendix C
385 for unique but necessarily random IVs. The resulting ciphertext is
386 compatible to ciphertext that was created using a "normal" IV.
388 return $cipher->encrypt($iv . $data);
392 * Decrypt the given AES ciphertext
394 * The mode is CBC, the key is derived using pbkdf2
396 * @param string $ciphertext The encrypted data
397 * @param string $secret The secret/password that shall be used
398 * @return string The decrypted data
400 function auth_decrypt($ciphertext, $secret)
402 $iv = substr($ciphertext, 0, 16);
403 $cipher = new AES();
404 $cipher->setPassword($secret);
405 $cipher->setIV($iv);
407 return $cipher->decrypt(substr($ciphertext, 16));
411 * Log out the current user
413 * This clears all authentication data and thus log the user
414 * off. It also clears session data.
416 * @author Andreas Gohr <andi@splitbrain.org>
418 * @param bool $keepbc - when true, the breadcrumb data is not cleared
420 function auth_logoff($keepbc = false)
422 global $conf;
423 global $USERINFO;
424 /* @var AuthPlugin $auth */
425 global $auth;
426 /* @var Input $INPUT */
427 global $INPUT;
429 // make sure the session is writable (it usually is)
430 @session_start();
432 if (isset($_SESSION[DOKU_COOKIE]['auth']['user']))
433 unset($_SESSION[DOKU_COOKIE]['auth']['user']);
434 if (isset($_SESSION[DOKU_COOKIE]['auth']['pass']))
435 unset($_SESSION[DOKU_COOKIE]['auth']['pass']);
436 if (isset($_SESSION[DOKU_COOKIE]['auth']['info']))
437 unset($_SESSION[DOKU_COOKIE]['auth']['info']);
438 if (!$keepbc && isset($_SESSION[DOKU_COOKIE]['bc']))
439 unset($_SESSION[DOKU_COOKIE]['bc']);
440 $INPUT->server->remove('REMOTE_USER');
441 $USERINFO = null; //FIXME
443 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
444 setcookie(DOKU_COOKIE, '', [
445 'expires' => time() - 600000,
446 'path' => $cookieDir,
447 'secure' => ($conf['securecookie'] && is_ssl()),
448 'httponly' => true,
449 'samesite' => $conf['samesitecookie'] ?: null, // null means browser default
452 if ($auth) $auth->logOff();
456 * Check if a user is a manager
458 * Should usually be called without any parameters to check the current
459 * user.
461 * The info is available through $INFO['ismanager'], too
463 * @param string $user Username
464 * @param array $groups List of groups the user is in
465 * @param bool $adminonly when true checks if user is admin
466 * @param bool $recache set to true to refresh the cache
467 * @return bool
468 * @see auth_isadmin
470 * @author Andreas Gohr <andi@splitbrain.org>
472 function auth_ismanager($user = null, $groups = null, $adminonly = false, $recache = false)
474 global $conf;
475 global $USERINFO;
476 /* @var AuthPlugin $auth */
477 global $auth;
478 /* @var Input $INPUT */
479 global $INPUT;
482 if (!$auth) return false;
483 if (is_null($user)) {
484 if (!$INPUT->server->has('REMOTE_USER')) {
485 return false;
486 } else {
487 $user = $INPUT->server->str('REMOTE_USER');
490 if (is_null($groups)) {
491 // checking the logged in user, or another one?
492 if ($USERINFO && $user === $INPUT->server->str('REMOTE_USER')) {
493 $groups = (array) $USERINFO['grps'];
494 } else {
495 $groups = $auth->getUserData($user);
496 $groups = $groups ? $groups['grps'] : [];
500 // prefer cached result
501 static $cache = [];
502 $cachekey = serialize([$user, $adminonly, $groups]);
503 if (!isset($cache[$cachekey]) || $recache) {
504 // check superuser match
505 $ok = auth_isMember($conf['superuser'], $user, $groups);
507 // check managers
508 if (!$ok && !$adminonly) {
509 $ok = auth_isMember($conf['manager'], $user, $groups);
512 $cache[$cachekey] = $ok;
515 return $cache[$cachekey];
519 * Check if a user is admin
521 * Alias to auth_ismanager with adminonly=true
523 * The info is available through $INFO['isadmin'], too
525 * @param string $user Username
526 * @param array $groups List of groups the user is in
527 * @param bool $recache set to true to refresh the cache
528 * @return bool
529 * @author Andreas Gohr <andi@splitbrain.org>
530 * @see auth_ismanager()
533 function auth_isadmin($user = null, $groups = null, $recache = false)
535 return auth_ismanager($user, $groups, true, $recache);
539 * Match a user and his groups against a comma separated list of
540 * users and groups to determine membership status
542 * Note: all input should NOT be nameencoded.
544 * @param string $memberlist commaseparated list of allowed users and groups
545 * @param string $user user to match against
546 * @param array $groups groups the user is member of
547 * @return bool true for membership acknowledged
549 function auth_isMember($memberlist, $user, array $groups)
551 /* @var AuthPlugin $auth */
552 global $auth;
553 if (!$auth) return false;
555 // clean user and groups
556 if (!$auth->isCaseSensitive()) {
557 $user = PhpString::strtolower($user);
558 $groups = array_map([PhpString::class, 'strtolower'], $groups);
560 $user = $auth->cleanUser($user);
561 $groups = array_map([$auth, 'cleanGroup'], $groups);
563 // extract the memberlist
564 $members = explode(',', $memberlist);
565 $members = array_map('trim', $members);
566 $members = array_unique($members);
567 $members = array_filter($members);
569 // compare cleaned values
570 foreach ($members as $member) {
571 if ($member == '@ALL') return true;
572 if (!$auth->isCaseSensitive()) $member = PhpString::strtolower($member);
573 if ($member[0] == '@') {
574 $member = $auth->cleanGroup(substr($member, 1));
575 if (in_array($member, $groups)) return true;
576 } else {
577 $member = $auth->cleanUser($member);
578 if ($member == $user) return true;
582 // still here? not a member!
583 return false;
587 * Convinience function for auth_aclcheck()
589 * This checks the permissions for the current user
591 * @author Andreas Gohr <andi@splitbrain.org>
593 * @param string $id page ID (needs to be resolved and cleaned)
594 * @return int permission level
596 function auth_quickaclcheck($id)
598 global $conf;
599 global $USERINFO;
600 /* @var Input $INPUT */
601 global $INPUT;
602 # if no ACL is used always return upload rights
603 if (!$conf['useacl']) return AUTH_UPLOAD;
604 return auth_aclcheck($id, $INPUT->server->str('REMOTE_USER'), is_array($USERINFO) ? $USERINFO['grps'] : []);
608 * Returns the maximum rights a user has for the given ID or its namespace
610 * @author Andreas Gohr <andi@splitbrain.org>
612 * @triggers AUTH_ACL_CHECK
613 * @param string $id page ID (needs to be resolved and cleaned)
614 * @param string $user Username
615 * @param array|null $groups Array of groups the user is in
616 * @return int permission level
618 function auth_aclcheck($id, $user, $groups)
620 $data = [
621 'id' => $id ?? '',
622 'user' => $user,
623 'groups' => $groups
626 return Event::createAndTrigger('AUTH_ACL_CHECK', $data, 'auth_aclcheck_cb');
630 * default ACL check method
632 * DO NOT CALL DIRECTLY, use auth_aclcheck() instead
634 * @author Andreas Gohr <andi@splitbrain.org>
636 * @param array $data event data
637 * @return int permission level
639 function auth_aclcheck_cb($data)
641 $id =& $data['id'];
642 $user =& $data['user'];
643 $groups =& $data['groups'];
645 global $conf;
646 global $AUTH_ACL;
647 /* @var AuthPlugin $auth */
648 global $auth;
650 // if no ACL is used always return upload rights
651 if (!$conf['useacl']) return AUTH_UPLOAD;
652 if (!$auth) return AUTH_NONE;
653 if (!is_array($AUTH_ACL)) return AUTH_NONE;
655 //make sure groups is an array
656 if (!is_array($groups)) $groups = [];
658 //if user is superuser or in superusergroup return 255 (acl_admin)
659 if (auth_isadmin($user, $groups)) {
660 return AUTH_ADMIN;
663 if (!$auth->isCaseSensitive()) {
664 $user = PhpString::strtolower($user);
665 $groups = array_map([PhpString::class, 'strtolower'], $groups);
667 $user = auth_nameencode($auth->cleanUser($user));
668 $groups = array_map([$auth, 'cleanGroup'], $groups);
670 //prepend groups with @ and nameencode
671 foreach ($groups as &$group) {
672 $group = '@' . auth_nameencode($group);
675 $ns = getNS($id);
676 $perm = -1;
678 //add ALL group
679 $groups[] = '@ALL';
681 //add User
682 if ($user) $groups[] = $user;
684 //check exact match first
685 $matches = preg_grep('/^' . preg_quote($id, '/') . '[ \t]+([^ \t]+)[ \t]+/', $AUTH_ACL);
686 if (count($matches)) {
687 foreach ($matches as $match) {
688 $match = preg_replace('/#.*$/', '', $match); //ignore comments
689 $acl = preg_split('/[ \t]+/', $match);
690 if (!$auth->isCaseSensitive() && $acl[1] !== '@ALL') {
691 $acl[1] = PhpString::strtolower($acl[1]);
693 if (!in_array($acl[1], $groups)) {
694 continue;
696 if ($acl[2] > AUTH_DELETE) $acl[2] = AUTH_DELETE; //no admins in the ACL!
697 if ($acl[2] > $perm) {
698 $perm = $acl[2];
701 if ($perm > -1) {
702 //we had a match - return it
703 return (int) $perm;
707 //still here? do the namespace checks
708 if ($ns) {
709 $path = $ns . ':*';
710 } else {
711 $path = '*'; //root document
714 do {
715 $matches = preg_grep('/^' . preg_quote($path, '/') . '[ \t]+([^ \t]+)[ \t]+/', $AUTH_ACL);
716 if (count($matches)) {
717 foreach ($matches as $match) {
718 $match = preg_replace('/#.*$/', '', $match); //ignore comments
719 $acl = preg_split('/[ \t]+/', $match);
720 if (!$auth->isCaseSensitive() && $acl[1] !== '@ALL') {
721 $acl[1] = PhpString::strtolower($acl[1]);
723 if (!in_array($acl[1], $groups)) {
724 continue;
726 if ($acl[2] > AUTH_DELETE) $acl[2] = AUTH_DELETE; //no admins in the ACL!
727 if ($acl[2] > $perm) {
728 $perm = $acl[2];
731 //we had a match - return it
732 if ($perm != -1) {
733 return (int) $perm;
736 //get next higher namespace
737 $ns = getNS($ns);
739 if ($path != '*') {
740 $path = $ns . ':*';
741 if ($path == ':*') $path = '*';
742 } else {
743 //we did this already
744 //looks like there is something wrong with the ACL
745 //break here
746 msg('No ACL setup yet! Denying access to everyone.');
747 return AUTH_NONE;
749 } while (1); //this should never loop endless
750 return AUTH_NONE;
754 * Encode ASCII special chars
756 * Some auth backends allow special chars in their user and groupnames
757 * The special chars are encoded with this function. Only ASCII chars
758 * are encoded UTF-8 multibyte are left as is (different from usual
759 * urlencoding!).
761 * Decoding can be done with rawurldecode
763 * @author Andreas Gohr <gohr@cosmocode.de>
764 * @see rawurldecode()
766 * @param string $name
767 * @param bool $skip_group
768 * @return string
770 function auth_nameencode($name, $skip_group = false)
772 global $cache_authname;
773 $cache =& $cache_authname;
774 $name = (string) $name;
776 // never encode wildcard FS#1955
777 if ($name == '%USER%') return $name;
778 if ($name == '%GROUP%') return $name;
780 if (!isset($cache[$name][$skip_group])) {
781 if ($skip_group && $name[0] == '@') {
782 $cache[$name][$skip_group] = '@' . preg_replace_callback(
783 '/([\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f])/',
784 'auth_nameencode_callback',
785 substr($name, 1)
787 } else {
788 $cache[$name][$skip_group] = preg_replace_callback(
789 '/([\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f])/',
790 'auth_nameencode_callback',
791 $name
796 return $cache[$name][$skip_group];
800 * callback encodes the matches
802 * @param array $matches first complete match, next matching subpatterms
803 * @return string
805 function auth_nameencode_callback($matches)
807 return '%' . dechex(ord(substr($matches[1], -1)));
811 * Create a pronouncable password
813 * The $foruser variable might be used by plugins to run additional password
814 * policy checks, but is not used by the default implementation
816 * @author Andreas Gohr <andi@splitbrain.org>
817 * @link http://www.phpbuilder.com/annotate/message.php3?id=1014451
818 * @triggers AUTH_PASSWORD_GENERATE
820 * @param string $foruser username for which the password is generated
821 * @return string pronouncable password
823 function auth_pwgen($foruser = '')
825 $data = [
826 'password' => '',
827 'foruser' => $foruser
830 $evt = new Event('AUTH_PASSWORD_GENERATE', $data);
831 if ($evt->advise_before(true)) {
832 $c = 'bcdfghjklmnprstvwz'; //consonants except hard to speak ones
833 $v = 'aeiou'; //vowels
834 $a = $c . $v; //both
835 $s = '!$%&?+*~#-_:.;,'; // specials
837 //use thre syllables...
838 for ($i = 0; $i < 3; $i++) {
839 $data['password'] .= $c[auth_random(0, strlen($c) - 1)];
840 $data['password'] .= $v[auth_random(0, strlen($v) - 1)];
841 $data['password'] .= $a[auth_random(0, strlen($a) - 1)];
843 //... and add a nice number and special
844 $data['password'] .= $s[auth_random(0, strlen($s) - 1)] . auth_random(10, 99);
846 $evt->advise_after();
848 return $data['password'];
852 * Sends a password to the given user
854 * @author Andreas Gohr <andi@splitbrain.org>
856 * @param string $user Login name of the user
857 * @param string $password The new password in clear text
858 * @return bool true on success
860 function auth_sendPassword($user, $password)
862 global $lang;
863 /* @var AuthPlugin $auth */
864 global $auth;
865 if (!$auth) return false;
867 $user = $auth->cleanUser($user);
868 $userinfo = $auth->getUserData($user, $requireGroups = false);
870 if (!$userinfo['mail']) return false;
872 $text = rawLocale('password');
873 $trep = [
874 'FULLNAME' => $userinfo['name'],
875 'LOGIN' => $user,
876 'PASSWORD' => $password
879 $mail = new Mailer();
880 $mail->to($mail->getCleanName($userinfo['name']) . ' <' . $userinfo['mail'] . '>');
881 $mail->subject($lang['regpwmail']);
882 $mail->setBody($text, $trep);
883 return $mail->send();
887 * Register a new user
889 * This registers a new user - Data is read directly from $_POST
891 * @author Andreas Gohr <andi@splitbrain.org>
893 * @return bool true on success, false on any error
895 function register()
897 global $lang;
898 global $conf;
899 /* @var \dokuwiki\Extension\AuthPlugin $auth */
900 global $auth;
901 global $INPUT;
903 if (!$INPUT->post->bool('save')) return false;
904 if (!actionOK('register')) return false;
906 // gather input
907 $login = trim($auth->cleanUser($INPUT->post->str('login')));
908 $fullname = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $INPUT->post->str('fullname')));
909 $email = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $INPUT->post->str('email')));
910 $pass = $INPUT->post->str('pass');
911 $passchk = $INPUT->post->str('passchk');
913 if (empty($login) || empty($fullname) || empty($email)) {
914 msg($lang['regmissing'], -1);
915 return false;
918 if ($conf['autopasswd']) {
919 $pass = auth_pwgen($login); // automatically generate password
920 } elseif (empty($pass) || empty($passchk)) {
921 msg($lang['regmissing'], -1); // complain about missing passwords
922 return false;
923 } elseif ($pass != $passchk) {
924 msg($lang['regbadpass'], -1); // complain about misspelled passwords
925 return false;
928 //check mail
929 if (!mail_isvalid($email)) {
930 msg($lang['regbadmail'], -1);
931 return false;
934 //okay try to create the user
935 if (!$auth->triggerUserMod('create', [$login, $pass, $fullname, $email])) {
936 msg($lang['regfail'], -1);
937 return false;
940 // send notification about the new user
941 $subscription = new RegistrationSubscriptionSender();
942 $subscription->sendRegister($login, $fullname, $email);
944 // are we done?
945 if (!$conf['autopasswd']) {
946 msg($lang['regsuccess2'], 1);
947 return true;
950 // autogenerated password? then send password to user
951 if (auth_sendPassword($login, $pass)) {
952 msg($lang['regsuccess'], 1);
953 return true;
954 } else {
955 msg($lang['regmailfail'], -1);
956 return false;
961 * Update user profile
963 * @author Christopher Smith <chris@jalakai.co.uk>
965 function updateprofile()
967 global $conf;
968 global $lang;
969 /* @var AuthPlugin $auth */
970 global $auth;
971 /* @var Input $INPUT */
972 global $INPUT;
974 if (!$INPUT->post->bool('save')) return false;
975 if (!checkSecurityToken()) return false;
977 if (!actionOK('profile')) {
978 msg($lang['profna'], -1);
979 return false;
982 $changes = [];
983 $changes['pass'] = $INPUT->post->str('newpass');
984 $changes['name'] = $INPUT->post->str('fullname');
985 $changes['mail'] = $INPUT->post->str('email');
987 // check misspelled passwords
988 if ($changes['pass'] != $INPUT->post->str('passchk')) {
989 msg($lang['regbadpass'], -1);
990 return false;
993 // clean fullname and email
994 $changes['name'] = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $changes['name']));
995 $changes['mail'] = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $changes['mail']));
997 // no empty name and email (except the backend doesn't support them)
998 if (
999 (empty($changes['name']) && $auth->canDo('modName')) ||
1000 (empty($changes['mail']) && $auth->canDo('modMail'))
1002 msg($lang['profnoempty'], -1);
1003 return false;
1005 if (!mail_isvalid($changes['mail']) && $auth->canDo('modMail')) {
1006 msg($lang['regbadmail'], -1);
1007 return false;
1010 $changes = array_filter($changes);
1012 // check for unavailable capabilities
1013 if (!$auth->canDo('modName')) unset($changes['name']);
1014 if (!$auth->canDo('modMail')) unset($changes['mail']);
1015 if (!$auth->canDo('modPass')) unset($changes['pass']);
1017 // anything to do?
1018 if ($changes === []) {
1019 msg($lang['profnochange'], -1);
1020 return false;
1023 if ($conf['profileconfirm']) {
1024 if (!$auth->checkPass($INPUT->server->str('REMOTE_USER'), $INPUT->post->str('oldpass'))) {
1025 msg($lang['badpassconfirm'], -1);
1026 return false;
1030 if (!$auth->triggerUserMod('modify', [$INPUT->server->str('REMOTE_USER'), &$changes])) {
1031 msg($lang['proffail'], -1);
1032 return false;
1035 if ($changes['pass']) {
1036 // update cookie and session with the changed data
1037 [/* user */, $sticky, /* pass */] = auth_getCookie();
1038 $pass = auth_encrypt($changes['pass'], auth_cookiesalt(!$sticky, true));
1039 auth_setCookie($INPUT->server->str('REMOTE_USER'), $pass, (bool) $sticky);
1040 } else {
1041 // make sure the session is writable
1042 @session_start();
1043 // invalidate session cache
1044 $_SESSION[DOKU_COOKIE]['auth']['time'] = 0;
1045 session_write_close();
1048 return true;
1052 * Delete the current logged-in user
1054 * @return bool true on success, false on any error
1056 function auth_deleteprofile()
1058 global $conf;
1059 global $lang;
1060 /* @var \dokuwiki\Extension\AuthPlugin $auth */
1061 global $auth;
1062 /* @var Input $INPUT */
1063 global $INPUT;
1065 if (!$INPUT->post->bool('delete')) return false;
1066 if (!checkSecurityToken()) return false;
1068 // action prevented or auth module disallows
1069 if (!actionOK('profile_delete') || !$auth->canDo('delUser')) {
1070 msg($lang['profnodelete'], -1);
1071 return false;
1074 if (!$INPUT->post->bool('confirm_delete')) {
1075 msg($lang['profconfdeletemissing'], -1);
1076 return false;
1079 if ($conf['profileconfirm']) {
1080 if (!$auth->checkPass($INPUT->server->str('REMOTE_USER'), $INPUT->post->str('oldpass'))) {
1081 msg($lang['badpassconfirm'], -1);
1082 return false;
1086 $deleted = [];
1087 $deleted[] = $INPUT->server->str('REMOTE_USER');
1088 if ($auth->triggerUserMod('delete', [$deleted])) {
1089 // force and immediate logout including removing the sticky cookie
1090 auth_logoff();
1091 return true;
1094 return false;
1098 * Send a new password
1100 * This function handles both phases of the password reset:
1102 * - handling the first request of password reset
1103 * - validating the password reset auth token
1105 * @author Benoit Chesneau <benoit@bchesneau.info>
1106 * @author Chris Smith <chris@jalakai.co.uk>
1107 * @author Andreas Gohr <andi@splitbrain.org>
1109 * @return bool true on success, false on any error
1111 function act_resendpwd()
1113 global $lang;
1114 global $conf;
1115 /* @var AuthPlugin $auth */
1116 global $auth;
1117 /* @var Input $INPUT */
1118 global $INPUT;
1120 if (!actionOK('resendpwd')) {
1121 msg($lang['resendna'], -1);
1122 return false;
1125 $token = preg_replace('/[^a-f0-9]+/', '', $INPUT->str('pwauth'));
1127 if ($token) {
1128 // we're in token phase - get user info from token
1130 $tfile = $conf['cachedir'] . '/' . $token[0] . '/' . $token . '.pwauth';
1131 if (!file_exists($tfile)) {
1132 msg($lang['resendpwdbadauth'], -1);
1133 $INPUT->remove('pwauth');
1134 return false;
1136 // token is only valid for 3 days
1137 if ((time() - filemtime($tfile)) > (3 * 60 * 60 * 24)) {
1138 msg($lang['resendpwdbadauth'], -1);
1139 $INPUT->remove('pwauth');
1140 @unlink($tfile);
1141 return false;
1144 $user = io_readfile($tfile);
1145 $userinfo = $auth->getUserData($user, $requireGroups = false);
1146 if (!$userinfo['mail']) {
1147 msg($lang['resendpwdnouser'], -1);
1148 return false;
1151 if (!$conf['autopasswd']) { // we let the user choose a password
1152 $pass = $INPUT->str('pass');
1154 // password given correctly?
1155 if (!$pass) return false;
1156 if ($pass != $INPUT->str('passchk')) {
1157 msg($lang['regbadpass'], -1);
1158 return false;
1161 // change it
1162 if (!$auth->triggerUserMod('modify', [$user, ['pass' => $pass]])) {
1163 msg($lang['proffail'], -1);
1164 return false;
1166 } else { // autogenerate the password and send by mail
1167 $pass = auth_pwgen($user);
1168 if (!$auth->triggerUserMod('modify', [$user, ['pass' => $pass]])) {
1169 msg($lang['proffail'], -1);
1170 return false;
1173 if (auth_sendPassword($user, $pass)) {
1174 msg($lang['resendpwdsuccess'], 1);
1175 } else {
1176 msg($lang['regmailfail'], -1);
1180 @unlink($tfile);
1181 return true;
1182 } else {
1183 // we're in request phase
1185 if (!$INPUT->post->bool('save')) return false;
1187 if (!$INPUT->post->str('login')) {
1188 msg($lang['resendpwdmissing'], -1);
1189 return false;
1190 } else {
1191 $user = trim($auth->cleanUser($INPUT->post->str('login')));
1194 $userinfo = $auth->getUserData($user, $requireGroups = false);
1195 if (!$userinfo['mail']) {
1196 msg($lang['resendpwdnouser'], -1);
1197 return false;
1200 // generate auth token
1201 $token = md5(auth_randombytes(16)); // random secret
1202 $tfile = $conf['cachedir'] . '/' . $token[0] . '/' . $token . '.pwauth';
1203 $url = wl('', ['do' => 'resendpwd', 'pwauth' => $token], true, '&');
1205 io_saveFile($tfile, $user);
1207 $text = rawLocale('pwconfirm');
1208 $trep = ['FULLNAME' => $userinfo['name'], 'LOGIN' => $user, 'CONFIRM' => $url];
1210 $mail = new Mailer();
1211 $mail->to($userinfo['name'] . ' <' . $userinfo['mail'] . '>');
1212 $mail->subject($lang['regpwmail']);
1213 $mail->setBody($text, $trep);
1214 if ($mail->send()) {
1215 msg($lang['resendpwdconfirm'], 1);
1216 } else {
1217 msg($lang['regmailfail'], -1);
1219 return true;
1221 // never reached
1225 * Encrypts a password using the given method and salt
1227 * If the selected method needs a salt and none was given, a random one
1228 * is chosen.
1230 * @author Andreas Gohr <andi@splitbrain.org>
1232 * @param string $clear The clear text password
1233 * @param string $method The hashing method
1234 * @param string $salt A salt, null for random
1235 * @return string The crypted password
1237 function auth_cryptPassword($clear, $method = '', $salt = null)
1239 global $conf;
1240 if (empty($method)) $method = $conf['passcrypt'];
1242 $pass = new PassHash();
1243 $call = 'hash_' . $method;
1245 if (!method_exists($pass, $call)) {
1246 msg("Unsupported crypt method $method", -1);
1247 return false;
1250 return $pass->$call($clear, $salt);
1254 * Verifies a cleartext password against a crypted hash
1256 * @author Andreas Gohr <andi@splitbrain.org>
1258 * @param string $clear The clear text password
1259 * @param string $crypt The hash to compare with
1260 * @return bool true if both match
1262 function auth_verifyPassword($clear, $crypt)
1264 $pass = new PassHash();
1265 return $pass->verify_hash($clear, $crypt);
1269 * Set the authentication cookie and add user identification data to the session
1271 * @param string $user username
1272 * @param string $pass encrypted password
1273 * @param bool $sticky whether or not the cookie will last beyond the session
1274 * @return bool
1276 function auth_setCookie($user, $pass, $sticky)
1278 global $conf;
1279 /* @var AuthPlugin $auth */
1280 global $auth;
1281 global $USERINFO;
1283 if (!$auth) return false;
1284 $USERINFO = $auth->getUserData($user);
1286 // set cookie
1287 $cookie = base64_encode($user) . '|' . ((int) $sticky) . '|' . base64_encode($pass);
1288 $cookieDir = empty($conf['cookiedir']) ? DOKU_REL : $conf['cookiedir'];
1289 $time = $sticky ? (time() + 60 * 60 * 24 * 365) : 0; //one year
1290 setcookie(DOKU_COOKIE, $cookie, [
1291 'expires' => $time,
1292 'path' => $cookieDir,
1293 'secure' => ($conf['securecookie'] && is_ssl()),
1294 'httponly' => true,
1295 'samesite' => $conf['samesitecookie'] ?: null, // null means browser default
1298 // set session
1299 $_SESSION[DOKU_COOKIE]['auth']['user'] = $user;
1300 $_SESSION[DOKU_COOKIE]['auth']['pass'] = sha1($pass);
1301 $_SESSION[DOKU_COOKIE]['auth']['buid'] = auth_browseruid();
1302 $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
1303 $_SESSION[DOKU_COOKIE]['auth']['time'] = time();
1305 return true;
1309 * Returns the user, (encrypted) password and sticky bit from cookie
1311 * @returns array
1313 function auth_getCookie()
1315 if (!isset($_COOKIE[DOKU_COOKIE])) {
1316 return [null, null, null];
1318 [$user, $sticky, $pass] = sexplode('|', $_COOKIE[DOKU_COOKIE], 3, '');
1319 $sticky = (bool) $sticky;
1320 $pass = base64_decode($pass);
1321 $user = base64_decode($user);
1322 return [$user, $sticky, $pass];
1325 //Setup VIM: ex: et ts=2 :