commit 4b1df6e2910dea65fb8bc6d22fa953ced667e03e Author: Marco Date: Tue Oct 20 14:26:38 2015 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/Database/MySQL.sql b/Database/MySQL.sql new file mode 100644 index 0000000..70580c7 --- /dev/null +++ b/Database/MySQL.sql @@ -0,0 +1,67 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; + + +CREATE TABLE IF NOT EXISTS `users` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `email` varchar(254) NOT NULL, + `password` varchar(255) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `username` varchar(100) DEFAULT NULL, + `verified` tinyint(1) unsigned NOT NULL DEFAULT '0', + `registered` int(10) unsigned NOT NULL, + `last_login` int(10) unsigned DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; + +CREATE TABLE IF NOT EXISTS `users_confirmations` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `email` varchar(254) NOT NULL, + `selector` varchar(16) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `token` varchar(255) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `expires` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `selector` (`selector`), + KEY `email_expires` (`email`,`expires`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; + +CREATE TABLE IF NOT EXISTS `users_remembered` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user` int(10) unsigned NOT NULL, + `selector` varchar(24) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `token` varchar(255) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `expires` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `selector` (`selector`), + KEY `user` (`user`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; + +CREATE TABLE IF NOT EXISTS `users_resets` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user` int(10) unsigned NOT NULL, + `selector` varchar(24) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `token` varchar(255) CHARACTER SET latin1 COLLATE latin1_general_cs NOT NULL, + `expires` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `selector` (`selector`), + KEY `user` (`user`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; + +CREATE TABLE IF NOT EXISTS `users_throttling` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `action_type` enum('login','register','confirm_email') NOT NULL, + `selector` varchar(44) CHARACTER SET latin1 COLLATE latin1_general_cs DEFAULT NULL, + `time_bucket` int(10) unsigned NOT NULL, + `attempts` mediumint(8) unsigned NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + UNIQUE KEY `action_type_selector_time_bucket` (`action_type`,`selector`,`time_bucket`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..03ccc7b --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Auth + +Secure authentication for PHP, once and for all, really simple to use. + +Completely framework-agnostic and database-agnostic. + +## Why do I need this? + + * There are [tons](http://www.troyhunt.com/2011/01/whos-who-of-bad-password-practices.html) [of](http://www.jeremytunnell.com/posts/swab-password-policies-and-two-factor-authentication-a-comedy-of-errors) [websites](http://badpasswordpolicies.tumblr.com/) with weak authentication systems. Don't build such a site. + * Re-implementing a new authentication system for every PHP project is *not* a good idea. + * Building your own authentication classes piece by piece, and copying it to every project, is *not* recommended, either. + * A secure authentication system with an easy-to-use API should be thoroughly designed and planned. + * Peer-review for your critical infrastructure is *a must*. + +## Requirements + + * PHP 5.5.0+ + * PDO + * OpenSSL + +## Installation + + * Set up the PHP library + * Install via [Composer](https://getcomposer.org/) (recommended) + + `$ composer require delight-im/auth` + + * or + * Install manually (*not* recommended) + * Copy the contents of the [`src`](src) directory to your project + * Include the files in your code via `require` or `require_once` + * Set up a database and create the required tables + * [MySQL](Database/MySQL.sql) + +## Usage + +### Create a new instance + +```php +// $db = new PDO('mysql:dbname=database;host=localhost;charset=utf8', 'username', 'password'); +// $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$auth = new Delight\Auth\Auth($db); +``` + +If you have an open `PDO` connection already, just re-use it. + +### Sign up a new user (register) + +```php +try { + $userId = $auth->register($_POST['email'], $_POST['password'], $_POST['username'], function ($selector, $token) { + // send `$selector` and `$token` to the user (e.g. via email) + }); + + // we have signed up a new user with the ID `$userId` +} +catch (Delight\Auth\InvalidEmailException $e) { + // invalid email address +} +catch (Delight\Auth\InvalidPasswordException $e) { + // invalid password +} +catch (Delight\Auth\UserAlreadyExistsException $e) { + // user already exists +} +catch (Delight\Auth\TooManyRequestsException $e) { + // too many requests +} +``` + +For email verification, you should build an URL with the selector and token and send it to the user, e.g.: + +```php +$url = 'https://www.example.com/verify_email?selector='.urlencode($selector).'&token='.urlencode($token); +``` + +If you don't want to perform email verification, just omit the last parameter to `register(...)`. The new user will be active immediately, then. + +### Sign in an existing user (login) + +```php +try { + $auth->login($_POST['email'], $_POST['password'], ($_POST['remember'] == 1)); + + // user is logged in +} +catch (Delight\Auth\InvalidEmailException $e) { + // wrong email address +} +catch (Delight\Auth\InvalidPasswordException $e) { + // wrong password +} +catch (Delight\Auth\EmailNotVerifiedException $e) { + // email not verified +} +catch (Delight\Auth\TooManyRequestsException $e) { + // too many requests +} +``` + +### Perform email verification + +Extract the selector and token from the URL that the user clicked on in the verification email. + +```php +try { + $auth->confirmEmail($_GET['selector'], $_GET['token']); + + // email address has been verified +} +catch (Delight\Auth\InvalidSelectorTokenPairException $e) { + // invalid token +} +catch (Delight\Auth\TokenExpiredException $e) { + // token expired +} +catch (Delight\Auth\TooManyRequestsException $e) { + // too many requests +} +``` + +### Change the current user's password + +If a user is currently logged in, they may change their password. + +```php +try { + $auth->changePassword($_POST['oldPassword'], $_POST['newPassword']); + + // password has been changed +} +catch (Delight\Auth\NotLoggedInException $e) { + // not logged in +} +catch (Delight\Auth\InvalidPasswordException $e) { + // invalid password(s) +} +``` + +### Logout + +```php +$auth->logout(); + +// user has been signed out +``` + +## Features + + * registration + * secure password storage using the bcrypt algorithm + * email verification through message with confirmation link + * assurance of unique email addresses + * customizable password requirements and enforcement + * optional usernames with customizable restrictions + * login + * keeping the user logged in for a long time via secure long-lived token ("remember me") + * account management + * change password + * tracking the time of sign up and last login + * check if user has been logged in via "remember me" cookie + * logout + * full and reliable destruction of session + * session management + * protection against session fixation attacks + * throttling + * per IP address + * per account + * enhanced HTTP security + * prevents clickjacking + * prevent content sniffing (MIME sniffing) + * disables caching of potentially sensitive data + +## Exceptions + +This library throws two types of exceptions to indicate problems: + + * `AuthException` and its subclasses are thrown whenever a method does not complete successfully. You should *always* catch these exceptions as they carry the normal error responses that you must react to. + * `AuthError` and its subclasses are thrown whenever there is an internal problem or the library has not been installed correctly. You should *not* catch these exceptions. + +## General advice + + * Both serving the authentication pages (e.g. login and registration) and submitting the data entered by the user should only be done over TLS (HTTPS). + * You should enforce a minimum length for passwords, e.g. 10 characters, but *no* maximum length. Moreover, you should not restrict the set of allowed characters. + * Whenever a user was remembered ("remember me") and did not log in by entering their password, you should require re-authentication for critical features. + * Encourage users to use pass*phrases*, i.e. combinations of words or even full sentences, instead of single pass*words*. + * Do not prevent users' password managers from working correctly. Thus please use the standard form fields only and do not prevent copy and paste. + * Before executing sensitive account operations (e.g. changing a user's email address, deleting a user's account), you should always require re-authentication, i.e. require the user to sign in once more. + * You should not offer an online password reset feature ("forgot password") for high-security applications. + * For high-security applications, you should not use email addresses as identifiers. Instead, choose identifiers that are specific to the application and secret, e.g. an internal customer number. + +## Contributing + +All contributions are welcome! If you wish to contribute, please create an issue first so that your feature, problem or question can be discussed. + +## License + +``` +Copyright 2015 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. +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..868eb8b --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "delight-im/auth", + "description": "Secure authentication for PHP, once and for all, really simple to use", + "require": { + "php": ">=5.5.0" + }, + "type": "library", + "keywords": [ "auth", "authentication", "login", "security" ], + "homepage": "https://github.com/delight-im/PHP-Auth", + "license": "Apache-2.0", + "autoload": { + "psr-4": { + "Delight\\Auth\\": "src/" + } + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..579b0ed --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,914 @@ + + * + * 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)); + } + +} diff --git a/src/Base64.php b/src/Base64.php new file mode 100644 index 0000000..c6b54fb --- /dev/null +++ b/src/Base64.php @@ -0,0 +1,44 @@ + + * + * 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; + } + +} diff --git a/src/Exceptions.php b/src/Exceptions.php new file mode 100644 index 0000000..ebd462e --- /dev/null +++ b/src/Exceptions.php @@ -0,0 +1,45 @@ + + * + * 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 {} diff --git a/tests/index.php b/tests/index.php new file mode 100644 index 0000000..54bf2c8 --- /dev/null +++ b/tests/index.php @@ -0,0 +1,235 @@ + + * + * 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. + */ + +header('Content-type: text/html; charset=utf-8'); +error_reporting(E_ALL); +ini_set('display_errors', 'stdout'); + +$db = new PDO('mysql:dbname=php_auth;host=127.0.0.1;charset=utf8', 'root', ''); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +require __DIR__.'/../src/Auth.php'; + +$auth = new Delight\Auth\Auth($db); + +$result = processRequestData($auth); + +showDebugData($auth, $result); + +if ($auth->check()) { + showAuthenticatedUserForm(); +} +else { + showGuestUserForm(); +} + +function processRequestData(Delight\Auth\Auth $auth) { + if (isset($_POST)) { + if (isset($_POST['action'])) { + if ($_POST['action'] === 'login') { + try { + $auth->login($_POST['email'], $_POST['password'], ($_POST['remember'] == 1)); + + return 'ok'; + } + catch (Delight\Auth\InvalidEmailException $e) { + return 'wrong email address'; + } + catch (Delight\Auth\InvalidPasswordException $e) { + return 'wrong password'; + } + catch (Delight\Auth\EmailNotVerifiedException $e) { + return 'email not verified'; + } + catch (Delight\Auth\TooManyRequestsException $e) { + return 'too many requests'; + } + } + else if ($_POST['action'] === 'register') { + try { + if ($_POST['require_verification'] == 1) { + $callback = function ($selector, $token) { + echo '
';
+							echo 'Email confirmation';
+							echo "\n";
+							echo '  >  Selector';
+							echo "\t\t\t\t";
+							echo htmlspecialchars($selector);
+							echo "\n";
+							echo '  >  Token';
+							echo "\t\t\t\t";
+							echo htmlspecialchars($token);
+							echo '
'; + }; + } + else { + $callback = null; + } + + return $auth->register($_POST['email'], $_POST['password'], $_POST['username'], $callback); + } + catch (Delight\Auth\InvalidEmailException $e) { + return 'invalid email address'; + } + catch (Delight\Auth\InvalidPasswordException $e) { + return 'invalid password'; + } + catch (Delight\Auth\UserAlreadyExistsException $e) { + return 'user already exists'; + } + catch (Delight\Auth\TooManyRequestsException $e) { + return 'too many requests'; + } + } + else if ($_POST['action'] === 'confirmEmail') { + try { + $auth->confirmEmail($_POST['selector'], $_POST['token']); + + return 'ok'; + } + catch (Delight\Auth\InvalidSelectorTokenPairException $e) { + return 'invalid token'; + } + catch (Delight\Auth\TokenExpiredException $e) { + return 'token expired'; + } + catch (Delight\Auth\TooManyRequestsException $e) { + return 'too many requests'; + } + } + else if ($_POST['action'] === 'changePassword') { + try { + $auth->changePassword($_POST['oldPassword'], $_POST['newPassword']); + + return 'ok'; + } + catch (Delight\Auth\NotLoggedInException $e) { + return 'not logged in'; + } + catch (Delight\Auth\InvalidPasswordException $e) { + return 'invalid password(s)'; + } + } + else if ($_POST['action'] === 'logout') { + $auth->logout(); + + return 'ok'; + } + else { + throw new Exception('Unexpected action: '.$_POST['action']); + } + } + } + + return null; +} + +function showDebugData(Delight\Auth\Auth $auth, $result) { + echo '
';
+
+	echo 'Last operation'."\t\t\t\t";
+	var_dump($result);
+	echo 'Session ID'."\t\t\t\t";
+	var_dump(session_id());
+	echo "\n";
+
+	echo '$auth->isLoggedIn()'."\t\t\t";
+	var_dump($auth->isLoggedIn());
+	echo '$auth->check()'."\t\t\t\t";
+	var_dump($auth->check());
+	echo "\n";
+
+	echo '$auth->getUserId()'."\t\t\t";
+	var_dump($auth->getUserId());
+	echo '$auth->id()'."\t\t\t\t";
+	var_dump($auth->id());
+	echo "\n";
+
+	echo '$auth->getEmail()'."\t\t\t";
+	var_dump($auth->getEmail());
+	echo '$auth->getUsername()'."\t\t\t";
+	var_dump($auth->getUsername());
+	echo '$auth->isRemembered()'."\t\t\t";
+	var_dump($auth->isRemembered());
+	echo '$auth->getIpAddress()'."\t\t\t";
+	var_dump($auth->getIpAddress());
+	echo "\n";
+
+	echo 'Auth::createRandomString()'."\t\t";
+	var_dump(Delight\Auth\Auth::createRandomString());
+	echo 'Auth::createUuid()'."\t\t\t";
+	var_dump(Delight\Auth\Auth::createUuid());
+
+	echo '
'; +} + +function showGeneralForm() { + echo '
'; + echo ''; + echo '
'; +} + +function showAuthenticatedUserForm() { + showGeneralForm(); + + echo '
'; + echo ''; + echo ' '; + echo ' '; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo ''; + echo '
'; +} + +function showGuestUserForm() { + showGeneralForm(); + + echo '
'; + echo ''; + echo ' '; + echo ' '; + echo ' '; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo ' '; + echo ' '; + echo ' '; + echo ' '; + echo ''; + echo '
'; + + echo '
'; + echo ''; + echo ' '; + echo ' '; + echo ''; + echo '
'; +}