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