ipAddress = !empty($ipAddress) ? $ipAddress : (isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null); $this->throttling = isset($throttling) ? (bool) $throttling : true; $this->sessionResyncInterval = isset($sessionResyncInterval) ? ((int) $sessionResyncInterval) : (60 * 5); $this->rememberCookieName = self::createRememberCookieName(); $this->initSessionIfNecessary(); $this->enhanceHttpSecurity(); $this->processRememberDirective(); $this->resyncSessionIfNecessary(); } /** Initializes the session and sets the correct configuration */ private function initSessionIfNecessary() { if (\session_status() === \PHP_SESSION_NONE) { // use cookies to store session IDs \ini_set('session.use_cookies', 1); // use cookies only (do not send session IDs in URLs) \ini_set('session.use_only_cookies', 1); // do not send session IDs in URLs \ini_set('session.use_trans_sid', 0); // start the session (requests a cookie to be written on the client) @Session::start(); } } /** Improves the application's security over HTTP(S) by setting specific headers */ private function enhanceHttpSecurity() { // remove exposure of PHP version (at least where possible) \header_remove('X-Powered-By'); // if the user is signed in if ($this->isLoggedIn()) { // prevent clickjacking \header('X-Frame-Options: sameorigin'); // prevent content sniffing (MIME sniffing) \header('X-Content-Type-Options: nosniff'); // disable caching of potentially sensitive data \header('Cache-Control: no-store, no-cache, must-revalidate', true); \header('Expires: Thu, 19 Nov 1981 00:00:00 GMT', true); \header('Pragma: no-cache', true); } } /** Checks if there is a "remember me" directive set and handles the automatic login (if appropriate) */ private function processRememberDirective() { // if the user is not signed in yet if (!$this->isLoggedIn()) { // if there is currently no cookie for the 'remember me' feature if (!isset($_COOKIE[$this->rememberCookieName])) { // if an old cookie for that feature from versions v1.x.x to v6.x.x has been found if (isset($_COOKIE['auth_remember'])) { // use the value from that old cookie instead $_COOKIE[$this->rememberCookieName] = $_COOKIE['auth_remember']; } } // if a remember cookie is set if (isset($_COOKIE[$this->rememberCookieName])) { // assume the cookie and its contents to be invalid until proven otherwise $valid = false; // split the cookie's content into selector and token $parts = \explode(self::COOKIE_CONTENT_SEPARATOR, $_COOKIE[$this->rememberCookieName], 2); // if both selector and token were found if (!empty($parts[0]) && !empty($parts[1])) { try { $rememberData = $this->db->selectRow( 'SELECT a.user, a.token, a.expires, b.email, b.username, b.status, b.roles_mask, b.force_logout FROM ' . $this->makeTableName('users_remembered') . ' AS a JOIN ' . $this->makeTableName('users') . ' AS b ON a.user = b.id WHERE a.selector = ?', [ $parts[0] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($rememberData)) { if ($rememberData['expires'] >= \time()) { if (\password_verify($parts[1], $rememberData['token'])) { // the cookie and its contents have now been proven to be valid $valid = true; $this->onLoginSuccessful($rememberData['user'], $rememberData['email'], $rememberData['username'], $rememberData['status'], $rememberData['roles_mask'], $rememberData['force_logout'], true); } } } } // if the cookie or its contents have been invalid if (!$valid) { // mark the cookie as such to prevent any further futile attempts $this->setRememberCookie('', '', \time() + 60 * 60 * 24 * 365.25); } } } } private function resyncSessionIfNecessary() { // if the user is signed in if ($this->isLoggedIn()) { // the following session field may not have been initialized for sessions that had already existed before the introduction of this feature if (!isset($_SESSION[self::SESSION_FIELD_LAST_RESYNC])) { $_SESSION[self::SESSION_FIELD_LAST_RESYNC] = 0; } // if it's time for resynchronization if (($_SESSION[self::SESSION_FIELD_LAST_RESYNC] + $this->sessionResyncInterval) <= \time()) { // fetch the authoritative data from the database again try { $authoritativeData = $this->db->selectRow( 'SELECT email, username, status, roles_mask, force_logout FROM ' . $this->makeTableName('users') . ' WHERE id = ?', [ $this->getUserId() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if the user's data has been found if (!empty($authoritativeData)) { // the following session field may not have been initialized for sessions that had already existed before the introduction of this feature if (!isset($_SESSION[self::SESSION_FIELD_FORCE_LOGOUT])) { $_SESSION[self::SESSION_FIELD_FORCE_LOGOUT] = 0; } // if the counter that keeps track of forced logouts has been incremented if ($authoritativeData['force_logout'] > $_SESSION[self::SESSION_FIELD_FORCE_LOGOUT]) { // the user must be signed out $this->logOut(); } // if the counter that keeps track of forced logouts has remained unchanged else { // the session data needs to be updated $_SESSION[self::SESSION_FIELD_EMAIL] = $authoritativeData['email']; $_SESSION[self::SESSION_FIELD_USERNAME] = $authoritativeData['username']; $_SESSION[self::SESSION_FIELD_STATUS] = (int) $authoritativeData['status']; $_SESSION[self::SESSION_FIELD_ROLES] = (int) $authoritativeData['roles_mask']; // remember that we've just performed the required resynchronization $_SESSION[self::SESSION_FIELD_LAST_RESYNC] = \time(); } } // if no data has been found for the user else { // their account may have been deleted so they should be signed out $this->logOut(); } } } } /** * Attempts to sign up a user * * If you want the user's account to be activated by default, pass `null` as the callback * * If you want to make the user verify their email address first, pass an anonymous function as the callback * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * @param string $email the email address to register * @param string $password the password for the new account * @param string|null $username (optional) the username that will be displayed * @param callable|null $callback (optional) the function that sends the confirmation email to the user * @return int the ID of the user that has been created (if any) * @throws InvalidEmailException if the email address was invalid * @throws InvalidPasswordException if the password was invalid * @throws UserAlreadyExistsException if a user with the specified email address already exists * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see confirmEmail * @see confirmEmailAndSignIn */ public function register($email, $password, $username = null, callable $callback = null) { $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); $this->throttle([ 'createNewAccount', $this->getIpAddress() ], 1, (60 * 60 * 12), 5, true); $newUserId = $this->createUserInternal(false, $email, $password, $username, $callback); $this->throttle([ 'createNewAccount', $this->getIpAddress() ], 1, (60 * 60 * 12), 5, false); return $newUserId; } /** * Attempts to sign up a user while ensuring that the username is unique * * If you want the user's account to be activated by default, pass `null` as the callback * * If you want to make the user verify their email address first, pass an anonymous function as the callback * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * @param string $email the email address to register * @param string $password the password for the new account * @param string|null $username (optional) the username that will be displayed * @param callable|null $callback (optional) the function that sends the confirmation email to the user * @return int the ID of the user that has been created (if any) * @throws InvalidEmailException if the email address was invalid * @throws InvalidPasswordException if the password was invalid * @throws UserAlreadyExistsException if a user with the specified email address already exists * @throws DuplicateUsernameException if the specified username wasn't unique * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see confirmEmail * @see confirmEmailAndSignIn */ public function registerWithUniqueUsername($email, $password, $username = null, callable $callback = null) { $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); $this->throttle([ 'createNewAccount', $this->getIpAddress() ], 1, (60 * 60 * 12), 5, true); $newUserId = $this->createUserInternal(true, $email, $password, $username, $callback); $this->throttle([ 'createNewAccount', $this->getIpAddress() ], 1, (60 * 60 * 12), 5, false); return $newUserId; } /** * Attempts to sign in a user with their email address and password * * @param string $email the user's email address * @param string $password the user's password * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws InvalidEmailException if the email address was invalid or could not be found * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function login($email, $password, $rememberDuration = null, callable $onBeforeSuccess = null) { $this->throttle([ 'attemptToLogin', 'email', $email ], 500, (60 * 60 * 24), null, true); $this->authenticateUserInternal($password, $email, null, $rememberDuration, $onBeforeSuccess); } /** * Attempts to sign in a user with their username and password * * When using this method to authenticate users, you should ensure that usernames are unique * * Consistently using {@see registerWithUniqueUsername} instead of {@see register} can be helpful * * @param string $username the user's username * @param string $password the user's password * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws UnknownUsernameException if the specified username does not exist * @throws AmbiguousUsernameException if the specified username is ambiguous, i.e. there are multiple users with that name * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function loginWithUsername($username, $password, $rememberDuration = null, callable $onBeforeSuccess = null) { $this->throttle([ 'attemptToLogin', 'username', $username ], 500, (60 * 60 * 24), null, true); $this->authenticateUserInternal($password, null, $username, $rememberDuration, $onBeforeSuccess); } /** * Attempts to confirm the currently signed-in user's password again * * Whenever you want to confirm the user's identity again, e.g. before * the user is allowed to perform some "dangerous" action, you should * use this method to confirm that the user is who they claim to be. * * For example, when a user has been remembered by a long-lived cookie * and thus {@see isRemembered} returns `true`, this means that the * user has not entered their password for quite some time anymore. * * @param string $password the user's password * @return bool whether the supplied password has been correct * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function reconfirmPassword($password) { if ($this->isLoggedIn()) { try { $password = self::validatePassword($password); } catch (InvalidPasswordException $e) { return false; } $this->throttle([ 'reconfirmPassword', $this->getIpAddress() ], 3, (60 * 60), 4, true); try { $expectedHash = $this->db->selectValue( 'SELECT password FROM ' . $this->makeTableName('users') . ' WHERE id = ?', [ $this->getUserId() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($expectedHash)) { $validated = \password_verify($password, $expectedHash); if (!$validated) { $this->throttle([ 'reconfirmPassword', $this->getIpAddress() ], 3, (60 * 60), 4, false); } return $validated; } else { throw new NotLoggedInException(); } } else { throw new NotLoggedInException(); } } /** * Logs the user out * * @throws AuthError if an internal problem occurred (do *not* catch) */ public function logOut() { // if the user has been signed in if ($this->isLoggedIn()) { // retrieve any locally existing remember directive $rememberDirectiveSelector = $this->getRememberDirectiveSelector(); // if such a remember directive exists if (isset($rememberDirectiveSelector)) { // delete the local remember directive $this->deleteRememberDirectiveForUserById( $this->getUserId(), $rememberDirectiveSelector ); } // remove all session variables maintained by this library unset($_SESSION[self::SESSION_FIELD_LOGGED_IN]); unset($_SESSION[self::SESSION_FIELD_USER_ID]); unset($_SESSION[self::SESSION_FIELD_EMAIL]); unset($_SESSION[self::SESSION_FIELD_USERNAME]); unset($_SESSION[self::SESSION_FIELD_STATUS]); unset($_SESSION[self::SESSION_FIELD_ROLES]); unset($_SESSION[self::SESSION_FIELD_REMEMBERED]); unset($_SESSION[self::SESSION_FIELD_LAST_RESYNC]); unset($_SESSION[self::SESSION_FIELD_FORCE_LOGOUT]); unset($_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL]); unset($_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID]); unset($_SESSION[self::SESSION_FIELD_AWAITING_2FA_REMEMBER_DURATION]); } } /** * Logs the user out in all other sessions (except for the current one) * * @throws NotLoggedInException if the user is not currently signed in * @throws AuthError if an internal problem occurred (do *not* catch) */ public function logOutEverywhereElse() { if (!$this->isLoggedIn()) { throw new NotLoggedInException(); } // determine the expiry date of any locally existing remember directive $previousRememberDirectiveExpiry = $this->getRememberDirectiveExpiry(); // schedule a forced logout in all sessions $this->forceLogoutForUserById($this->getUserId()); // the following session field may not have been initialized for sessions that had already existed before the introduction of this feature if (!isset($_SESSION[self::SESSION_FIELD_FORCE_LOGOUT])) { $_SESSION[self::SESSION_FIELD_FORCE_LOGOUT] = 0; } // ensure that we will simply skip or ignore the next forced logout (which we have just caused) in the current session $_SESSION[self::SESSION_FIELD_FORCE_LOGOUT]++; // re-generate the session ID to prevent session fixation attacks (requests a cookie to be written on the client) Session::regenerate(true); // if there had been an existing remember directive previously if (isset($previousRememberDirectiveExpiry)) { // restore the directive with the old expiry date but new credentials $this->createRememberDirective( $this->getUserId(), $previousRememberDirectiveExpiry - \time() ); } } /** * Logs the user out in all sessions * * @throws NotLoggedInException if the user is not currently signed in * @throws AuthError if an internal problem occurred (do *not* catch) */ public function logOutEverywhere() { if (!$this->isLoggedIn()) { throw new NotLoggedInException(); } // schedule a forced logout in all sessions $this->forceLogoutForUserById($this->getUserId()); // and immediately apply the logout locally $this->logOut(); } /** * Destroys all session data * * @throws AuthError if an internal problem occurred (do *not* catch) */ public function destroySession() { // remove all session variables without exception $_SESSION = []; // delete the session cookie $this->deleteSessionCookie(); // let PHP destroy the session \session_destroy(); } /** * Creates a new directive keeping the user logged in ("remember me") * * @param int $userId the user ID to keep signed in * @param int $duration the duration in seconds * @throws AuthError if an internal problem occurred (do *not* catch) */ private function createRememberDirective($userId, $duration) { $selector = self::createRandomString(24); $token = self::createRandomString(32); $tokenHashed = \password_hash($token, \PASSWORD_DEFAULT); $expires = \time() + ((int) $duration); try { $this->db->insert( $this->makeTableNameComponents('users_remembered'), [ 'user' => $userId, 'selector' => $selector, 'token' => $tokenHashed, 'expires' => $expires ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } $this->setRememberCookie($selector, $token, $expires); } protected function deleteRememberDirectiveForUserById($userId, $selector = null) { parent::deleteRememberDirectiveForUserById($userId, $selector); $this->setRememberCookie(null, null, \time() - 3600); } /** * Sets or updates the cookie that manages the "remember me" token * * @param string|null $selector the selector from the selector/token pair * @param string|null $token the token from the selector/token pair * @param int $expires the UNIX time in seconds which the token should expire at * @throws AuthError if an internal problem occurred (do *not* catch) */ private function setRememberCookie($selector, $token, $expires) { $params = \session_get_cookie_params(); if (isset($selector) && isset($token)) { $content = $selector . self::COOKIE_CONTENT_SEPARATOR . $token; } else { $content = ''; } // save the cookie with the selector and token (requests a cookie to be written on the client) $cookie = new Cookie($this->rememberCookieName); $cookie->setValue($content); $cookie->setExpiryTime($expires); $cookie->setPath($params['path']); $cookie->setDomain($params['domain']); $cookie->setHttpOnly($params['httponly']); $cookie->setSecureOnly($params['secure']); $result = $cookie->save(); if ($result === false) { throw new HeadersAlreadySentError(); } // if we've been deleting the cookie above if (!isset($selector) || !isset($token)) { // attempt to delete a potential old cookie from versions v1.x.x to v6.x.x as well (requests a cookie to be written on the client) $cookie = new Cookie('auth_remember'); $cookie->setPath((!empty($params['path'])) ? $params['path'] : '/'); $cookie->setDomain($params['domain']); $cookie->setHttpOnly($params['httponly']); $cookie->setSecureOnly($params['secure']); $cookie->delete(); } } protected function onLoginSuccessful($userId, $email, $username, $status, $roles, $forceLogout, $remembered) { // update the timestamp of the user's last login try { $this->db->update( $this->makeTableNameComponents('users'), [ 'last_login' => \time() ], [ 'id' => $userId ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } parent::onLoginSuccessful($userId, $email, $username, $status, $roles, $forceLogout, $remembered); } /** * Deletes the session cookie on the client * * @throws AuthError if an internal problem occurred (do *not* catch) */ private function deleteSessionCookie() { $params = \session_get_cookie_params(); // ask for the session cookie to be deleted (requests a cookie to be written on the client) $cookie = new Cookie(\session_name()); $cookie->setPath($params['path']); $cookie->setDomain($params['domain']); $cookie->setHttpOnly($params['httponly']); $cookie->setSecureOnly($params['secure']); $result = $cookie->delete(); if ($result === false) { throw new HeadersAlreadySentError(); } } /** * Confirms an email address (and activates the account) by supplying the correct selector/token pair * * The selector/token pair must have been generated previously by registering a new account * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @return string[] an array with the old email address (if any) at index zero and the new email address (which has just been verified) at index one * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct * @throws TokenExpiredException if the token has already expired * @throws UserAlreadyExistsException if an attempt has been made to change the email address to a (now) occupied address * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function confirmEmail($selector, $token) { $this->throttle([ 'confirmEmail', $this->getIpAddress() ], 5, (60 * 60), 10); $this->throttle([ 'confirmEmail', 'selector', $selector ], 3, (60 * 60), 10); $this->throttle([ 'confirmEmail', 'token', $token ], 3, (60 * 60), 10); try { $confirmationData = $this->db->selectRow( 'SELECT a.id, a.user_id, a.email AS new_email, a.token, a.expires, b.email AS old_email FROM ' . $this->makeTableName('users_confirmations') . ' AS a JOIN ' . $this->makeTableName('users') . ' AS b ON b.id = a.user_id WHERE a.selector = ?', [ $selector ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($confirmationData)) { if (\password_verify($token, $confirmationData['token'])) { if ($confirmationData['expires'] >= \time()) { // invalidate any potential outstanding password reset requests try { $this->db->delete( $this->makeTableNameComponents('users_resets'), [ 'user' => $confirmationData['user_id'] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // mark the email address as verified (and possibly update it to the new address given) try { $this->db->update( $this->makeTableNameComponents('users'), [ 'email' => $confirmationData['new_email'], 'verified' => 1 ], [ 'id' => $confirmationData['user_id'] ] ); } catch (IntegrityConstraintViolationException $e) { throw new UserAlreadyExistsException(); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if the user is currently signed in if ($this->isLoggedIn()) { // if the user has just confirmed an email address for their own account if ($this->getUserId() === $confirmationData['user_id']) { // immediately update the email address in the current session as well $_SESSION[self::SESSION_FIELD_EMAIL] = $confirmationData['new_email']; } } // consume the token just being used for confirmation try { $this->db->delete( $this->makeTableNameComponents('users_confirmations'), [ 'id' => $confirmationData['id'] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if the email address has not been changed but simply been verified if ($confirmationData['old_email'] === $confirmationData['new_email']) { // the output should not contain any previous email address $confirmationData['old_email'] = null; } return [ $confirmationData['old_email'], $confirmationData['new_email'] ]; } else { throw new TokenExpiredException(); } } else { throw new InvalidSelectorTokenPairException(); } } else { throw new InvalidSelectorTokenPairException(); } } /** * Confirms an email address and activates the account by supplying the correct selector/token pair * * The selector/token pair must have been generated previously by registering a new account * * The user will be automatically signed in if this operation is successful * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year * @return string[] an array with the old email address (if any) at index zero and the new email address (which has just been verified) at index one * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct * @throws TokenExpiredException if the token has already expired * @throws UserAlreadyExistsException if an attempt has been made to change the email address to a (now) occupied address * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function confirmEmailAndSignIn($selector, $token, $rememberDuration = null) { $emailBeforeAndAfter = $this->confirmEmail($selector, $token); if (!$this->isLoggedIn()) { if ($emailBeforeAndAfter[1] !== null) { $emailBeforeAndAfter[1] = self::validateEmailAddress($emailBeforeAndAfter[1]); $userData = $this->getUserDataByEmailAddress( $emailBeforeAndAfter[1], [ 'id', 'email', 'username', 'status', 'roles_mask', 'force_logout' ] ); $this->finishSingleFactorOrThrow( $userData['id'], $userData['email'], $userData['username'], $userData['status'], $userData['roles_mask'], $userData['force_logout'], true, $rememberDuration ); } } return $emailBeforeAndAfter; } /** * Changes the currently signed-in user's password while requiring the old password for verification * * @param string $oldPassword the old password to verify account ownership * @param string $newPassword the new password that should be set * @throws NotLoggedInException if the user is not currently signed in * @throws InvalidPasswordException if either the old password has been wrong or the desired new one has been invalid * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function changePassword($oldPassword, $newPassword) { if ($this->reconfirmPassword($oldPassword)) { $this->changePasswordWithoutOldPassword($newPassword); } else { throw new InvalidPasswordException(); } } /** * Changes the currently signed-in user's password without requiring the old password for verification * * @param string $newPassword the new password that should be set * @throws NotLoggedInException if the user is not currently signed in * @throws InvalidPasswordException if the desired new password has been invalid * @throws AuthError if an internal problem occurred (do *not* catch) */ public function changePasswordWithoutOldPassword($newPassword) { if ($this->isLoggedIn()) { $newPassword = self::validatePassword($newPassword); $this->updatePasswordInternal($this->getUserId(), $newPassword); try { $this->logOutEverywhereElse(); } catch (NotLoggedInException $ignored) {} } else { throw new NotLoggedInException(); } } /** * Provides a one-time password as the second factor of authentification after the first factor has already been completed previously * * Two-factor authentification would previously have been enabled by calling {@see prepareTwoFactorViaTotp}, {@see prepareTwoFactorViaSms} or {@see prepareTwoFactorViaEmail}, and then {@see enableTwoFactorViaTotp}, {@see enableTwoFactorViaSms} or {@see enableTwoFactorViaEmail} * * @param string $otpValue a one-time password (OTP) that has just been entered by the user * @throws InvalidOneTimePasswordException if the one-time password provided by the user is not valid * @throws NotLoggedInException if the user has not completed the first factor of authentification recently * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function provideOneTimePasswordAsSecondFactor($otpValue) { if (empty($_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL]) || $_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL] < \time()) { throw new NotLoggedInException(); } if (empty($_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID])) { throw new NotLoggedInException(); } $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID] = (int) $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID]; $otpValue = !empty($otpValue) ? (string) $otpValue : ''; $otpValue = \preg_replace('/[^A-Za-z0-9]/', '', $otpValue); $otpValue = \strtoupper($otpValue); if (empty($otpValue)) { throw new InvalidOneTimePasswordException(); } $this->throttle([ 'provideOneTimePasswordAsSecondFactor', $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID] ], 2, 60 * 15, 3); $this->throttle([ 'provideOneTimePasswordAsSecondFactor', $this->getIpAddress() ], 5, 60 * 15, 3); $otpValueSelector = self::createSelectorForOneTimePassword($otpValue, $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID]); $otpValueToken = \password_hash($otpValue, \PASSWORD_DEFAULT); try { $otpRecords = $this->db->select( 'SELECT id, mechanism, token, expires_at FROM ' . $this->makeTableName('users_otps') . ' WHERE selector = ? AND user_id = ?', [ $otpValueSelector, $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } $success = false; $performTotpVerification = true; if (!empty($otpRecords)) { foreach ($otpRecords as $otpRecord) { if (!empty($otpRecord)) { if (\password_verify($otpValue, $otpRecord['token'])) { // if the mechanism for this one-time password was time-based (TOTP) if (!empty($otpRecord['mechanism']) && $otpRecord['mechanism'] === self::TWO_FACTOR_MECHANISM_TOTP) { // if the one-time password had an expiry time and that time has passed recently if (isset($otpRecord['expires_at']) && $otpRecord['expires_at'] > (\time() - 60 * 15) && $otpRecord['expires_at'] < \time()) { // the one-time password was in fact a TOTP value on our denylist to prevent replay attacks $performTotpVerification = false; continue; } } // if the one-time password had no expiry time, i.e. it was valid indefinitely, or if the expiry time has not passed yet if (!isset($otpRecord['expires_at']) || $otpRecord['expires_at'] >= \time()) { // remove the one-time password from the database to prevent repeated usages try { $this->db->delete( $this->makeTableNameComponents('users_otps'), [ 'id' => $otpRecord['id'] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // remember that we have successfully verified the one-time password now $success = true; break; } } } } } // if the one-time password couldn't be verified yet by looking it up in the database if (!$success) { // if we are still supposed to interpret and verify the one-time password as TOTP if ($performTotpVerification) { try { $totpSecret = $this->db->selectValue( 'SELECT seed FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL', [ $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID], self::TWO_FACTOR_MECHANISM_TOTP, ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($totpSecret)) { if (\Delight\Otp\Otp::verifyTotp($totpSecret, $otpValue)) { // insert the TOTP value into our denylist to prevent replay attacks try { $this->db->insert( $this->makeTableNameComponents('users_otps'), [ 'user_id' => $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID], 'mechanism' => self::TWO_FACTOR_MECHANISM_TOTP, 'single_factor' => 0, 'selector' => $otpValueSelector, 'token' => $otpValueToken, 'expires_at' => \time() - 5 ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // remember that we have successfully verified the one-time password now $success = true; } } } } if ($success) { try { $userData = $this->db->selectRow( 'SELECT email, username, status, roles_mask, force_logout FROM ' . $this->makeTableName('users') . ' WHERE id = ?', [ $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($userData)) { $this->onLoginSuccessful( $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID], $userData['email'], $userData['username'], $userData['status'], $userData['roles_mask'], $userData['force_logout'], false ); if (isset($_SESSION[self::SESSION_FIELD_AWAITING_2FA_REMEMBER_DURATION])) { $this->createRememberDirective( $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID], $_SESSION[self::SESSION_FIELD_AWAITING_2FA_REMEMBER_DURATION] ); } return; } } throw new InvalidOneTimePasswordException(); } /** * Attempts to change the email address of the currently signed-in user (which requires confirmation) * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * @param string $newEmail the desired new email address * @param callable $callback the function that sends the confirmation email to the user * @throws InvalidEmailException if the desired new email address is invalid * @throws UserAlreadyExistsException if a user with the desired new email address already exists * @throws EmailNotVerifiedException if the current (old) email address has not been verified yet * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see confirmEmail * @see confirmEmailAndSignIn */ public function changeEmail($newEmail, callable $callback) { if ($this->isLoggedIn()) { $newEmail = self::validateEmailAddress($newEmail); $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); try { $existingUsersWithNewEmail = $this->db->selectValue( 'SELECT COUNT(*) FROM ' . $this->makeTableName('users') . ' WHERE email = ?', [ $newEmail ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if ((int) $existingUsersWithNewEmail !== 0) { throw new UserAlreadyExistsException(); } try { $verified = $this->db->selectValue( 'SELECT verified FROM ' . $this->makeTableName('users') . ' WHERE id = ?', [ $this->getUserId() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // ensure that at least the current (old) email address has been verified before proceeding if ((int) $verified !== 1) { throw new EmailNotVerifiedException(); } $this->throttle([ 'requestEmailChange', 'userId', $this->getUserId() ], 1, (60 * 60 * 24)); $this->throttle([ 'requestEmailChange', $this->getIpAddress() ], 1, (60 * 60 * 24), 3); $this->createConfirmationRequest($this->getUserId(), $newEmail, $callback); } else { throw new NotLoggedInException(); } } /** * Attempts to re-send an earlier confirmation request for the user with the specified email address * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * @param string $email the email address of the user to re-send the confirmation request for * @param callable $callback the function that sends the confirmation request to the user * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded */ public function resendConfirmationForEmail($email, callable $callback) { $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); $this->resendConfirmationForColumnValue('email', $email, $callback); } /** * Attempts to re-send an earlier confirmation request for the user with the specified ID * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * @param int $userId the ID of the user to re-send the confirmation request for * @param callable $callback the function that sends the confirmation request to the user * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded */ public function resendConfirmationForUserId($userId, callable $callback) { $this->resendConfirmationForColumnValue('user_id', $userId, $callback); } /** * Attempts to re-send an earlier confirmation request * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to verify their email address as a next step, both pieces will be required again * * You must never pass untrusted input to the parameter that takes the column name * * @param string $columnName the name of the column to filter by * @param mixed $columnValue the value to look for in the selected column * @param callable $callback the function that sends the confirmation request to the user * @throws ConfirmationRequestNotFound if no previous request has been found that could be re-sent * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ private function resendConfirmationForColumnValue($columnName, $columnValue, callable $callback) { try { $latestAttempt = $this->db->selectRow( 'SELECT user_id, email FROM ' . $this->makeTableName('users_confirmations') . ' WHERE ' . $columnName . ' = ? ORDER BY id DESC LIMIT 1 OFFSET 0', [ $columnValue ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if ($latestAttempt === null) { throw new ConfirmationRequestNotFound(); } $this->throttle([ 'resendConfirmation', 'userId', $latestAttempt['user_id'] ], 1, (60 * 60 * 6)); $this->throttle([ 'resendConfirmation', $this->getIpAddress() ], 4, (60 * 60 * 24 * 7), 2); $this->createConfirmationRequest( $latestAttempt['user_id'], $latestAttempt['email'], $callback ); } /** * Initiates a password reset request for the user with the specified email address * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to proceed to the second step of the password reset, both pieces will be required again * * @param string $email the email address of the user who wants to request the password reset * @param callable $callback the function that sends the password reset information to the user * @param int|null $requestExpiresAfter (optional) the interval in seconds after which the request should expire * @param int|null $maxOpenRequests (optional) the maximum number of unexpired and unused requests per user * @throws InvalidEmailException if the email address was invalid or could not be found * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email * @throws ResetDisabledException if the user has explicitly disabled password resets for their account * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see canResetPasswordOrThrow * @see canResetPassword * @see resetPassword * @see resetPasswordAndSignIn */ public function forgotPassword($email, callable $callback, $requestExpiresAfter = null, $maxOpenRequests = null) { $email = self::validateEmailAddress($email); $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); if ($requestExpiresAfter === null) { // use six hours as the default $requestExpiresAfter = 60 * 60 * 6; } else { $requestExpiresAfter = (int) $requestExpiresAfter; } if ($maxOpenRequests === null) { // use two requests per user as the default $maxOpenRequests = 2; } else { $maxOpenRequests = (int) $maxOpenRequests; } $userData = $this->getUserDataByEmailAddress( $email, [ 'id', 'verified', 'resettable' ] ); // ensure that the account has been verified before initiating a password reset if ((int) $userData['verified'] !== 1) { throw new EmailNotVerifiedException(); } // do not allow a password reset if the user has explicitly disabled this feature if ((int) $userData['resettable'] !== 1) { throw new ResetDisabledException(); } $openRequests = $this->throttling ? (int) $this->getOpenPasswordResetRequests($userData['id']) : 0; if ($openRequests < $maxOpenRequests) { $this->throttle([ 'requestPasswordReset', $this->getIpAddress() ], 4, (60 * 60 * 24 * 7), 2); $this->throttle([ 'requestPasswordReset', 'user', $userData['id'] ], 4, (60 * 60 * 24 * 7), 2); $this->createPasswordResetRequest($userData['id'], $requestExpiresAfter, $callback); } else { throw new TooManyRequestsException('', $requestExpiresAfter); } } /** * Authenticates an existing user * * @param string $password the user's password * @param string|null $email (optional) the user's email address * @param string|null $username (optional) the user's username * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws InvalidEmailException if the email address was invalid or could not be found * @throws UnknownUsernameException if an attempt has been made to authenticate with a non-existing username * @throws AmbiguousUsernameException if an attempt has been made to authenticate with an ambiguous username * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ private function authenticateUserInternal($password, $email = null, $username = null, $rememberDuration = null, callable $onBeforeSuccess = null) { $this->throttle([ 'enumerateUsers', $this->getIpAddress() ], 1, (60 * 60), 75); $this->throttle([ 'attemptToLogin', $this->getIpAddress() ], 4, (60 * 60), 5, true); $columnsToFetch = [ 'id', 'email', 'password', 'verified', 'username', 'status', 'roles_mask', 'force_logout' ]; if ($email !== null) { $email = self::validateEmailAddress($email); // attempt to look up the account information using the specified email address $userData = $this->getUserDataByEmailAddress( $email, $columnsToFetch ); } elseif ($username !== null) { $username = \trim($username); // attempt to look up the account information using the specified username $userData = $this->getUserDataByUsername( $username, $columnsToFetch ); } // if neither an email address nor a username has been provided else { // we can't do anything here because the method call has been invalid throw new EmailOrUsernameRequiredError(); } $password = self::validatePassword($password); if (\password_verify($password, $userData['password'])) { // if the password needs to be re-hashed to keep up with improving password cracking techniques if (\password_needs_rehash($userData['password'], \PASSWORD_DEFAULT)) { // create a new hash from the password and update it in the database $this->updatePasswordInternal($userData['id'], $password); } if ((int) $userData['verified'] === 1) { if (!isset($onBeforeSuccess) || (\is_callable($onBeforeSuccess) && $onBeforeSuccess($userData['id']) === true)) { // continue to support the old parameter format if ($rememberDuration === true) { $rememberDuration = 60 * 60 * 24 * 28; } elseif ($rememberDuration === false) { $rememberDuration = null; } $this->finishSingleFactorOrThrow( $userData['id'], $userData['email'], $userData['username'], $userData['status'], $userData['roles_mask'], $userData['force_logout'], false, $rememberDuration ); return; } else { $this->throttle([ 'attemptToLogin', $this->getIpAddress() ], 4, (60 * 60), 5, false); if (isset($email)) { $this->throttle([ 'attemptToLogin', 'email', $email ], 500, (60 * 60 * 24), null, false); } elseif (isset($username)) { $this->throttle([ 'attemptToLogin', 'username', $username ], 500, (60 * 60 * 24), null, false); } throw new AttemptCancelledException(); } } else { throw new EmailNotVerifiedException(); } } else { $this->throttle([ 'attemptToLogin', $this->getIpAddress() ], 4, (60 * 60), 5, false); if (isset($email)) { $this->throttle([ 'attemptToLogin', 'email', $email ], 500, (60 * 60 * 24), null, false); } elseif (isset($username)) { $this->throttle([ 'attemptToLogin', 'username', $username ], 500, (60 * 60 * 24), null, false); } // we cannot authenticate the user due to the password being wrong throw new InvalidPasswordException(); } } /** * Either finishes single-factor authentification (if two-factor authentification is not set up), or throws an exception (otherwise) * * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see provideOneTimePasswordAsSecondFactor */ private function finishSingleFactorOrThrow($userId, $email, $username, $status, $roles, $forceLogout, $remembered, $rememberDuration = null) { try { $twoFactorMethods = $this->db->select( 'SELECT mechanism, seed FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND expires_at IS NULL', [ $userId ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if any mechanism for two-factor authentification has been set up for this user if (!empty($twoFactorMethods)) { $secondFactorRequiredException = new SecondFactorRequiredException(); $throttled = false; foreach ($twoFactorMethods as $twoFactorMethod) { if (!empty($twoFactorMethod) && !empty($twoFactorMethod['mechanism'])) { // if the specific mechanism requires that we generate a one-time password randomly now if ($twoFactorMethod['mechanism'] === self::TWO_FACTOR_MECHANISM_SMS || $twoFactorMethod['mechanism'] === self::TWO_FACTOR_MECHANISM_EMAIL) { if (!$throttled) { $this->throttle([ 'generateOtp', $userId ], 1, 60 * 5, 2); $throttled = true; } $otpValue = $this->generateAndStoreRandomOneTimePassword($userId, $twoFactorMethod['mechanism']); if ($twoFactorMethod['mechanism'] === self::TWO_FACTOR_MECHANISM_SMS) { $secondFactorRequiredException->addSmsOption($twoFactorMethod['seed'], $otpValue); } elseif ($twoFactorMethod['mechanism'] === self::TWO_FACTOR_MECHANISM_EMAIL) { $secondFactorRequiredException->addEmailOption($twoFactorMethod['seed'], $otpValue); } else { throw new InvalidStateError(); } } // if the specific mechanism mandates that the one-time password is generated on the client side elseif ($twoFactorMethod['mechanism'] === self::TWO_FACTOR_MECHANISM_TOTP) { $secondFactorRequiredException->addTotpOption(); } else { throw new InvalidStateError(); } } } // allow for the second factor to be completed within five minutes, and consider the first factor to be completed and valid until then $_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL] = \time() + 60 * 5; // remember which user it is that has completed the first factor and is now expected to complete the second factor $_SESSION[self::SESSION_FIELD_AWAITING_2FA_USER_ID] = $userId; // remember the "remember me" duration that the user just requested to be kept after signing in $_SESSION[self::SESSION_FIELD_AWAITING_2FA_REMEMBER_DURATION] = $rememberDuration; // cancel/pause the login attempt for now throw $secondFactorRequiredException; } $this->onLoginSuccessful($userId, $email, $username, $status, $roles, $forceLogout, $remembered); if ($rememberDuration !== null) { $this->createRememberDirective($userId, $rememberDuration); } } private function generateAndStoreRandomOneTimePassword($userId, $mechanism) { // generate a random one-time password $otpLength = 6; $otpValue = \strtoupper(\substr(\Delight\Otp\Otp::createSecret(\Delight\Otp\Otp::SHARED_SECRET_STRENGTH_LOW), 0, $otpLength)); $otpValueSelector = self::createSelectorForOneTimePassword($otpValue, $userId); $otpValueToken = \password_hash($otpValue, \PASSWORD_DEFAULT); // store the generated one-time password for the user and define it to expire after ten minutes try { $this->db->insert( $this->makeTableNameComponents('users_otps'), [ 'user_id' => $userId, 'mechanism' => $mechanism, 'single_factor' => 0, 'selector' => $otpValueSelector, 'token' => $otpValueToken, 'expires_at' => \time() + 60 * 10, ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // delete any old one-time passwords for the user that have expired at least 15 minutes ago try { $this->db->exec( 'DELETE FROM ' . $this->makeTableName('users_otps') . ' WHERE user_id = ? AND expires_at < ?', [ $userId, \time() - 60 * 15 ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } return $otpValue; } /** * Returns the requested user data for the account with the specified email address (if any) * * You must never pass untrusted input to the parameter that takes the column list * * @param string $email the email address to look for * @param array $requestedColumns the columns to request from the user's record * @return array the user data (if an account was found) * @throws InvalidEmailException if the email address could not be found * @throws AuthError if an internal problem occurred (do *not* catch) */ private function getUserDataByEmailAddress($email, array $requestedColumns) { try { $projection = \implode(', ', $requestedColumns); $userData = $this->db->selectRow( 'SELECT ' . $projection . ' FROM ' . $this->makeTableName('users') . ' WHERE email = ?', [ $email ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($userData)) { return $userData; } else { throw new InvalidEmailException(); } } /** * Returns the number of open requests for a password reset by the specified user * * @param int $userId the ID of the user to check the requests for * @return int the number of open requests for a password reset * @throws AuthError if an internal problem occurred (do *not* catch) */ private function getOpenPasswordResetRequests($userId) { try { $requests = $this->db->selectValue( 'SELECT COUNT(*) FROM ' . $this->makeTableName('users_resets') . ' WHERE user = ? AND expires > ?', [ $userId, \time() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($requests)) { return $requests; } else { return 0; } } /** * Creates a new password reset request * * The callback function must have the following signature: * * `function ($selector, $token)` * * Both pieces of information must be sent to the user, usually embedded in a link * * When the user wants to proceed to the second step of the password reset, both pieces will be required again * * @param int $userId the ID of the user who requested the reset * @param int $expiresAfter the interval in seconds after which the request should expire * @param callable $callback the function that sends the password reset information to the user * @throws AuthError if an internal problem occurred (do *not* catch) */ private function createPasswordResetRequest($userId, $expiresAfter, callable $callback) { $selector = self::createRandomString(20); $token = self::createRandomString(20); $tokenHashed = \password_hash($token, \PASSWORD_DEFAULT); $expiresAt = \time() + $expiresAfter; try { $this->db->insert( $this->makeTableNameComponents('users_resets'), [ 'user' => $userId, 'selector' => $selector, 'token' => $tokenHashed, 'expires' => $expiresAt ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (\is_callable($callback)) { $callback($selector, $token); } else { throw new MissingCallbackError(); } } /** * Resets the password for a particular account by supplying the correct selector/token pair * * The selector/token pair must have been generated previously by calling {@see forgotPassword} * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @param string $newPassword the new password to set for the account * @return string[] an array with the user's ID at index `id` and the user's email address at index `email` * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct * @throws TokenExpiredException if the token has already expired * @throws ResetDisabledException if the user has explicitly disabled password resets for their account * @throws InvalidPasswordException if the new password was invalid * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see forgotPassword * @see canResetPasswordOrThrow * @see canResetPassword * @see resetPasswordAndSignIn */ public function resetPassword($selector, $token, $newPassword) { $this->throttle([ 'resetPassword', $this->getIpAddress() ], 5, (60 * 60), 10); $this->throttle([ 'resetPassword', 'selector', $selector ], 3, (60 * 60), 10); $this->throttle([ 'resetPassword', 'token', $token ], 3, (60 * 60), 10); try { $resetData = $this->db->selectRow( 'SELECT a.id, a.user, a.token, a.expires, b.email, b.resettable FROM ' . $this->makeTableName('users_resets') . ' AS a JOIN ' . $this->makeTableName('users') . ' AS b ON b.id = a.user WHERE a.selector = ?', [ $selector ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($resetData)) { if ((int) $resetData['resettable'] === 1) { if (\password_verify($token, $resetData['token'])) { if ($resetData['expires'] >= \time()) { $newPassword = self::validatePassword($newPassword); $this->updatePasswordInternal($resetData['user'], $newPassword); $this->forceLogoutForUserById($resetData['user']); try { $this->db->delete( $this->makeTableNameComponents('users_resets'), [ 'id' => $resetData['id'] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } return [ 'id' => $resetData['user'], 'email' => $resetData['email'] ]; } else { throw new TokenExpiredException(); } } else { throw new InvalidSelectorTokenPairException(); } } else { throw new ResetDisabledException(); } } else { throw new InvalidSelectorTokenPairException(); } } /** * Resets the password for a particular account by supplying the correct selector/token pair * * The selector/token pair must have been generated previously by calling {@see forgotPassword} * * The user will be automatically signed in if this operation is successful * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @param string $newPassword the new password to set for the account * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year * @return string[] an array with the user's ID at index `id` and the user's email address at index `email` * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct * @throws TokenExpiredException if the token has already expired * @throws ResetDisabledException if the user has explicitly disabled password resets for their account * @throws InvalidPasswordException if the new password was invalid * @throws SecondFactorRequiredException if a second factor needs to be provided for authentification * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see forgotPassword * @see canResetPasswordOrThrow * @see canResetPassword * @see resetPassword */ public function resetPasswordAndSignIn($selector, $token, $newPassword, $rememberDuration = null) { $idAndEmail = $this->resetPassword($selector, $token, $newPassword); if (!$this->isLoggedIn()) { $idAndEmail['email'] = self::validateEmailAddress($idAndEmail['email']); $userData = $this->getUserDataByEmailAddress( $idAndEmail['email'], [ 'username', 'status', 'roles_mask', 'force_logout' ] ); $this->finishSingleFactorOrThrow( $idAndEmail['id'], $idAndEmail['email'], $userData['username'], $userData['status'], $userData['roles_mask'], $userData['force_logout'], true, $rememberDuration ); } return $idAndEmail; } /** * Check if the supplied selector/token pair can be used to reset a password * * The password can be reset using the supplied information if this method does *not* throw any exception * * The selector/token pair must have been generated previously by calling {@see forgotPassword} * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @throws InvalidSelectorTokenPairException if either the selector or the token was not correct * @throws TokenExpiredException if the token has already expired * @throws ResetDisabledException if the user has explicitly disabled password resets for their account * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) * * @see forgotPassword * @see canResetPassword * @see resetPassword * @see resetPasswordAndSignIn */ public function canResetPasswordOrThrow($selector, $token) { try { // pass an invalid password intentionally to force an expected error $this->resetPassword($selector, $token, null); // we should already be in one of the `catch` blocks now so this is not expected throw new AuthError(); } // if the password is the only thing that's invalid catch (InvalidPasswordException $ignored) { // the password can be reset } // if some other things failed (as well) catch (AuthException $e) { // re-throw the exception throw $e; } } /** * Check if the supplied selector/token pair can be used to reset a password * * The selector/token pair must have been generated previously by calling {@see forgotPassword} * * @param string $selector the selector from the selector/token pair * @param string $token the token from the selector/token pair * @return bool whether the password can be reset using the supplied information * @throws AuthError if an internal problem occurred (do *not* catch) * * @see forgotPassword * @see canResetPasswordOrThrow * @see resetPassword * @see resetPasswordAndSignIn */ public function canResetPassword($selector, $token) { try { $this->canResetPasswordOrThrow($selector, $token); return true; } catch (AuthException $e) { return false; } } /** * Sets whether password resets should be permitted for the account of the currently signed-in user * * @param bool $enabled whether password resets should be enabled for the user's account * @throws NotLoggedInException if the user is not currently signed in * @throws AuthError if an internal problem occurred (do *not* catch) */ public function setPasswordResetEnabled($enabled) { $enabled = (bool) $enabled; if ($this->isLoggedIn()) { try { $this->db->update( $this->makeTableNameComponents('users'), [ 'resettable' => $enabled ? 1 : 0 ], [ 'id' => $this->getUserId() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } } else { throw new NotLoggedInException(); } } /** * Returns whether password resets are permitted for the account of the currently signed-in user * * @return bool * @throws NotLoggedInException if the user is not currently signed in * @throws AuthError if an internal problem occurred (do *not* catch) */ public function isPasswordResetEnabled() { if ($this->isLoggedIn()) { try { $enabled = $this->db->selectValue( 'SELECT resettable FROM ' . $this->makeTableName('users') . ' WHERE id = ?', [ $this->getUserId() ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } return ((int) $enabled) === 1; } else { throw new NotLoggedInException(); } } /** * Prepares the setup of two-factor authentification via time-based one-time passwords (TOTP) * * After performing this step, the user will be able to add the service or application to their authenticator application * * When the user has entered a one-time password from their authenticator application afterwards, call {@see enableTwoFactorViaTotp} with that one-time password * * @param string|null $serviceName (optional) the name of the service or application that the user interacts with, often the domain name or application title * @return string[] an array with the key URI (which can be encoded as a QR code) at index zero and the secret string (for manual input) at index one * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function prepareTwoFactorViaTotp($serviceName = null) { $serviceName = !empty($serviceName) ? (string) $serviceName : (!empty($_SERVER['SERVER_NAME']) ? (string) $_SERVER['SERVER_NAME'] : (string) $_SERVER['SERVER_ADDR']); return $this->prepareTwoFactor(self::TWO_FACTOR_MECHANISM_TOTP, $serviceName, null); } /** * Prepares the setup of two-factor authentification with one-time passwords sent via SMS * * After performing this step, a one-time password will have to be delivered to the user via SMS and then be requested from the user for verification * * When the user has entered the one-time password from the text message afterwards, call {@see enableTwoFactorViaSms} with that one-time password * * @param string $phoneNumber the phone number to send the one-time passwords to * @return string[] an array with the phone number at index zero and the one-time password to be sent (but not otherwise displayed to the user) at index one * @throws InvalidPhoneNumberException if no valid phone number has been provided * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function prepareTwoFactorViaSms($phoneNumber) { $phoneNumber = !empty($phoneNumber) ? \trim((string) $phoneNumber) : ''; if (\strlen($phoneNumber) < 3) { throw new InvalidPhoneNumberException(); } $this->prepareTwoFactor(self::TWO_FACTOR_MECHANISM_SMS, null, $phoneNumber); $otpValue = $this->generateAndStoreRandomOneTimePassword($this->getUserId(), self::TWO_FACTOR_MECHANISM_SMS); return [ $phoneNumber, $otpValue ]; } /** * Prepares the setup of two-factor authentification with one-time passwords sent via email * * After performing this step, a one-time password will have to be delivered to the user via email and then be requested from the user for verification * * When the user has entered the one-time password from the email afterwards, call {@see enableTwoFactorViaEmail} with that one-time password * * @return string[] an array with the email address at index zero and the one-time password to be sent (but not otherwise displayed to the user) at index one * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function prepareTwoFactorViaEmail() { $this->prepareTwoFactor(self::TWO_FACTOR_MECHANISM_EMAIL, null, null); $otpValue = $this->generateAndStoreRandomOneTimePassword($this->getUserId(), self::TWO_FACTOR_MECHANISM_EMAIL); return [ $this->getEmail(), $otpValue ]; } /** * Prepares the setup of two-factor authentification via a specified mechanism * * @param int $mechanism the specific mechanism to be used for two-factor authentification, as one of the `TWO_FACTOR_MECHANISM_*` constants from this class * @param string|null $serviceName (optional) the name of the service or application that the user interacts with, only used with TOTP 2FA * @param string|null $phoneNumber (optional) the phone number to send the one-time passwords to, only used with SMS 2FA * @return string[] an array with the TOTP configuration, if applicable, namely the key URI at index zero and the secret string at index one, or otherwise an empty array * @throws TwoFactorMechanismAlreadyEnabledException if the specified method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ private function prepareTwoFactor($mechanism, $serviceName = null, $phoneNumber = null) { if (empty($mechanism)) { throw new InvalidStateError(); } if ($mechanism !== self::TWO_FACTOR_MECHANISM_TOTP && $mechanism !== self::TWO_FACTOR_MECHANISM_SMS && $mechanism !== self::TWO_FACTOR_MECHANISM_EMAIL) { throw new InvalidStateError(); } if ($this->isLoggedIn()) { $this->throttle([ 'prepareTwoFactor', 'mechanism', $mechanism, 'userId', $this->getUserId() ], 2, (60 * 60), 2); $this->throttle([ 'prepareTwoFactor', 'mechanism', $mechanism, $this->getIpAddress() ], 3, (60 * 60), 3); try { $existingConfig = $this->db->selectRow( 'SELECT id, expires_at FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ?', [ $this->getUserId(), $mechanism, ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if an existing configuration has been found if (!empty($existingConfig)) { // if the existing configuration has not been completed/enabled yet if (!empty($existingConfig['expires_at'])) { // delete the existing (incomplete) configuration try { $this->db->delete( $this->makeTableNameComponents('users_2fa'), [ 'id' => $existingConfig['id'], 'user_id' => $this->getUserId(), 'mechanism' => $mechanism, 'expires_at' => $existingConfig['expires_at'], ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } } // if the existing configuration has been completed/enabled already else { throw new TwoFactorMechanismAlreadyEnabledException(); } } // create a new configuration if ($mechanism === self::TWO_FACTOR_MECHANISM_TOTP) { $seed = \Delight\Otp\Otp::createSecret(\Delight\Otp\Otp::SHARED_SECRET_STRENGTH_HIGH); $totpKeyUri = \Delight\Otp\Otp::createTotpKeyUriForQrCode($serviceName, $this->getEmail(), $seed); $otpConfig = [ $totpKeyUri, $seed ]; } elseif ($mechanism === self::TWO_FACTOR_MECHANISM_SMS) { $seed = $phoneNumber; $otpConfig = []; } elseif ($mechanism === self::TWO_FACTOR_MECHANISM_EMAIL) { $seed = $this->getEmail(); $otpConfig = []; } else { throw new InvalidStateError(); } try { $this->db->insert( $this->makeTableNameComponents('users_2fa'), [ 'user_id' => $this->getUserId(), 'mechanism' => $mechanism, 'seed' => $seed, 'created_at' => \time(), 'expires_at' => \time() + 60 * 30, ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } return $otpConfig; } else { throw new NotLoggedInException(); } } /** * Completes the previously started setup of two-factor authentification via time-based one-time passwords (TOTP) * * Initially providing a valid one-time password here once is what proves that the setup was successful on the client side * * In order to let the user set up their authenticator application, call {@see prepareTwoFactorViaTotp} as a first step * * @param string $otpValue a one-time password (OTP) that has just been entered by the user * @return string[] a few recovery codes that can be used instead of one-time passwords from the authenticator application in case the user loses access to their TOTP source * @throws InvalidOneTimePasswordException if the one-time password provided by the user is not valid * @throws TwoFactorMechanismNotInitializedException if this method of two-factor authentification has not been initialized before or if the initialization has expired * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function enableTwoFactorViaTotp($otpValue) { return $this->enableTwoFactor(self::TWO_FACTOR_MECHANISM_TOTP, $otpValue); } /** * Completes the previously started setup of two-factor authentification with one-time passwords sent via SMS * * Initially providing a valid one-time password here once is what proves that the setup was successful on the client side * * In order to let the user set up their phone number for OTPs via SMS, call {@see prepareTwoFactorViaSms} as a first step * * @param string $otpValue a one-time password (OTP) that has just been entered by the user * @return string[] a few recovery codes that can be used instead of one-time passwords from text messages in case the user loses access to their phone (number) * @throws InvalidOneTimePasswordException if the one-time password provided by the user is not valid * @throws TwoFactorMechanismNotInitializedException if this method of two-factor authentification has not been initialized before or if the initialization has expired * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function enableTwoFactorViaSms($otpValue) { return $this->enableTwoFactor(self::TWO_FACTOR_MECHANISM_SMS, $otpValue); } /** * Completes the previously started setup of two-factor authentification with one-time passwords sent via email * * Initially providing a valid one-time password here once is what proves that the setup was successful on the client side * * In order to let the user set up their email address for OTPs via email, call {@see prepareTwoFactorViaEmail} as a first step * * @param string $otpValue a one-time password (OTP) that has just been entered by the user * @return string[] a few recovery codes that can be used instead of one-time passwords from emails in case the user loses access to their email (address) * @throws InvalidOneTimePasswordException if the one-time password provided by the user is not valid * @throws TwoFactorMechanismNotInitializedException if this method of two-factor authentification has not been initialized before or if the initialization has expired * @throws TwoFactorMechanismAlreadyEnabledException if this method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ public function enableTwoFactorViaEmail($otpValue) { return $this->enableTwoFactor(self::TWO_FACTOR_MECHANISM_EMAIL, $otpValue); } /** * Completes the previously started setup of two-factor authentification via a specified mechanism * * Initially providing a valid one-time password here once is what proves that the setup was successful on the client side * * In order to let the user start the setup on the client side, call {@see prepareTwoFactorViaTotp}, {@see prepareTwoFactorViaSms} or {@see prepareTwoFactorViaEmail} as a first step * * @param int $mechanism the specific mechanism to be used for two-factor authentification, as one of the `TWO_FACTOR_MECHANISM_*` constants from this class * @param string $otpValue a one-time password (OTP) that has just been entered by the user * @return string[] a few recovery codes that can be used instead of one-time passwords from the configured source in case the user loses access to their source * @throws InvalidOneTimePasswordException if the one-time password provided by the user is not valid * @throws TwoFactorMechanismNotInitializedException if the specified method of two-factor authentification has not been initialized before or if the initialization has expired * @throws TwoFactorMechanismAlreadyEnabledException if the specified method of two-factor authentification has already been enabled * @throws NotLoggedInException if the user is not currently signed in * @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded * @throws AuthError if an internal problem occurred (do *not* catch) */ private function enableTwoFactor($mechanism, $otpValue) { if (empty($mechanism)) { throw new InvalidStateError(); } if ($mechanism !== self::TWO_FACTOR_MECHANISM_TOTP && $mechanism !== self::TWO_FACTOR_MECHANISM_SMS && $mechanism !== self::TWO_FACTOR_MECHANISM_EMAIL) { throw new InvalidStateError(); } if ($this->isLoggedIn()) { $this->throttle([ 'enableTwoFactor', 'mechanism', $mechanism, 'userId', $this->getUserId() ], 2, (60 * 60), 2); $this->throttle([ 'enableTwoFactor', 'mechanism', $mechanism, $this->getIpAddress() ], 3, (60 * 60), 3); $otpValue = !empty($otpValue) ? \trim((string) $otpValue) : null; if (empty($otpValue)) { throw new InvalidOneTimePasswordException(); } try { $existingConfig = $this->db->selectRow( 'SELECT id, seed, expires_at FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ?', [ $this->getUserId(), $mechanism, ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } // if no existing configuration has been found if (empty($existingConfig)) { throw new TwoFactorMechanismNotInitializedException(); } // if an existing configuration had already been completed/enabled if (empty($existingConfig['expires_at'])) { throw new TwoFactorMechanismAlreadyEnabledException(); } // if the existing prepared configuration has already expired if ($existingConfig['expires_at'] < \time()) { throw new TwoFactorMechanismNotInitializedException(); } // check if the one-time password provided by the user is valid if ($mechanism === self::TWO_FACTOR_MECHANISM_TOTP) { $otpValueVerified = \Delight\Otp\Otp::verifyTotp($existingConfig['seed'], $otpValue); } elseif ($mechanism === self::TWO_FACTOR_MECHANISM_SMS || $mechanism === self::TWO_FACTOR_MECHANISM_EMAIL) { $otpValueVerified = false; try { $otpRecords = $this->db->select( 'SELECT id, token FROM ' . $this->makeTableName('users_otps') . ' WHERE selector = ? AND user_id = ? AND mechanism = ? AND expires_at >= ?', [ self::createSelectorForOneTimePassword($otpValue, $this->getUserId()), $this->getUserId(), $mechanism, \time(), ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if (!empty($otpRecords)) { foreach ($otpRecords as $otpRecord) { if (!empty($otpRecord)) { if (\password_verify($otpValue, $otpRecord['token'])) { $otpValueVerified = true; // remove the one-time password from the database to prevent repeated usages try { $this->db->delete( $this->makeTableNameComponents('users_otps'), [ 'id' => $otpRecord['id'] ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } break; } } } } } else { throw new InvalidStateError(); } // fail if the one-time password provided by the user has been invalid if (!$otpValueVerified) { throw new InvalidOneTimePasswordException(); } // now that we know the one-time password has been valid // generate and store recovery codes that are to be presented to the user *once* $recoveryCodes = []; for ($i = 0; $i < 6; $i++) { $recoveryCode = \strtoupper(\Delight\Otp\Otp::createSecret(\Delight\Otp\Otp::SHARED_SECRET_STRENGTH_LOW)); $recoveryCodeSelector = self::createSelectorForOneTimePassword($recoveryCode, $this->getUserId()); $recoveryCodeToken = \password_hash($recoveryCode, \PASSWORD_DEFAULT); try { $this->db->insert( $this->makeTableNameComponents('users_otps'), [ 'user_id' => $this->getUserId(), 'mechanism' => $mechanism, 'single_factor' => 0, 'selector' => $recoveryCodeSelector, 'token' => $recoveryCodeToken, 'expires_at' => null ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } $recoveryCodes[] = $recoveryCode; } // update the existing (incomplete) configuration to complete/enable it try { $this->db->update( $this->makeTableNameComponents('users_2fa'), [ 'expires_at' => null, ], [ 'id' => $existingConfig['id'], 'user_id' => $this->getUserId(), 'mechanism' => $mechanism, 'expires_at' => $existingConfig['expires_at'], ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } return $recoveryCodes; } else { throw new NotLoggedInException(); } } /** * Returns whether the user is currently logged in by reading from the session * * @return boolean whether the user is logged in or not */ public function isLoggedIn() { return isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_LOGGED_IN]) && $_SESSION[self::SESSION_FIELD_LOGGED_IN] === true; } /** * Shorthand/alias for ´isLoggedIn()´ * * @return boolean */ public function check() { return $this->isLoggedIn(); } /** * Returns the currently signed-in user's ID by reading from the session * * @return int the user ID */ public function getUserId() { if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_USER_ID])) { return $_SESSION[self::SESSION_FIELD_USER_ID]; } else { return null; } } /** * Shorthand/alias for {@see getUserId} * * @return int */ public function id() { return $this->getUserId(); } /** * Returns the currently signed-in user's email address by reading from the session * * @return string the email address */ public function getEmail() { if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_EMAIL])) { return $_SESSION[self::SESSION_FIELD_EMAIL]; } else { return null; } } /** * Returns the currently signed-in user's display name by reading from the session * * @return string the display name */ public function getUsername() { if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_USERNAME])) { return $_SESSION[self::SESSION_FIELD_USERNAME]; } else { return null; } } /** * Returns the currently signed-in user's status by reading from the session * * @return int the status as one of the constants from the {@see Status} class */ public function getStatus() { if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_STATUS])) { return $_SESSION[self::SESSION_FIELD_STATUS]; } else { return null; } } /** * Returns whether the currently signed-in user is in "normal" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isNormal() { return $this->getStatus() === Status::NORMAL; } /** * Returns whether the currently signed-in user is in "archived" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isArchived() { return $this->getStatus() === Status::ARCHIVED; } /** * Returns whether the currently signed-in user is in "banned" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isBanned() { return $this->getStatus() === Status::BANNED; } /** * Returns whether the currently signed-in user is in "locked" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isLocked() { return $this->getStatus() === Status::LOCKED; } /** * Returns whether the currently signed-in user is in "pending review" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isPendingReview() { return $this->getStatus() === Status::PENDING_REVIEW; } /** * Returns whether the currently signed-in user is in "suspended" state * * @return bool * * @see Status * @see Auth::getStatus */ public function isSuspended() { return $this->getStatus() === Status::SUSPENDED; } /** * Returns whether the currently signed-in user has the specified role * * @param int $role the role as one of the constants from the {@see Role} class * @return bool * * @see Role */ public function hasRole($role) { if (empty($role) || !\is_numeric($role)) { return false; } if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_ROLES])) { $role = (int) $role; return (((int) $_SESSION[self::SESSION_FIELD_ROLES]) & $role) === $role; } else { return false; } } /** * Returns whether the currently signed-in user has *any* of the specified roles * * @param int[] ...$roles the roles as constants from the {@see Role} class * @return bool * * @see Role */ public function hasAnyRole(...$roles) { foreach ($roles as $role) { if ($this->hasRole($role)) { return true; } } return false; } /** * Returns whether the currently signed-in user has *all* of the specified roles * * @param int[] ...$roles the roles as constants from the {@see Role} class * @return bool * * @see Role */ public function hasAllRoles(...$roles) { foreach ($roles as $role) { if (!$this->hasRole($role)) { return false; } } return true; } /** * Returns an array of the user's roles, mapping the numerical values to their descriptive names * * @return array */ public function getRoles() { return \array_filter( Role::getMap(), [ $this, 'hasRole' ], \ARRAY_FILTER_USE_KEY ); } /** * Returns whether the currently signed-in user has been remembered by a long-lived cookie * * @return bool whether they have been remembered */ public function isRemembered() { if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_REMEMBERED])) { return $_SESSION[self::SESSION_FIELD_REMEMBERED]; } else { return null; } } /** * Returns the user's current IP address * * @return string the IP address (IPv4 or IPv6) */ public function getIpAddress() { return $this->ipAddress; } /** * Returns whether we are waiting for the user to complete the second factor of (two-factor) authentification, them having successfully completed the first factor before * * @return bool */ public function isWaitingForSecondFactor() { return !empty($_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL]) && $_SESSION[self::SESSION_FIELD_AWAITING_2FA_UNTIL] >= \time(); } /** * Performs throttling or rate limiting using the token bucket algorithm (inverse leaky bucket algorithm) * * @param array $criteria the individual criteria that together describe the resource that is being throttled * @param int $supply the number of units to provide per interval (>= 1) * @param int $interval the interval (in seconds) for which the supply is provided (>= 5) * @param int|null $burstiness (optional) the permitted degree of variation or unevenness during peaks (>= 1) * @param bool|null $simulated (optional) whether to simulate a dry run instead of actually consuming the requested units * @param int|null $cost (optional) the number of units to request (>= 1) * @param bool|null $force (optional) whether to apply throttling locally (with this call) even when throttling has been disabled globally (on the instance, via the constructor option) * @return float the number of units remaining from the supply * @throws TooManyRequestsException if the actual demand has exceeded the designated supply * @throws AuthError if an internal problem occurred (do *not* catch) */ public function throttle(array $criteria, $supply, $interval, $burstiness = null, $simulated = null, $cost = null, $force = null) { // validate the supplied parameters and set appropriate defaults where necessary $force = ($force !== null) ? (bool) $force : false; if (!$this->throttling && !$force) { return $supply; } // generate a unique key for the bucket (consisting of 44 or fewer ASCII characters) $key = Base64::encodeUrlSafeWithoutPadding( \hash( 'sha256', \implode("\n", $criteria), true ) ); // validate the supplied parameters and set appropriate defaults where necessary $burstiness = ($burstiness !== null) ? (int) $burstiness : 1; $simulated = ($simulated !== null) ? (bool) $simulated : false; $cost = ($cost !== null) ? (int) $cost : 1; $now = \time(); // determine the volume of the bucket $capacity = $burstiness * (int) $supply; // calculate the rate at which the bucket is refilled (per second) $bandwidthPerSecond = (int) $supply / (int) $interval; try { $bucket = $this->db->selectRow( 'SELECT tokens, replenished_at FROM ' . $this->makeTableName('users_throttling') . ' WHERE bucket = ?', [ $key ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if ($bucket === null) { $bucket = []; } // initialize the number of tokens in the bucket $bucket['tokens'] = isset($bucket['tokens']) ? (float) $bucket['tokens'] : (float) $capacity; // initialize the last time that the bucket has been refilled (as a Unix timestamp in seconds) $bucket['replenished_at'] = isset($bucket['replenished_at']) ? (int) $bucket['replenished_at'] : $now; // replenish the bucket as appropriate $secondsSinceLastReplenishment = \max(0, $now - $bucket['replenished_at']); $tokensToAdd = $secondsSinceLastReplenishment * $bandwidthPerSecond; $bucket['tokens'] = \min((float) $capacity, $bucket['tokens'] + $tokensToAdd); $bucket['replenished_at'] = $now; $accepted = $bucket['tokens'] >= $cost; if (!$simulated) { if ($accepted) { // remove the requested number of tokens from the bucket $bucket['tokens'] = \max(0, $bucket['tokens'] - $cost); } // set the earliest time after which the bucket *may* be deleted (as a Unix timestamp in seconds) $bucket['expires_at'] = $now + \floor($capacity / $bandwidthPerSecond * 2); // merge the updated bucket into the database try { $affected = $this->db->update( $this->makeTableNameComponents('users_throttling'), $bucket, [ 'bucket' => $key ] ); } catch (Error $e) { throw new DatabaseError($e->getMessage()); } if ($affected === 0) { $bucket['bucket'] = $key; try { $this->db->insert( $this->makeTableNameComponents('users_throttling'), $bucket ); } catch (IntegrityConstraintViolationException $ignored) {} catch (Error $e) { throw new DatabaseError($e->getMessage()); } } } if ($accepted) { return $bucket['tokens']; } else { $tokensMissing = $cost - $bucket['tokens']; $estimatedWaitingTimeSeconds = \ceil($tokensMissing / $bandwidthPerSecond); throw new TooManyRequestsException('', $estimatedWaitingTimeSeconds); } } /** * Returns the component that can be used for administrative tasks * * You must offer access to this interface to authorized users only (restricted via your own access control) * * @return Administration */ public function admin() { return new Administration($this->db, $this->dbTablePrefix, $this->dbSchema); } /** * Creates a UUID v4 as per RFC 4122 * * The UUID contains 128 bits of data (where 122 are random), i.e. 36 characters * * @return string the UUID * @author Jack @ Stack Overflow */ public static function createUuid() { $data = \openssl_random_pseudo_bytes(16); // set the version to 0100 $data[6] = \chr(\ord($data[6]) & 0x0f | 0x40); // set bits 6-7 to 10 $data[8] = \chr(\ord($data[8]) & 0x3f | 0x80); return \vsprintf('%s%s-%s-%s-%s-%s%s%s', \str_split(\bin2hex($data), 4)); } /** * Generates a unique cookie name for the given descriptor based on the supplied seed * * @param string $descriptor a short label describing the purpose of the cookie, e.g. 'session' * @param string|null $seed (optional) the data to deterministically generate the name from * @return string */ public static function createCookieName($descriptor, $seed = null) { // use the supplied seed or the current UNIX time in seconds $seed = ($seed !== null) ? $seed : \time(); foreach (self::COOKIE_PREFIXES as $cookiePrefix) { // if the seed contains a certain cookie prefix if (\strpos($seed, $cookiePrefix) === 0) { // prepend the same prefix to the descriptor $descriptor = $cookiePrefix . $descriptor; } } // generate a unique token based on the name(space) of this library and on the seed $token = Base64::encodeUrlSafeWithoutPadding( \md5( __NAMESPACE__ . "\n" . $seed, true ) ); return $descriptor . '_' . $token; } /** * Generates a unique cookie name for the 'remember me' feature * * @param string|null $sessionName (optional) the session name that the output should be based on * @return string */ public static function createRememberCookieName($sessionName = null) { return self::createCookieName( 'remember', ($sessionName !== null) ? $sessionName : \session_name() ); } private static function createSelectorForOneTimePassword($otpValue, $userId = null) { $userId = !empty($userId) ? (int) $userId : 0; $key = $userId . '#' . $otpValue; return \substr(\sha1($key, false), 0, 24); } /** * Returns the selector of a potential locally existing remember directive * * @return string|null */ private function getRememberDirectiveSelector() { if (isset($_COOKIE[$this->rememberCookieName])) { $selectorAndToken = \explode(self::COOKIE_CONTENT_SEPARATOR, $_COOKIE[$this->rememberCookieName], 2); return $selectorAndToken[0]; } else { return null; } } /** * Returns the expiry date of a potential locally existing remember directive * * @return int|null */ private function getRememberDirectiveExpiry() { // if the user is currently signed in if ($this->isLoggedIn()) { // determine the selector of any currently existing remember directive $existingSelector = $this->getRememberDirectiveSelector(); // if there is currently a remember directive whose selector we have just retrieved if (isset($existingSelector)) { // fetch the expiry date for the given selector $existingExpiry = $this->db->selectValue( 'SELECT expires FROM ' . $this->makeTableName('users_remembered') . ' WHERE selector = ? AND user = ?', [ $existingSelector, $this->getUserId() ] ); // if an expiration date has been found if (isset($existingExpiry)) { // return the date return (int) $existingExpiry; } } } return null; } }