mirror of
https://github.com/delight-im/PHP-Auth.git
synced 2025-10-22 03:16:04 +02:00
916 lines
30 KiB
PHP
916 lines
30 KiB
PHP
<?php
|
||
|
||
/*
|
||
* Copyright (c) delight.im <info@delight.im>
|
||
*
|
||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||
* you may not use this file except in compliance with the License.
|
||
* You may obtain a copy of the License at
|
||
*
|
||
* http://www.apache.org/licenses/LICENSE-2.0
|
||
*
|
||
* Unless required by applicable law or agreed to in writing, software
|
||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
* See the License for the specific language governing permissions and
|
||
* limitations under the License.
|
||
*/
|
||
|
||
namespace Delight\Auth;
|
||
|
||
require __DIR__.'/Base64.php';
|
||
require __DIR__.'/Exceptions.php';
|
||
|
||
/** Base class that provides all methods, properties and utilities for secure authentication */
|
||
class Auth {
|
||
|
||
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_REMEMBERED = 'auth_remembered';
|
||
const COOKIE_CONTENT_SEPARATOR = '~';
|
||
const COOKIE_NAME_REMEMBER = 'auth_remember';
|
||
const IP_ADDRESS_HASH_ALGORITHM = 'sha256';
|
||
const THROTTLE_ACTION_LOGIN = 'login';
|
||
const THROTTLE_ACTION_REGISTER = 'register';
|
||
const THROTTLE_ACTION_CONFIRM_EMAIL = 'confirm_email';
|
||
const THROTTLE_HTTP_RESPONSE_CODE = 429;
|
||
|
||
/** @var \PDO the database connection that will be used */
|
||
private $db;
|
||
/** @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 \PDO $databaseConnection the database connection that will be used
|
||
* @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
|
||
*/
|
||
public function __construct(\PDO $databaseConnection, $useHttps = false, $allowCookiesScriptAccess = false, $ipAddress = null) {
|
||
$this->db = $databaseConnection;
|
||
$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, post-check=0, pre-check=0', 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])) {
|
||
$stmt = $this->db->prepare("SELECT a.user, a.token, a.expires, b.email, b.username FROM users_remembered AS a JOIN users AS b ON a.user = b.id WHERE a.selector = :selector");
|
||
$stmt->bindValue(':selector', $parts[0], \PDO::PARAM_STR);
|
||
if ($stmt->execute()) {
|
||
$rememberData = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||
if ($rememberData !== false) {
|
||
if ($rememberData['expires'] >= time()) {
|
||
if (password_verify($parts[1], $rememberData['token'])) {
|
||
$this->onLoginSuccessful($rememberData['user'], $rememberData['email'], $rememberData['username'], true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attempts to sign up a user
|
||
*
|
||
* If you want accounts to be activated by default, pass `null` as the fourth argument
|
||
*
|
||
* If you want to perform email verification, pass `function ($selector, $token) {}` as the fourth argument
|
||
*
|
||
* @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 $emailConfirmationCallback (optional) the function that sends the confirmation email
|
||
* @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)
|
||
*/
|
||
public function register($email, $password, $username = null, callable $emailConfirmationCallback = null) {
|
||
$this->throttle(self::THROTTLE_ACTION_REGISTER);
|
||
|
||
$email = isset($email) ? trim($email) : null;
|
||
if (empty($email)) {
|
||
throw new InvalidEmailException();
|
||
}
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
throw new InvalidEmailException();
|
||
}
|
||
|
||
$password = isset($password) ? trim($password) : null;
|
||
if (empty($password)) {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
|
||
$username = isset($username) ? trim($username) : null;
|
||
$password = password_hash($password, PASSWORD_DEFAULT);
|
||
$verified = isset($emailConfirmationCallback) && is_callable($emailConfirmationCallback) ? 0 : 1;
|
||
|
||
$stmt = $this->db->prepare("INSERT INTO users (email, password, username, verified, registered) VALUES (:email, :password, :username, :verified, :registered)");
|
||
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':password', $password, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':username', $username, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':verified', $verified, \PDO::PARAM_INT);
|
||
$stmt->bindValue(':registered', time(), \PDO::PARAM_INT);
|
||
|
||
try {
|
||
$result = $stmt->execute();
|
||
}
|
||
catch (\PDOException $e) {
|
||
// if we have a duplicate entry
|
||
if ($e->getCode() == '23000') {
|
||
throw new UserAlreadyExistsException();
|
||
}
|
||
// if we have another error
|
||
else {
|
||
// throw an exception
|
||
throw new DatabaseError(null, null, $e);
|
||
}
|
||
}
|
||
|
||
// if creating the new user was successful
|
||
if ($result) {
|
||
// get the ID of the user that we've just created
|
||
$stmt = $this->db->prepare("SELECT id FROM users WHERE email = :email");
|
||
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
|
||
|
||
if ($result = $stmt->execute()) {
|
||
$newUserId = $stmt->fetchColumn();
|
||
}
|
||
else {
|
||
$newUserId = null;
|
||
}
|
||
|
||
if ($verified === 1) {
|
||
return $newUserId;
|
||
}
|
||
else {
|
||
$this->createConfirmationRequest($email, $emailConfirmationCallback);
|
||
|
||
return $newUserId;
|
||
}
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Creates a request for email confirmation
|
||
*
|
||
* @param string $email the email address to verify
|
||
* @param callable $emailConfirmationCallback the function that sends the confirmation email
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function createConfirmationRequest($email, callable $emailConfirmationCallback) {
|
||
$selector = self::createRandomString(16);
|
||
$token = self::createRandomString(16);
|
||
$tokenHashed = password_hash($token, PASSWORD_DEFAULT);
|
||
$expires = time() + 3600 * 24;
|
||
|
||
$stmt = $this->db->prepare("INSERT INTO users_confirmations (email, selector, token, expires) VALUES (:email, :selector, :token, :expires)");
|
||
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':token', $tokenHashed, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':expires', $expires, \PDO::PARAM_INT);
|
||
|
||
if ($stmt->execute()) {
|
||
if (isset($emailConfirmationCallback) && is_callable($emailConfirmationCallback)) {
|
||
$emailConfirmationCallback($selector, $token);
|
||
}
|
||
else {
|
||
throw new MissingCallbackError();
|
||
}
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attempts to sign in a user
|
||
*
|
||
* @param string $email the user's email address
|
||
* @param string $password the user's password
|
||
* @param bool $remember whether to keep the user logged in ("remember me") or not
|
||
* @throws InvalidEmailException if the email address was invalid or could not be found
|
||
* @throws InvalidPasswordException if the password was invalid or didn't match the email address
|
||
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function login($email, $password, $remember = false) {
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN);
|
||
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
|
||
|
||
$email = isset($email) ? trim($email) : null;
|
||
if (empty($email)) {
|
||
throw new InvalidEmailException();
|
||
}
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
throw new InvalidEmailException();
|
||
}
|
||
|
||
$password = isset($password) ? trim($password) : null;
|
||
if (empty($password)) {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
|
||
$stmt = $this->db->prepare("SELECT id, password, verified, username FROM users WHERE email = :email");
|
||
$stmt->bindValue(':email', $email, \PDO::PARAM_STR);
|
||
if ($stmt->execute()) {
|
||
$userData = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||
if ($userData !== false) {
|
||
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 ($userData['verified'] == 1) {
|
||
$this->onLoginSuccessful($userData['id'], $email, $userData['username'], false);
|
||
|
||
if ($remember) {
|
||
$this->createRememberDirective($userData['id']);
|
||
}
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new EmailNotVerifiedException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidEmailException();
|
||
}
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Creates a new directive keeping the user logged in ("remember me")
|
||
*
|
||
* @param int $userId the user ID to keep signed in
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private function createRememberDirective($userId) {
|
||
$selector = self::createRandomString(24);
|
||
$token = self::createRandomString(24);
|
||
$tokenHashed = password_hash($token, PASSWORD_DEFAULT);
|
||
$expires = time() + 3600 * 24 * 28;
|
||
|
||
$stmt = $this->db->prepare("INSERT INTO users_remembered (user, selector, token, expires) VALUES (:user, :selector, :token, :expires)");
|
||
$stmt->bindValue(':user', $userId, \PDO::PARAM_INT);
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':token', $tokenHashed, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':expires', $expires, \PDO::PARAM_INT);
|
||
|
||
if ($stmt->execute()) {
|
||
$this->setRememberCookie($selector, $token, $expires);
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
$stmt = $this->db->prepare("DELETE FROM users_remembered WHERE user = :user");
|
||
$stmt->bindValue(':user', $userId, \PDO::PARAM_INT);
|
||
|
||
if ($stmt->execute()) {
|
||
$this->setRememberCookie(null, null, time() - 3600);
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 timestamp (in seconds) when the token expires
|
||
* @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
|
||
$result = setcookie(self::COOKIE_NAME_REMEMBER, $content, $expires, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||
|
||
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 bool $remembered whether the user was remembered ("remember me") or logged in actively
|
||
*/
|
||
private function onLoginSuccessful($userId, $email, $username, $remembered) {
|
||
$stmt = $this->db->prepare("UPDATE users SET last_login = :lastLogin WHERE id = :id");
|
||
$stmt->bindValue(':lastLogin', time(), \PDO::PARAM_INT);
|
||
$stmt->bindValue(':id', $userId, \PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
|
||
// re-generate the session ID to prevent session fixation attacks
|
||
session_regenerate_id(true);
|
||
|
||
// save the user data in the session
|
||
$this->setLoggedIn(true);
|
||
$this->setUserId($userId);
|
||
$this->setEmail($email);
|
||
$this->setUsername($username);
|
||
$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();
|
||
|
||
// set the cookie with the selector and token
|
||
$result = setcookie(session_name(), '', time() - 3600, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||
|
||
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
|
||
* @throws InvalidSelectorTokenPairException if either the selector or the token was not correct
|
||
* @throws TokenExpiredException if the token has already expired
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
public function confirmEmail($selector, $token) {
|
||
$this->throttle(self::THROTTLE_ACTION_CONFIRM_EMAIL);
|
||
$this->throttle(self::THROTTLE_ACTION_CONFIRM_EMAIL, $selector);
|
||
|
||
$stmt = $this->db->prepare("SELECT id, email, token, expires FROM users_confirmations WHERE selector = :selector");
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
if ($stmt->execute()) {
|
||
$confirmationData = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||
if ($confirmationData !== false) {
|
||
if (password_verify($token, $confirmationData['token'])) {
|
||
if ($confirmationData['expires'] >= time()) {
|
||
$stmt = $this->db->prepare("UPDATE users SET verified = :verified WHERE email = :email");
|
||
$stmt->bindValue(':verified', 1, \PDO::PARAM_INT);
|
||
$stmt->bindValue(':email', $confirmationData['email'], \PDO::PARAM_STR);
|
||
if ($stmt->execute()) {
|
||
$stmt = $this->db->prepare("DELETE FROM users_confirmations WHERE id = :id");
|
||
$stmt->bindValue(':id', $confirmationData['id'], \PDO::PARAM_INT);
|
||
if ($stmt->execute()) {
|
||
return;
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
else {
|
||
throw new TokenExpiredException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidSelectorTokenPairException();
|
||
}
|
||
}
|
||
else {
|
||
throw new InvalidSelectorTokenPairException();
|
||
}
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = isset($oldPassword) ? trim($oldPassword) : null;
|
||
if (empty($oldPassword)) {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
|
||
$newPassword = isset($newPassword) ? trim($newPassword) : null;
|
||
if (empty($newPassword)) {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
|
||
$userId = $this->getUserId();
|
||
|
||
$stmt = $this->db->prepare("SELECT password FROM users WHERE id = :userId");
|
||
$stmt->bindValue(':userId', $userId, \PDO::PARAM_INT);
|
||
if ($stmt->execute()) {
|
||
$passwordInDatabase = $stmt->fetchColumn();
|
||
if (password_verify($oldPassword, $passwordInDatabase)) {
|
||
$this->updatePassword($userId, $newPassword);
|
||
|
||
return;
|
||
}
|
||
else {
|
||
throw new InvalidPasswordException();
|
||
}
|
||
}
|
||
else {
|
||
throw new DatabaseError();
|
||
}
|
||
}
|
||
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
|
||
*/
|
||
private function updatePassword($userId, $newPassword) {
|
||
$newPassword = password_hash($newPassword, PASSWORD_DEFAULT);
|
||
|
||
$stmt = $this->db->prepare("UPDATE users SET password = :password WHERE id = :userId");
|
||
$stmt->bindValue(':password', $newPassword, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':userId', $userId, \PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
}
|
||
|
||
/**
|
||
* 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 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);
|
||
}
|
||
|
||
/**
|
||
* Throttles the specified action for the user to protect against too many requests
|
||
*
|
||
* @param string $actionType one of the `THROTTLE_ACTION_*` constants
|
||
* @param mixed|null $customSelector a custom selector to use for throttling (if any), otherwise the IP address will be used
|
||
* @throws TooManyRequestsException if the number of allowed attempts/requests has been exceeded
|
||
* @throws AuthError if an internal problem occurred (do *not* catch)
|
||
*/
|
||
private 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();
|
||
|
||
$stmt = $this->db->prepare('INSERT INTO users_throttling (action_type, selector, time_bucket, attempts) VALUES (:actionType, :selector, :timeBucket, 1)');
|
||
$stmt->bindValue(':actionType', $actionType, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':timeBucket', $timeBucket, \PDO::PARAM_INT);
|
||
try {
|
||
$stmt->execute();
|
||
}
|
||
catch (\PDOException $e) {
|
||
// if we have a duplicate entry
|
||
if ($e->getCode() == '23000') {
|
||
// update the old entry
|
||
$stmt = $this->db->prepare('UPDATE users_throttling SET attempts = attempts+1 WHERE action_type = :actionType AND selector = :selector AND time_bucket = :timeBucket');
|
||
$stmt->bindValue(':actionType', $actionType, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':timeBucket', $timeBucket, \PDO::PARAM_INT);
|
||
$stmt->execute();
|
||
}
|
||
// if we have another error
|
||
else {
|
||
// throw an exception
|
||
throw new DatabaseError(null, null, $e);
|
||
}
|
||
}
|
||
|
||
$stmt = $this->db->prepare('SELECT attempts FROM users_throttling WHERE action_type = :actionType AND selector = :selector AND time_bucket = :timeBucket');
|
||
$stmt->bindValue(':actionType', $actionType, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':selector', $selector, \PDO::PARAM_STR);
|
||
$stmt->bindValue(':timeBucket', $timeBucket, \PDO::PARAM_INT);
|
||
if ($stmt->execute()) {
|
||
$attempts = $stmt->fetchColumn();
|
||
|
||
if ($attempts !== false) {
|
||
// if the number of attempts has acceeded our accepted limit
|
||
if ($attempts > $this->throttlingActionsPerTimeBucket) {
|
||
// send a HTTP status code that indicates active throttling
|
||
http_response_code(self::THROTTLE_HTTP_RESPONSE_CODE);
|
||
// tell the client when they should try again
|
||
@header('Retry-After: '.$this->throttlingTimeBucketSize);
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
|
||
// optimize the cookie domain
|
||
$params['domain'] = self::optimizeCookieDomain($params['domain']);
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* Optimizes the specified cookie domain
|
||
*
|
||
* @param string $domain the supplied cookie domain
|
||
* @return string the optimized cookie domain
|
||
*/
|
||
private static function optimizeCookieDomain($domain) {
|
||
// if no domain has been explicitly provided
|
||
if (empty($domain)) {
|
||
// use the current hostname as a default
|
||
$domain = $_SERVER['SERVER_NAME'];
|
||
}
|
||
|
||
// if the domain name starts with the `www` subdomain
|
||
if (substr($domain, 0, 4) === 'www.') {
|
||
// strip the subdomain
|
||
$domain = substr($domain, 4);
|
||
}
|
||
|
||
// count the dots in the domain name
|
||
$numDots = substr_count($domain, '.');
|
||
|
||
// if there is no dot at all (usually `localhost`) or only a single dot (no subdomain)
|
||
if ($numDots < 2) {
|
||
// if the domain doesn't already start with a dot
|
||
if (substr($domain, 0, 1) !== '.') {
|
||
// prepend a dot to allow all subdomains
|
||
$domain = '.'.$domain;
|
||
}
|
||
}
|
||
|
||
// return the optimized domain name
|
||
return $domain;
|
||
}
|
||
|
||
/**
|
||
* Creates a random string with the given maximum length
|
||
*
|
||
* With the default parameter, the output should contain at least as much randomness as a UUID
|
||
*
|
||
* @param int $maxLength the maximum length of the output string (integer multiple of 4)
|
||
* @return string the new random string
|
||
*/
|
||
public static function createRandomString($maxLength = 24) {
|
||
// calculate how many bytes of randomness we need for the specified string length
|
||
$bytes = floor(intval($maxLength) / 4) * 3;
|
||
// get random data
|
||
$data = openssl_random_pseudo_bytes($bytes);
|
||
|
||
// return the Base64-encoded result
|
||
return Base64::encode($data, true);
|
||
}
|
||
|
||
/**
|
||
* 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));
|
||
}
|
||
|
||
}
|