mirror of
https://github.com/delight-im/PHP-Auth.git
synced 2025-10-21 19:06:49 +02:00
1642 lines
52 KiB
PHP
1642 lines
52 KiB
PHP
<?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;
|
||
|
||
require_once __DIR__ . '/Exceptions.php';
|
||
|
||
/** Component that provides all features and utilities for secure authentication of individual users */
|
||
final class Auth extends UserManager {
|
||
|
||
const SESSION_FIELD_LOGGED_IN = 'auth_logged_in';
|
||
const SESSION_FIELD_USER_ID = 'auth_user_id';
|
||
const SESSION_FIELD_EMAIL = 'auth_email';
|
||
const SESSION_FIELD_USERNAME = 'auth_username';
|
||
const SESSION_FIELD_STATUS = 'auth_status';
|
||
const SESSION_FIELD_ROLES = 'auth_roles';
|
||
const SESSION_FIELD_REMEMBERED = 'auth_remembered';
|
||
const COOKIE_CONTENT_SEPARATOR = '~';
|
||
const COOKIE_NAME_REMEMBER = 'auth_remember';
|
||
const IP_ADDRESS_HASH_ALGORITHM = 'sha256';
|
||
const HTTP_STATUS_CODE_TOO_MANY_REQUESTS = 429;
|
||
|
||
/** @var boolean whether HTTPS (TLS/SSL) will be used (recommended) */
|
||
private $useHttps;
|
||
/** @var boolean whether cookies should be accessible via client-side scripts (*not* recommended) */
|
||
private $allowCookiesScriptAccess;
|
||
/** @var string the user's current IP address */
|
||
private $ipAddress;
|
||
/** @var int the number of actions allowed (in throttling) per time bucket */
|
||
private $throttlingActionsPerTimeBucket;
|
||
/** @var int the size of the time buckets (used for throttling) in seconds */
|
||
private $throttlingTimeBucketSize;
|
||
|
||
/**
|
||
* @param PdoDatabase|PdoDsn|\PDO $databaseConnection the database connection to operate on
|
||
* @param bool $useHttps whether HTTPS (TLS/SSL) will be used (recommended)
|
||
* @param bool $allowCookiesScriptAccess whether cookies should be accessible via client-side scripts (*not* recommended)
|
||
* @param string $ipAddress 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
|
||
*/
|
||
public function __construct($databaseConnection, $useHttps = false, $allowCookiesScriptAccess = false, $ipAddress = null, $dbTablePrefix = null) {
|
||
parent::__construct($databaseConnection, $dbTablePrefix);
|
||
|
||
$this->useHttps = $useHttps;
|
||
$this->allowCookiesScriptAccess = $allowCookiesScriptAccess;
|
||
$this->ipAddress = empty($ipAddress) ? $_SERVER['REMOTE_ADDR'] : $ipAddress;
|
||
$this->throttlingActionsPerTimeBucket = 20;
|
||
$this->throttlingTimeBucketSize = 3600;
|
||
|
||
$this->initSession();
|
||
$this->enhanceHttpSecurity();
|
||
|
||
$this->processRememberDirective();
|
||
}
|
||
|
||
/** Initializes the session and sets the correct configuration */
|
||
private function initSession() {
|
||
// 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);
|
||
|
||
// get our cookie settings
|
||
$params = $this->createCookieSettings();
|
||
// define our new cookie settings
|
||
session_set_cookie_params($params['lifetime'], $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||
|
||
// start the session
|
||
@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 a remember cookie is set
|
||
if (isset($_COOKIE[self::COOKIE_NAME_REMEMBER])) {
|
||
// split the cookie's content into selector and token
|
||
$parts = explode(self::COOKIE_CONTENT_SEPARATOR, $_COOKIE[self::COOKIE_NAME_REMEMBER], 2);
|
||
// if both selector and token were found
|
||
if (isset($parts[0]) && isset($parts[1])) {
|
||
try {
|
||
$rememberData = $this->db->selectRow(
|
||
'SELECT a.user, a.token, a.expires, b.email, b.username, b.status, b.roles_mask FROM ' . $this->dbTablePrefix . 'users_remembered AS a JOIN ' . $this->dbTablePrefix . 'users AS b ON a.user = b.id WHERE a.selector = ?',
|
||
[ $parts[0] ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($rememberData)) {
|
||
if ($rememberData['expires'] >= time()) {
|
||
if (password_verify($parts[1], $rememberData['token'])) {
|
||
$this->onLoginSuccessful($rememberData['user'], $rememberData['email'], $rememberData['username'], $rememberData['status'], $rememberData['roles_mask'], true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 AuthError if an internal problem occurred (do *not* catch)
|
||
*
|
||
* @see confirmEmail
|
||
* @see confirmEmailAndSignIn
|
||
*/
|
||
public function register($email, $password, $username = null, callable $callback = null) {
|
||
return $this->createUserInternal(false, $email, $password, $username, $callback);
|
||
}
|
||
|
||
/**
|
||
* 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 AuthError if an internal problem occurred (do *not* catch)
|
||
*
|
||
* @see confirmEmail
|
||
* @see confirmEmailAndSignIn
|
||
*/
|
||
public function registerWithUniqueUsername($email, $password, $username = null, callable $callback = null) {
|
||
return $this->createUserInternal(true, $email, $password, $username, $callback);
|
||
}
|
||
|
||
/**
|
||
* Attempts to sign in a user with their email address and password
|
||
*
|
||
* @param string $email the user's email address
|
||
* @param string $password the user's password
|
||
* @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
|
||
* @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel
|
||
* @throws InvalidEmailException if the email address was invalid or could not be found
|
||
* @throws InvalidPasswordException if the password was invalid
|
||
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
|
||
* @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function login($email, $password, $rememberDuration = null, callable $onBeforeSuccess = null) {
|
||
$this->authenticateUserInternal($password, $email, null, $rememberDuration, $onBeforeSuccess);
|
||
}
|
||
|
||
/**
|
||
* Attempts to sign in a user with their username and password
|
||
*
|
||
* When using this method to authenticate users, you should ensure that usernames are unique
|
||
*
|
||
* Consistently using {@see registerWithUniqueUsername} instead of {@see register} can be helpful
|
||
*
|
||
* @param string $username the user's username
|
||
* @param string $password the user's password
|
||
* @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
|
||
* @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel
|
||
* @throws UnknownUsernameException if the specified username does not exist
|
||
* @throws AmbiguousUsernameException if the specified username is ambiguous, i.e. there are multiple users with that name
|
||
* @throws InvalidPasswordException if the password was invalid
|
||
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
|
||
* @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function loginWithUsername($username, $password, $rememberDuration = null, callable $onBeforeSuccess = null) {
|
||
$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 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;
|
||
}
|
||
|
||
try {
|
||
$expectedHash = $this->db->selectValue(
|
||
'SELECT password FROM ' . $this->dbTablePrefix . 'users WHERE id = ?',
|
||
[ $this->getUserId() ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($expectedHash)) {
|
||
return \password_verify($password, $expectedHash);
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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->dbTablePrefix . 'users_remembered',
|
||
[
|
||
'user' => $userId,
|
||
'selector' => $selector,
|
||
'token' => $tokenHashed,
|
||
'expires' => $expires
|
||
]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
$this->setRememberCookie($selector, $token, $expires);
|
||
}
|
||
|
||
/**
|
||
* Clears an existing directive that keeps the user logged in ("remember me")
|
||
*
|
||
* @param int $userId the user ID that shouldn't be kept signed in anymore
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function deleteRememberDirective($userId) {
|
||
try {
|
||
$this->db->delete(
|
||
$this->dbTablePrefix . 'users_remembered',
|
||
[ 'user' => $userId ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
$this->setRememberCookie(null, null, time() - 3600);
|
||
}
|
||
|
||
/**
|
||
* Sets or updates the cookie that manages the "remember me" token
|
||
*
|
||
* @param string $selector the selector from the selector/token pair
|
||
* @param string $token the token from the selector/token pair
|
||
* @param int $expires the interval in seconds after which the token should expire
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function setRememberCookie($selector, $token, $expires) {
|
||
// get our cookie settings
|
||
$params = $this->createCookieSettings();
|
||
|
||
if (isset($selector) && isset($token)) {
|
||
$content = $selector . self::COOKIE_CONTENT_SEPARATOR . $token;
|
||
}
|
||
else {
|
||
$content = '';
|
||
}
|
||
|
||
// set the cookie with the selector and token
|
||
|
||
$cookie = new Cookie(self::COOKIE_NAME_REMEMBER);
|
||
|
||
$cookie->setValue($content);
|
||
$cookie->setExpiryTime($expires);
|
||
|
||
if (!empty($params['path'])) {
|
||
$cookie->setPath($params['path']);
|
||
}
|
||
|
||
if (!empty($params['domain'])) {
|
||
$cookie->setDomain($params['domain']);
|
||
}
|
||
|
||
$cookie->setHttpOnly($params['httponly']);
|
||
$cookie->setSecureOnly($params['secure']);
|
||
|
||
$result = $cookie->save();
|
||
|
||
if ($result === false) {
|
||
throw new HeadersAlreadySentError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called when the user has successfully logged in (via standard login or "remember me")
|
||
*
|
||
* @param int $userId the ID of the user who has just logged in
|
||
* @param string $email the email address of the user who has just logged in
|
||
* @param string $username the username (if any)
|
||
* @param int $status the status as one of the constants from the {@see Status} class
|
||
* @param int $roles the bitmask containing the roles of the user
|
||
* @param bool $remembered whether the user was remembered ("remember me") or logged in actively
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function onLoginSuccessful($userId, $email, $username, $status, $roles, $remembered) {
|
||
try {
|
||
$this->db->update(
|
||
$this->dbTablePrefix . 'users',
|
||
[ 'last_login' => time() ],
|
||
[ 'id' => $userId ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
// re-generate the session ID to prevent session fixation attacks
|
||
Session::regenerate(true);
|
||
|
||
// save the user data in the session
|
||
$this->setLoggedIn(true);
|
||
$this->setUserId($userId);
|
||
$this->setEmail($email);
|
||
$this->setUsername($username);
|
||
$this->setStatus($status);
|
||
$this->setRoles($roles);
|
||
$this->setRemembered($remembered);
|
||
}
|
||
|
||
/**
|
||
* Logs out the user and destroys all session data
|
||
*
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function logout() {
|
||
// if the user has been signed in
|
||
if ($this->isLoggedIn()) {
|
||
// get the user's ID
|
||
$userId = $this->getUserId();
|
||
// if a user ID was set
|
||
if (isset($userId)) {
|
||
// delete any existing remember directives
|
||
$this->deleteRememberDirective($userId);
|
||
}
|
||
}
|
||
|
||
// unset the session variables
|
||
$_SESSION = array();
|
||
|
||
// delete the cookie
|
||
$this->deleteSessionCookie();
|
||
|
||
// destroy the session
|
||
session_destroy();
|
||
}
|
||
|
||
/**
|
||
* Deletes the session cookie on the client
|
||
*
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function deleteSessionCookie() {
|
||
// get our cookie settings
|
||
$params = $this->createCookieSettings();
|
||
|
||
// cause the session cookie to be deleted
|
||
$cookie = new Cookie(session_name());
|
||
if (!empty($params['path'])) {
|
||
$cookie->setPath($params['path']);
|
||
}
|
||
if (!empty($params['domain'])) {
|
||
$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 the email address that has successfully been verified
|
||
* @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 AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function confirmEmail($selector, $token) {
|
||
$this->throttle(self::THROTTLE_ACTION_CONSUME_TOKEN);
|
||
$this->throttle(self::THROTTLE_ACTION_CONSUME_TOKEN, $selector);
|
||
|
||
try {
|
||
$confirmationData = $this->db->selectRow(
|
||
'SELECT id, user_id, email, token, expires FROM ' . $this->dbTablePrefix . 'users_confirmations WHERE selector = ?',
|
||
[ $selector ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($confirmationData)) {
|
||
if (password_verify($token, $confirmationData['token'])) {
|
||
if ($confirmationData['expires'] >= time()) {
|
||
try {
|
||
$this->db->update(
|
||
$this->dbTablePrefix . 'users',
|
||
[
|
||
'email' => $confirmationData['email'],
|
||
'verified' => 1
|
||
],
|
||
[ 'id' => $confirmationData['user_id'] ]
|
||
);
|
||
}
|
||
catch (IntegrityConstraintViolationException $e) {
|
||
throw new UserAlreadyExistsException();
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
// if the user is currently signed in
|
||
if ($this->isLoggedIn()) {
|
||
// if the user has just confirmed an email address for their own account
|
||
if ($this->getUserId() === $confirmationData['user_id']) {
|
||
// immediately update the email address in the current session as well
|
||
$this->setEmail($confirmationData['email']);
|
||
}
|
||
}
|
||
|
||
try {
|
||
$this->db->delete(
|
||
$this->dbTablePrefix . 'users_confirmations',
|
||
[ 'id' => $confirmationData['id'] ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
return $confirmationData['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 the email address that has successfully been verified
|
||
* @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 InvalidEmailException if the email address has been invalid
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function confirmEmailAndSignIn($selector, $token, $rememberDuration = null) {
|
||
$verifiedEmail = $this->confirmEmail($selector, $token);
|
||
|
||
if (!$this->isLoggedIn()) {
|
||
if ($verifiedEmail !== null) {
|
||
$verifiedEmail = self::validateEmailAddress($verifiedEmail);
|
||
|
||
$userData = $this->getUserDataByEmailAddress(
|
||
$verifiedEmail,
|
||
[ 'id', 'email', 'username', 'status', 'roles_mask' ]
|
||
);
|
||
|
||
$this->onLoginSuccessful($userData['id'], $userData['email'], $userData['username'], $userData['status'], $userData['roles_mask'], true);
|
||
|
||
if ($rememberDuration !== null) {
|
||
$this->createRememberDirective($userData['id'], $rememberDuration);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $verifiedEmail;
|
||
}
|
||
|
||
/**
|
||
* Changes the (currently logged-in) user's password
|
||
*
|
||
* @param string $oldPassword the old password to verify account ownership
|
||
* @param string $newPassword the new password that should be used
|
||
* @throws NotLoggedInException if the user is not currently logged in
|
||
* @throws InvalidPasswordException if either the old password was wrong or the new password was invalid
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function changePassword($oldPassword, $newPassword) {
|
||
if ($this->isLoggedIn()) {
|
||
$oldPassword = self::validatePassword($oldPassword);
|
||
$newPassword = self::validatePassword($newPassword);
|
||
|
||
$userId = $this->getUserId();
|
||
|
||
try {
|
||
$passwordInDatabase = $this->db->selectValue(
|
||
'SELECT password FROM ' . $this->dbTablePrefix . 'users WHERE id = ?',
|
||
[ $userId ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($passwordInDatabase)) {
|
||
if (password_verify($oldPassword, $passwordInDatabase)) {
|
||
// update the password in the database
|
||
$this->updatePassword($userId, $newPassword);
|
||
|
||
// delete any remaining remember directives
|
||
$this->deleteRememberDirective($userId);
|
||
}
|
||
else {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Updates the given user's password by setting it to the new specified password
|
||
*
|
||
* @param int $userId the ID of the user whose password should be updated
|
||
* @param string $newPassword the new password
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function updatePassword($userId, $newPassword) {
|
||
$newPassword = password_hash($newPassword, PASSWORD_DEFAULT);
|
||
|
||
try {
|
||
$this->db->update(
|
||
$this->dbTablePrefix . 'users',
|
||
[ 'password' => $newPassword ],
|
||
[ 'id' => $userId ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 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);
|
||
|
||
try {
|
||
$existingUsersWithNewEmail = $this->db->selectValue(
|
||
'SELECT COUNT(*) FROM ' . $this->dbTablePrefix . 'users WHERE email = ?',
|
||
[ $newEmail ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if ((int) $existingUsersWithNewEmail !== 0) {
|
||
throw new UserAlreadyExistsException();
|
||
}
|
||
|
||
try {
|
||
$verified = $this->db->selectValue(
|
||
'SELECT verified FROM ' . $this->dbTablePrefix . 'users WHERE id = ?',
|
||
[ $this->getUserId() ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
// ensure that at least the current (old) email address has been verified before proceeding
|
||
if ((int) $verified !== 1) {
|
||
throw new EmailNotVerifiedException();
|
||
}
|
||
|
||
$this->createConfirmationRequest($this->getUserId(), $newEmail, $callback);
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
*/
|
||
public function forgotPassword($email, callable $callback, $requestExpiresAfter = null, $maxOpenRequests = null) {
|
||
$email = self::validateEmailAddress($email);
|
||
|
||
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 = (int) $this->getOpenPasswordResetRequests($userData['id']);
|
||
|
||
if ($openRequests < $maxOpenRequests) {
|
||
$this->createPasswordResetRequest($userData['id'], $requestExpiresAfter, $callback);
|
||
}
|
||
else {
|
||
self::onTooManyRequests($requestExpiresAfter);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Authenticates an existing user
|
||
*
|
||
* @param string $password the user's password
|
||
* @param string|null $email (optional) the user's email address
|
||
* @param string|null $username (optional) the user's username
|
||
* @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
|
||
* @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel
|
||
* @throws InvalidEmailException if the email address was invalid or could not be found
|
||
* @throws UnknownUsernameException if an attempt has been made to authenticate with a non-existing username
|
||
* @throws AmbiguousUsernameException if an attempt has been made to authenticate with an ambiguous username
|
||
* @throws InvalidPasswordException if the password was invalid
|
||
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
|
||
* @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function authenticateUserInternal($password, $email = null, $username = null, $rememberDuration = null, callable $onBeforeSuccess = null) {
|
||
$columnsToFetch = [ 'id', 'email', 'password', 'verified', 'username', 'status', 'roles_mask' ];
|
||
|
||
if ($email !== null) {
|
||
$email = self::validateEmailAddress($email);
|
||
|
||
// attempt to look up the account information using the specified email address
|
||
try {
|
||
$userData = $this->getUserDataByEmailAddress(
|
||
$email,
|
||
$columnsToFetch
|
||
);
|
||
}
|
||
// if there is no user with the specified email address
|
||
catch (InvalidEmailException $e) {
|
||
// throttle this operation
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN);
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
|
||
|
||
// and re-throw the exception
|
||
throw new InvalidEmailException();
|
||
}
|
||
}
|
||
elseif ($username !== null) {
|
||
$username = trim($username);
|
||
|
||
// attempt to look up the account information using the specified username
|
||
try {
|
||
$userData = $this->getUserDataByUsername(
|
||
$username,
|
||
$columnsToFetch
|
||
);
|
||
}
|
||
// if there is no user with the specified username
|
||
catch (UnknownUsernameException $e) {
|
||
// throttle this operation
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN);
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
|
||
|
||
// and re-throw the exception
|
||
throw new UnknownUsernameException();
|
||
}
|
||
// if there are multiple users with the specified username
|
||
catch (AmbiguousUsernameException $e) {
|
||
// throttle this operation
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN);
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
|
||
|
||
// and re-throw the exception
|
||
throw new AmbiguousUsernameException();
|
||
}
|
||
}
|
||
// 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->updatePassword($userData['id'], $password);
|
||
}
|
||
|
||
if ((int) $userData['verified'] === 1) {
|
||
if (!isset($onBeforeSuccess) || (\is_callable($onBeforeSuccess) && $onBeforeSuccess($userData['id']) === true)) {
|
||
$this->onLoginSuccessful($userData['id'], $userData['email'], $userData['username'], $userData['status'], $userData['roles_mask'], false);
|
||
|
||
// continue to support the old parameter format
|
||
if ($rememberDuration === true) {
|
||
$rememberDuration = 60 * 60 * 24 * 28;
|
||
}
|
||
elseif ($rememberDuration === false) {
|
||
$rememberDuration = null;
|
||
}
|
||
|
||
if ($rememberDuration !== null) {
|
||
$this->createRememberDirective($userData['id'], $rememberDuration);
|
||
}
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new AttemptCancelledException();
|
||
}
|
||
}
|
||
else {
|
||
throw new EmailNotVerifiedException();
|
||
}
|
||
}
|
||
else {
|
||
// throttle this operation
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN);
|
||
if (isset($email)) {
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
|
||
}
|
||
elseif (isset($username)) {
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
|
||
}
|
||
|
||
// we cannot authenticate the user due to the password being wrong
|
||
throw new InvalidPasswordException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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->dbTablePrefix . 'users WHERE email = ?',
|
||
[ $email ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
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->dbTablePrefix . 'users_resets WHERE user = ? AND expires > ?',
|
||
[
|
||
$userId,
|
||
time()
|
||
]
|
||
);
|
||
|
||
if (!empty($requests)) {
|
||
return $requests;
|
||
}
|
||
else {
|
||
return 0;
|
||
}
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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->dbTablePrefix . 'users_resets',
|
||
[
|
||
'user' => $userId,
|
||
'selector' => $selector,
|
||
'token' => $tokenHashed,
|
||
'expires' => $expiresAt
|
||
]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (isset($callback) && 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 `Auth#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
|
||
* @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)
|
||
*/
|
||
public function resetPassword($selector, $token, $newPassword) {
|
||
$this->throttle(self::THROTTLE_ACTION_CONSUME_TOKEN);
|
||
$this->throttle(self::THROTTLE_ACTION_CONSUME_TOKEN, $selector);
|
||
|
||
try {
|
||
$resetData = $this->db->selectRow(
|
||
'SELECT a.id, a.user, a.token, a.expires, b.resettable FROM ' . $this->dbTablePrefix . 'users_resets AS a JOIN ' . $this->dbTablePrefix . 'users AS b ON b.id = a.user WHERE a.selector = ?',
|
||
[ $selector ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($resetData)) {
|
||
if ((int) $resetData['resettable'] === 1) {
|
||
if (password_verify($token, $resetData['token'])) {
|
||
if ($resetData['expires'] >= time()) {
|
||
$newPassword = self::validatePassword($newPassword);
|
||
|
||
// update the password in the database
|
||
$this->updatePassword($resetData['user'], $newPassword);
|
||
|
||
// delete any remaining remember directives
|
||
$this->deleteRememberDirective($resetData['user']);
|
||
|
||
try {
|
||
$this->db->delete(
|
||
$this->dbTablePrefix . 'users_resets',
|
||
[ 'id' => $resetData['id'] ]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
else {
|
||
throw new TokenExpiredException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidSelectorTokenPairException();
|
||
}
|
||
}
|
||
else {
|
||
throw new ResetDisabledException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidSelectorTokenPairException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 `Auth#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)
|
||
*/
|
||
public function canResetPassword($selector, $token) {
|
||
try {
|
||
// pass an invalid password intentionally to force an expected error
|
||
$this->resetPassword($selector, $token, null);
|
||
|
||
// we should already be in the `catch` block now so this is not expected
|
||
throw new AuthError();
|
||
}
|
||
// if the password is the only thing that's invalid
|
||
catch (InvalidPasswordException $e) {
|
||
// the password can be reset
|
||
return true;
|
||
}
|
||
// if some other things failed (as well)
|
||
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->dbTablePrefix . 'users',
|
||
[
|
||
'resettable' => $enabled ? 1 : 0
|
||
],
|
||
[
|
||
'id' => $this->getUserId()
|
||
]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
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->dbTablePrefix . 'users WHERE id = ?',
|
||
[ $this->getUserId() ]
|
||
);
|
||
|
||
return (int) $enabled === 1;
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
else {
|
||
throw new NotLoggedInException();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets whether the user is currently logged in and updates the session
|
||
*
|
||
* @param bool $loggedIn whether the user is logged in or not
|
||
*/
|
||
private function setLoggedIn($loggedIn) {
|
||
$_SESSION[self::SESSION_FIELD_LOGGED_IN] = $loggedIn;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
|
||
/**
|
||
* Sets the currently signed-in user's ID and updates the session
|
||
*
|
||
* @param int $userId the user's ID
|
||
*/
|
||
private function setUserId($userId) {
|
||
$_SESSION[self::SESSION_FIELD_USER_ID] = intval($userId);
|
||
}
|
||
|
||
/**
|
||
* 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 `getUserId()`
|
||
*
|
||
* @return int
|
||
*/
|
||
public function id() {
|
||
return $this->getUserId();
|
||
}
|
||
|
||
/**
|
||
* Sets the currently signed-in user's email address and updates the session
|
||
*
|
||
* @param string $email the email address
|
||
*/
|
||
private function setEmail($email) {
|
||
$_SESSION[self::SESSION_FIELD_EMAIL] = $email;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets the currently signed-in user's display name and updates the session
|
||
*
|
||
* @param string $username the display name
|
||
*/
|
||
private function setUsername($username) {
|
||
$_SESSION[self::SESSION_FIELD_USERNAME] = $username;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets the currently signed-in user's status and updates the session
|
||
*
|
||
* @param int $status the status as one of the constants from the {@see Status} class
|
||
*/
|
||
private function setStatus($status) {
|
||
$_SESSION[self::SESSION_FIELD_STATUS] = (int) $status;
|
||
}
|
||
|
||
/**
|
||
* Sets the currently signed-in user's roles and updates the session
|
||
*
|
||
* @param int $roles the bitmask containing the roles
|
||
*/
|
||
private function setRoles($roles) {
|
||
$_SESSION[self::SESSION_FIELD_ROLES] = (int) $roles;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
$role = (int) $role;
|
||
|
||
if (isset($_SESSION) && isset($_SESSION[self::SESSION_FIELD_ROLES])) {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Sets whether the currently signed-in user has been remembered by a long-lived cookie
|
||
*
|
||
* @param bool $remembered whether the user was remembered
|
||
*/
|
||
private function setRemembered($remembered) {
|
||
$_SESSION[self::SESSION_FIELD_REMEMBERED] = $remembered;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hashes the supplied data
|
||
*
|
||
* @param mixed $data the data to hash
|
||
* @return string the hash in Base64-encoded format
|
||
*/
|
||
private static function hash($data) {
|
||
$hashRaw = hash(self::IP_ADDRESS_HASH_ALGORITHM, $data, true);
|
||
|
||
return Base64::encode($hashRaw);
|
||
}
|
||
|
||
/**
|
||
* Returns the user's current IP address
|
||
*
|
||
* @return string the IP address (IPv4 or IPv6)
|
||
*/
|
||
public function getIpAddress() {
|
||
return $this->ipAddress;
|
||
}
|
||
|
||
/**
|
||
* Returns the current time bucket that is used for throttling purposes
|
||
*
|
||
* @return int the time bucket
|
||
*/
|
||
private function getTimeBucket() {
|
||
return (int) (time() / $this->throttlingTimeBucketSize);
|
||
}
|
||
|
||
protected function throttle($actionType, $customSelector = null) {
|
||
// if a custom selector has been provided (e.g. username, user ID or confirmation token)
|
||
if (isset($customSelector)) {
|
||
// use the provided selector for throttling
|
||
$selector = self::hash($customSelector);
|
||
}
|
||
// if no custom selector was provided
|
||
else {
|
||
// throttle by the user's IP address
|
||
$selector = self::hash($this->getIpAddress());
|
||
}
|
||
|
||
// get the time bucket that we do the throttling for
|
||
$timeBucket = self::getTimeBucket();
|
||
|
||
try {
|
||
$this->db->insert(
|
||
$this->dbTablePrefix . 'users_throttling',
|
||
[
|
||
'action_type' => $actionType,
|
||
'selector' => $selector,
|
||
'time_bucket' => $timeBucket,
|
||
'attempts' => 1
|
||
]
|
||
);
|
||
}
|
||
catch (IntegrityConstraintViolationException $e) {
|
||
// if we have a duplicate entry, update the old entry
|
||
try {
|
||
$this->db->exec(
|
||
'UPDATE ' . $this->dbTablePrefix . 'users_throttling SET attempts = attempts+1 WHERE action_type = ? AND selector = ? AND time_bucket = ?',
|
||
[
|
||
$actionType,
|
||
$selector,
|
||
$timeBucket
|
||
]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
try {
|
||
$attempts = $this->db->selectValue(
|
||
'SELECT attempts FROM ' . $this->dbTablePrefix . 'users_throttling WHERE action_type = ? AND selector = ? AND time_bucket = ?',
|
||
[
|
||
$actionType,
|
||
$selector,
|
||
$timeBucket
|
||
]
|
||
);
|
||
}
|
||
catch (Error $e) {
|
||
throw new DatabaseError();
|
||
}
|
||
|
||
if (!empty($attempts)) {
|
||
// if the number of attempts has acceeded our accepted limit
|
||
if ($attempts > $this->throttlingActionsPerTimeBucket) {
|
||
self::onTooManyRequests($this->throttlingTimeBucketSize);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called when there have been too many requests for some action or object
|
||
*
|
||
* @param int|null $retryAfterInterval (optional) the interval in seconds after which the client should retry
|
||
* @throws TooManyRequestsException to inform any calling method about this problem
|
||
*/
|
||
private static function onTooManyRequests($retryAfterInterval = null) {
|
||
// if no interval has been provided after which the client should retry
|
||
if ($retryAfterInterval === null) {
|
||
// use one day as the default
|
||
$retryAfterInterval = 60 * 60 * 24;
|
||
}
|
||
|
||
// send an appropriate HTTP status code
|
||
http_response_code(self::HTTP_STATUS_CODE_TOO_MANY_REQUESTS);
|
||
// tell the client when they should try again
|
||
@header('Retry-After: '.$retryAfterInterval);
|
||
// throw an exception
|
||
throw new TooManyRequestsException();
|
||
}
|
||
|
||
/**
|
||
* Customizes the throttling options
|
||
*
|
||
* @param int $actionsPerTimeBucket the number of allowed attempts/requests per time bucket
|
||
* @param int $timeBucketSize the size of the time buckets in seconds
|
||
*/
|
||
public function setThrottlingOptions($actionsPerTimeBucket, $timeBucketSize) {
|
||
$this->throttlingActionsPerTimeBucket = intval($actionsPerTimeBucket);
|
||
|
||
if (isset($timeBucketSize)) {
|
||
$this->throttlingTimeBucketSize = intval($timeBucketSize);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
/**
|
||
* Creates the cookie settings that will be used to create and update cookies on the client
|
||
*
|
||
* @return array the cookie settings
|
||
*/
|
||
private function createCookieSettings() {
|
||
// get the default cookie settings
|
||
$params = session_get_cookie_params();
|
||
|
||
// check if we want to send cookies via SSL/TLS only
|
||
$params['secure'] = $params['secure'] || $this->useHttps;
|
||
// check if we want to send cookies via HTTP(S) only
|
||
$params['httponly'] = $params['httponly'] || !$this->allowCookiesScriptAccess;
|
||
|
||
// return the modified settings
|
||
return $params;
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
}
|
||
|
||
}
|