1
0
mirror of https://github.com/delight-im/PHP-Auth.git synced 2025-10-21 19:06:49 +02:00
Files
php-auth/src/Auth.php

2901 lines
105 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* PHP-Auth (https://github.com/delight-im/PHP-Auth)
* Copyright (c) delight.im (https://www.delight.im/)
* Licensed under the MIT License (https://opensource.org/licenses/MIT)
*/
namespace Delight\Auth;
use Delight\Base64\Base64;
use Delight\Cookie\Cookie;
use Delight\Cookie\Session;
use Delight\Db\PdoDatabase;
use Delight\Db\PdoDsn;
use Delight\Db\Throwable\Error;
use Delight\Db\Throwable\IntegrityConstraintViolationException;
/** Component that provides all features and utilities for secure authentication of individual users */
final class Auth extends UserManager {
const COOKIE_PREFIXES = [ Cookie::PREFIX_SECURE, Cookie::PREFIX_HOST ];
const COOKIE_CONTENT_SEPARATOR = '~';
const TWO_FACTOR_MECHANISM_TOTP = 1;
const TWO_FACTOR_MECHANISM_SMS = 2;
const TWO_FACTOR_MECHANISM_EMAIL = 3;
/** @var string the user's current IP address */
private $ipAddress;
/** @var bool whether throttling should be enabled (e.g. in production) or disabled (e.g. during development) */
private $throttling;
/** @var int the interval in seconds after which to resynchronize the session data with its authoritative source in the database */
private $sessionResyncInterval;
/** @var string the name of the cookie used for the 'remember me' feature */
private $rememberCookieName;
/**
* @param PdoDatabase|PdoDsn|\PDO $databaseConnection the database connection to operate on
* @param string|null $ipAddress (optional) the IP address that should be used instead of the default setting (if any), e.g. when behind a proxy
* @param string|null $dbTablePrefix (optional) the prefix for the names of all database tables used by this component
* @param bool|null $throttling (optional) whether throttling should be enabled (e.g. in production) or disabled (e.g. during development)
* @param int|null $sessionResyncInterval (optional) the interval in seconds after which to resynchronize the session data with its authoritative source in the database
* @param string|null $dbSchema (optional) the schema name for all database tables used by this component
*/
public function __construct($databaseConnection, $ipAddress = null, $dbTablePrefix = null, $throttling = null, $sessionResyncInterval = null, $dbSchema = null) {
parent::__construct($databaseConnection, $dbTablePrefix, $dbSchema);
$this->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) deprecated
* @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 deprecated
* @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) deprecated
* @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 deprecated
* @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() === ((int) $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, true);
$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, with or without spaces and special characters
* @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) ? self::sanitizeOtpValue((string) $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']) && ((int) $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) deprecated
* @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 deprecated
* @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 (((int) $twoFactorMethod['mechanism']) === self::TWO_FACTOR_MECHANISM_SMS || ((int) $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 (((int) $twoFactorMethod['mechanism']) === self::TWO_FACTOR_MECHANISM_SMS) {
$secondFactorRequiredException->addSmsOption($otpValue, $twoFactorMethod['seed'], \Delight\Auth\PhoneNumber::mask($twoFactorMethod['seed']));
}
elseif (((int) $twoFactorMethod['mechanism']) === self::TWO_FACTOR_MECHANISM_EMAIL) {
$secondFactorRequiredException->addEmailOption($otpValue, $twoFactorMethod['seed'], \Delight\Auth\EmailAddress::mask($twoFactorMethod['seed']));
}
else {
throw new InvalidStateError();
}
}
// if the specific mechanism mandates that the one-time password is generated on the client side
elseif (((int) $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
$otpValue = null;
if (\PHP_VERSION_ID >= 70000) {
try {
$otpValue = \random_int(0, 999999);
$otpValue = \sprintf('%06d', $otpValue);
}
catch (\Exception $e) {
$otpValue = null;
}
}
if (empty($otpValue)) {
$otpValue = \Delight\Otp\Otp::createSecret(\Delight\Otp\Otp::SHARED_SECRET_STRENGTH_LOW);
$otpValue = \substr($otpValue, 0, 6);
$otpValue = \strtoupper($otpValue);
}
// create a selector/token pair from the generated one-time password
$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, true);
$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());
}
// delete any one-time passwords for the user that were for (verification of) the old configuration
try {
$this->db->exec(
'DELETE FROM ' . $this->makeTableName('users_otps') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NOT NULL',
[
$this->getUserId(),
$mechanism,
]
);
}
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, with or without spaces and special characters
* @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, with or without spaces and special characters
* @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, with or without spaces and special characters
* @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, with or without spaces and special characters
* @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) ? self::sanitizeOtpValue((string) $otpValue) : '';
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 the currently signed-in user has enabled two-factor authentification
*
* @return bool
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function hasTwoFactor() {
if ($this->isLoggedIn()) {
try {
$enabled = $this->db->selectValue(
'SELECT COUNT(*) FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND expires_at IS NULL',
[ $this->getUserId() ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
return ((int) $enabled) > 0;
}
else {
return false;
}
}
/**
* Returns whether the currently signed-in user has enabled two-factor authentification via time-based one-time passwords (TOTP)
*
* @return bool
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function hasTwoFactorViaTotp() {
if ($this->isLoggedIn()) {
try {
$enabled = $this->db->selectValue(
'SELECT COUNT(*) FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_TOTP ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
return ((int) $enabled) > 0;
}
else {
return false;
}
}
/**
* Returns whether the currently signed-in user has enabled two-factor authentification with one-time passwords sent via SMS
*
* @return bool
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function hasTwoFactorViaSms() {
if ($this->isLoggedIn()) {
try {
$enabled = $this->db->selectValue(
'SELECT COUNT(*) FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_SMS ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
return ((int) $enabled) > 0;
}
else {
return false;
}
}
/**
* Returns whether the currently signed-in user has enabled two-factor authentification with one-time passwords sent via email
*
* @return bool
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function hasTwoFactorViaEmail() {
if ($this->isLoggedIn()) {
try {
$enabled = $this->db->selectValue(
'SELECT COUNT(*) FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_EMAIL ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
return ((int) $enabled) > 0;
}
else {
return false;
}
}
/**
* 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();
}
/**
* Disables two-factor authentification via time-based one-time passwords (TOTP) for the currently signed-in user
*
* @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 disableTwoFactorViaTotp() {
if ($this->isLoggedIn()) {
try {
$this->db->exec(
'DELETE FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_TOTP ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
}
else {
throw new NotLoggedInException();
}
}
/**
* Disables two-factor authentification with one-time passwords sent via SMS for the currently signed-in user
*
* @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 disableTwoFactorViaSms() {
if ($this->isLoggedIn()) {
try {
$this->db->exec(
'DELETE FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_SMS ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
}
else {
throw new NotLoggedInException();
}
}
/**
* Disables two-factor authentification with one-time passwords sent via email for the currently signed-in user
*
* @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 disableTwoFactorViaEmail() {
if ($this->isLoggedIn()) {
try {
$this->db->exec(
'DELETE FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND mechanism = ? AND expires_at IS NULL',
[ $this->getUserId(), self::TWO_FACTOR_MECHANISM_EMAIL ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
}
else {
throw new NotLoggedInException();
}
}
/**
* Disables two-factor authentification by any method for the currently signed-in user
*
* @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 disableTwoFactor() {
if ($this->isLoggedIn()) {
try {
$this->db->exec(
'DELETE FROM ' . $this->makeTableName('users_2fa') . ' WHERE user_id = ? AND expires_at IS NULL',
[ $this->getUserId() ]
);
}
catch (Error $e) {
throw new DatabaseError($e->getMessage());
}
}
else {
throw new NotLoggedInException();
}
}
/**
* 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;
}
private static function sanitizeOtpValue($otpValue) {
$otpValue = \trim($otpValue);
$otpValue = \preg_replace('/[^A-Za-z0-9]/', '', $otpValue);
$otpValue = \strtoupper($otpValue);
return $otpValue;
}
}