1
0
mirror of https://github.com/delight-im/PHP-Auth.git synced 2025-08-22 23:52:58 +02:00

Initial commit

This commit is contained in:
Marco
2015-10-20 14:26:38 +02:00
commit 4b1df6e291
9 changed files with 1737 additions and 0 deletions

914
src/Auth.php Normal file
View File

@@ -0,0 +1,914 @@
<?php
/**
* Copyright 2015 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';
/** Secure authentication for PHP, once and for all, really simple to use */
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->bindParam(':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;
$registered = time();
$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->bindParam(':email', $email, \PDO::PARAM_STR);
$stmt->bindParam(':password', $password, \PDO::PARAM_STR);
$stmt->bindParam(':username', $username, \PDO::PARAM_STR);
$stmt->bindParam(':verified', $verified, \PDO::PARAM_INT);
$stmt->bindParam(':registered', $registered, \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->bindParam(':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->bindParam(':email', $email, \PDO::PARAM_STR);
$stmt->bindParam(':selector', $selector, \PDO::PARAM_STR);
$stmt->bindParam(':token', $tokenHashed, \PDO::PARAM_STR);
$stmt->bindParam(':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->bindParam(':email', $email, \PDO::PARAM_STR);
if ($stmt->execute()) {
$userData = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($userData !== false) {
if (password_verify($password, $userData['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->bindParam(':user', $userId, \PDO::PARAM_INT);
$stmt->bindParam(':selector', $selector, \PDO::PARAM_STR);
$stmt->bindParam(':token', $tokenHashed, \PDO::PARAM_STR);
$stmt->bindParam(':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->bindParam(':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) {
$lastLogin = time();
$stmt = $this->db->prepare("UPDATE users SET last_login = :lastLogin WHERE id = :id");
$stmt->bindParam(':lastLogin', $lastLogin, \PDO::PARAM_INT);
$stmt->bindParam(':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->bindParam(':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()) {
$verified = 1;
$stmt = $this->db->prepare("UPDATE users SET verified = :verified WHERE email = :email");
$stmt->bindParam(':verified', $verified, \PDO::PARAM_INT);
$stmt->bindParam(':email', $confirmationData['email'], \PDO::PARAM_STR);
if ($stmt->execute()) {
$stmt = $this->db->prepare("DELETE FROM users_confirmations WHERE id = :id");
$stmt->bindParam(':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->bindParam(':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->bindParam(':password', $newPassword, \PDO::PARAM_STR);
$stmt->bindParam(':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)
*/
public 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->bindParam(':actionType', $actionType, \PDO::PARAM_STR);
$stmt->bindParam(':selector', $selector, \PDO::PARAM_STR);
$stmt->bindParam(':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->bindParam(':actionType', $actionType, \PDO::PARAM_STR);
$stmt->bindParam(':selector', $selector, \PDO::PARAM_STR);
$stmt->bindParam(':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->bindParam(':actionType', $actionType, \PDO::PARAM_STR);
$stmt->bindParam(':selector', $selector, \PDO::PARAM_STR);
$stmt->bindParam(':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));
}
}

44
src/Base64.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
/**
* Copyright 2015 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;
class Base64 {
const SPECIAL_CHARS_ORIGINAL = '+/=';
const SPECIAL_CHARS_SAFE = '._-';
public static function encode($data, $safeChars = false) {
$result = base64_encode($data);
if ($safeChars) {
$result = strtr($result, self::SPECIAL_CHARS_ORIGINAL, self::SPECIAL_CHARS_SAFE);
}
return $result;
}
public static function decode($data) {
$data = strtr($data, self::SPECIAL_CHARS_SAFE, self::SPECIAL_CHARS_ORIGINAL);
$result = base64_decode($data, true);
return $result;
}
}

45
src/Exceptions.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* Copyright 2015 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;
class AuthException extends \Exception {}
class InvalidEmailException extends AuthException {}
class InvalidPasswordException extends AuthException {}
class EmailNotVerifiedException extends AuthException {}
class UserAlreadyExistsException extends AuthException {}
class NotLoggedInException extends AuthException {}
class InvalidSelectorTokenPairException extends AuthException {}
class TokenExpiredException extends AuthException {}
class TooManyRequestsException extends AuthException {}
class AuthError extends \Exception {}
class DatabaseError extends AuthError {}
class MissingCallbackError extends AuthError {}
class HeadersAlreadySentError extends AuthError {}