diff options
author | marvin-borner@live.com | 2018-04-16 21:09:05 +0200 |
---|---|---|
committer | marvin-borner@live.com | 2018-04-16 21:09:05 +0200 |
commit | cf14306c2b3f82a81f8d56669a71633b4d4b5fce (patch) | |
tree | 86700651aa180026e89a66064b0364b1e4346f3f /main/app/sprinkles/account/src/Authenticate | |
parent | 619b01b3615458c4ed78bfaeabb6b1a47cc8ad8b (diff) |
Main merge to user management system - files are now at /main/public/
Diffstat (limited to 'main/app/sprinkles/account/src/Authenticate')
9 files changed, 708 insertions, 0 deletions
diff --git a/main/app/sprinkles/account/src/Authenticate/AuthGuard.php b/main/app/sprinkles/account/src/Authenticate/AuthGuard.php new file mode 100755 index 0000000..efcfaae --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/AuthGuard.php @@ -0,0 +1,56 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Slim\Http\Body; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AuthExpiredException; + +/** + * Middleware to catch requests that fail because they require user authentication. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AuthGuard +{ + /** + * @var Authenticator + */ + protected $authenticator; + + /** + * Constructor. + * + * @param $authenticator Authenticator The current authentication object. + */ + public function __construct($authenticator) + { + $this->authenticator = $authenticator; + } + + /** + * Invoke the AuthGuard middleware, throwing an exception if there is no authenticated user in the session. + * + * @param \Psr\Http\Message\ServerRequestInterface $request PSR7 request + * @param \Psr\Http\Message\ResponseInterface $response PSR7 response + * @param callable $next Next middleware + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function __invoke($request, $response, $next) + { + if (!$this->authenticator->check()) { + throw new AuthExpiredException(); + } else { + return $next($request, $response); + } + + return $response; + } +} diff --git a/main/app/sprinkles/account/src/Authenticate/Authenticator.php b/main/app/sprinkles/account/src/Authenticate/Authenticator.php new file mode 100755 index 0000000..5fb8920 --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Authenticator.php @@ -0,0 +1,419 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate; + +use Birke\Rememberme\Authenticator as RememberMe; +use Birke\Rememberme\Storage\PDOStorage as RememberMePDO; +use Birke\Rememberme\Triplet as RememberMeTriplet; +use Illuminate\Database\Capsule\Manager as Capsule; +use UserFrosting\Session\Session; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AccountDisabledException; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AccountInvalidException; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AccountNotVerifiedException; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AuthCompromisedException; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\AuthExpiredException; +use UserFrosting\Sprinkle\Account\Authenticate\Exception\InvalidCredentialsException; +use UserFrosting\Sprinkle\Account\Database\Models\User; +use UserFrosting\Sprinkle\Account\Facades\Password; +use UserFrosting\Sprinkle\Core\Util\ClassMapper; + +/** + * Handles authentication tasks. + * + * @author Alex Weissman (https://alexanderweissman.com) + * Partially inspired by Laravel's Authentication component: https://github.com/laravel/framework/blob/5.3/src/Illuminate/Auth/SessionGuard.php + */ +class Authenticator +{ + /** + * @var ClassMapper + */ + protected $classMapper; + + /** + * @var Session + */ + protected $session; + + /** + * @var Config + */ + protected $config; + + /** + * @var Cache + */ + protected $cache; + + /** + * @var bool + */ + protected $loggedOut = false; + + /** + * @var RememberMePDO + */ + protected $rememberMeStorage; + + /** + * @var RememberMe + */ + protected $rememberMe; + + /** + * @var User + */ + protected $user; + + /** + * Indicates if the user was authenticated via a rememberMe cookie. + * + * @var bool + */ + protected $viaRemember = false; + + /** + * Create a new Authenticator object. + * + * @param ClassMapper $classMapper Maps generic class identifiers to specific class names. + * @param Session $session The session wrapper object that will store the user's id. + * @param Config $config Config object that contains authentication settings. + * @param mixed $cache Cache service instance + */ + public function __construct(ClassMapper $classMapper, Session $session, $config, $cache) + { + $this->classMapper = $classMapper; + $this->session = $session; + $this->config = $config; + $this->cache = $cache; + + // Initialize RememberMe storage + $this->rememberMeStorage = new RememberMePDO($this->config['remember_me.table']); + + // Get the actual PDO instance from Eloquent + $pdo = Capsule::connection()->getPdo(); + + $this->rememberMeStorage->setConnection($pdo); + + // Set up RememberMe + $this->rememberMe = new RememberMe($this->rememberMeStorage); + // Set cookie name + $cookieName = $this->config['session.name'] . '-' . $this->config['remember_me.cookie.name']; + $this->rememberMe->getCookie()->setName($cookieName); + + // Change cookie path + $this->rememberMe->getCookie()->setPath($this->config['remember_me.session.path']); + + // Set expire time, if specified + if ($this->config->has('remember_me.expire_time') && ($this->config->has('remember_me.expire_time') != null)) { + $this->rememberMe->getCookie()->setExpireTime($this->config['remember_me.expire_time']); + } + + $this->user = null; + + $this->viaRemember = false; + } + + /** + * Attempts to authenticate a user based on a supplied identity and password. + * + * If successful, the user's id is stored in session. + */ + public function attempt($identityColumn, $identityValue, $password, $rememberMe = false) + { + // Try to load the user, using the specified conditions + $user = $this->classMapper->staticMethod('user', 'where', $identityColumn, $identityValue)->first(); + + if (!$user) { + throw new InvalidCredentialsException(); + } + + // Check that the user has a password set (so, rule out newly created accounts without a password) + if (!$user->password) { + throw new InvalidCredentialsException(); + } + + // Check that the user's account is enabled + if ($user->flag_enabled == 0) { + throw new AccountDisabledException(); + } + + // Check that the user's account is verified (if verification is required) + if ($this->config['site.registration.require_email_verification'] && $user->flag_verified == 0) { + throw new AccountNotVerifiedException(); + } + + // Here is my password. May I please assume the identify of this user now? + if (Password::verify($password, $user->password)) { + $this->login($user, $rememberMe); + return $user; + } else { + // We know the password is at fault here (as opposed to the identity), but lets not give away the combination in case of someone bruteforcing + throw new InvalidCredentialsException(); + } + } + + /** + * Determine if the current user is authenticated. + * + * @return bool + */ + public function check() + { + return !is_null($this->user()); + } + + /** + * Determine if the current user is a guest (unauthenticated). + * + * @return bool + */ + public function guest() + { + return !$this->check(); + } + + /** + * Process an account login request. + * + * This method logs in the specified user, allowing the client to assume the user's identity for the duration of the session. + * @param User $user The user to log in. + * @param bool $rememberMe Set to true to make this a "persistent session", i.e. one that will re-login even after the session expires. + * @todo Figure out a way to update the currentUser service to reflect the logged-in user *immediately* in the service provider. + * As it stands, the currentUser service will still reflect a "guest user" for the remainder of the request. + */ + public function login($user, $rememberMe = false) + { + $oldId = session_id(); + $this->session->regenerateId(true); + + // Since regenerateId deletes the old session, we'll do the same in cache + $this->flushSessionCache($oldId); + + // If the user wants to be remembered, create Rememberme cookie + if ($rememberMe) { + $this->rememberMe->createCookie($user->id); + } else { + $this->rememberMe->clearCookie(); + } + + // Assume identity + $key = $this->config['session.keys.current_user_id']; + $this->session[$key] = $user->id; + + // Set auth mode + $this->viaRemember = false; + + // User login actions + $user->onLogin(); + } + + /** + * Processes an account logout request. + * + * Logs the currently authenticated user out, destroying the PHP session and clearing the persistent session. + * This can optionally remove persistent sessions across all browsers/devices, since there can be a "RememberMe" cookie + * and corresponding database entries in multiple browsers/devices. See http://jaspan.com/improved_persistent_login_cookie_best_practice. + * + * @param bool $complete If set to true, will ensure that the user is logged out from *all* browsers on all devices. + */ + public function logout($complete = false) + { + $currentUserId = $this->session->get($this->config['session.keys.current_user_id']); + + // This removes all of the user's persistent logins from the database + if ($complete) { + $this->storage->cleanAllTriplets($currentUserId); + } + + // Clear the rememberMe cookie + $this->rememberMe->clearCookie(); + + // User logout actions + if ($currentUserId) { + $currentUser = $this->classMapper->staticMethod('user', 'find', $currentUserId); + if ($currentUser) { + $currentUser->onLogout(); + } + } + + $this->user = null; + $this->loggedOut = true; + + $oldId = session_id(); + + // Completely destroy the session + $this->session->destroy(); + + // Since regenerateId deletes the old session, we'll do the same in cache + $this->flushSessionCache($oldId); + + // Restart the session service + $this->session->start(); + } + + /** + * Try to get the currently authenticated user, returning a guest user if none was found. + * + * Tries to re-establish a session for "remember-me" users who have been logged out due to an expired session. + * @return User|null + * @throws AuthExpiredException + * @throws AuthCompromisedException + * @throws AccountInvalidException + * @throws AccountDisabledException + */ + public function user() + { + $user = null; + + if (!$this->loggedOut) { + + // Return any cached user + if (!is_null($this->user)) { + return $this->user; + } + + // If this throws a PDOException we catch it and return null than allowing the exception to propagate. + // This is because the error handler relies on Twig, which relies on a Twig Extension, which relies on the global current_user variable. + // So, we really don't want this method to throw any database exceptions. + try { + // Now, check to see if we have a user in session + $user = $this->loginSessionUser(); + + // If no user was found in the session, try to login via RememberMe cookie + if (!$user) { + $user = $this->loginRememberedUser(); + } + } catch (\PDOException $e) { + $user = null; + } + } + + return $this->user = $user; + } + + /** + * Determine whether the current user was authenticated using a remember me cookie. + * + * This function is useful when users are performing sensitive operations, and you may want to force them to re-authenticate. + * @return bool + */ + public function viaRemember() + { + return $this->viaRemember; + } + + /** + * Attempt to log in the client from their rememberMe token (in their cookie). + * + * @return User|bool If successful, the User object of the remembered user. Otherwise, return false. + * @throws AuthCompromisedException The client attempted to log in with an invalid rememberMe token. + */ + protected function loginRememberedUser() + { + /** @var \Birke\Rememberme\LoginResult $loginResult */ + $loginResult = $this->rememberMe->login(); + + if ($loginResult->isSuccess()) { + // Update in session + $this->session[$this->config['session.keys.current_user_id']] = $loginResult->getCredential(); + // There is a chance that an attacker has stolen the login token, + // so we store the fact that the user was logged in via RememberMe (instead of login form) + $this->viaRemember = true; + } else { + // If $rememberMe->login() was not successfull, check if the token was invalid as well. This means the cookie was stolen. + if ($loginResult->hasPossibleManipulation()) { + throw new AuthCompromisedException(); + } + } + + return $this->validateUserAccount($loginResult->getCredential()); + } + + /** + * Attempt to log in the client from the session. + * + * @return User|null If successful, the User object of the user in session. Otherwise, return null. + * @throws AuthExpiredException The client attempted to use an expired rememberMe token. + */ + protected function loginSessionUser() + { + $userId = $this->session->get($this->config['session.keys.current_user_id']); + + // If a user_id was found in the session, check any rememberMe cookie that was submitted. + // If they submitted an expired rememberMe cookie, then we need to log them out. + if ($userId) { + if (!$this->validateRememberMeCookie()) { + $this->logout(); + throw new AuthExpiredException(); + } + } + + return $this->validateUserAccount($userId); + } + + /** + * Determine if the cookie contains a valid rememberMe token. + * + * @return bool + */ + protected function validateRememberMeCookie() + { + $cookieValue = $this->rememberMe->getCookie()->getValue(); + if (!$cookieValue) { + return true; + } + $triplet = RememberMeTriplet::fromString($cookieValue); + if (!$triplet->isValid()) { + return false; + } + + return true; + } + + /** + * Tries to load the specified user by id from the database. + * + * Checks that the account is valid and enabled, throwing an exception if not. + * @param int $userId + * @return User|null + * @throws AccountInvalidException + * @throws AccountDisabledException + */ + protected function validateUserAccount($userId) + { + if ($userId) { + $user = $this->classMapper->staticMethod('user', 'find', $userId); + + // If the user doesn't exist any more, throw an exception. + if (!$user) { + throw new AccountInvalidException(); + } + + // If the user has been disabled since their last request, throw an exception. + if (!$user->flag_enabled) { + throw new AccountDisabledException(); + } + + return $user; + } else { + return null; + } + } + + /** + * Flush the cache associated with a session id + * + * @param string $id The session id + * @return bool + */ + public function flushSessionCache($id) + { + return $this->cache->tags('_s' . $id)->flush(); + } +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/AccountDisabledException.php b/main/app/sprinkles/account/src/Authenticate/Exception/AccountDisabledException.php new file mode 100755 index 0000000..e79ceb5 --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/AccountDisabledException.php @@ -0,0 +1,21 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Disabled account exception. Used when an account has been disabled. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AccountDisabledException extends HttpException +{ + protected $defaultMessage = 'ACCOUNT.DISABLED'; + protected $httpErrorCode = 403; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/AccountInvalidException.php b/main/app/sprinkles/account/src/Authenticate/Exception/AccountInvalidException.php new file mode 100755 index 0000000..607235b --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/AccountInvalidException.php @@ -0,0 +1,21 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Invalid account exception. Used when an account has been removed during an active session. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AccountInvalidException extends HttpException +{ + protected $defaultMessage = 'ACCOUNT.INVALID'; + protected $httpErrorCode = 403; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/AccountNotVerifiedException.php b/main/app/sprinkles/account/src/Authenticate/Exception/AccountNotVerifiedException.php new file mode 100755 index 0000000..7eb56a6 --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/AccountNotVerifiedException.php @@ -0,0 +1,21 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Unverified account exception. Used when an account is required to complete email verification, but hasn't done so yet. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AccountNotVerifiedException extends HttpException +{ + protected $defaultMessage = 'ACCOUNT.UNVERIFIED'; + protected $httpErrorCode = 403; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/AuthCompromisedException.php b/main/app/sprinkles/account/src/Authenticate/Exception/AuthCompromisedException.php new file mode 100755 index 0000000..df3efbe --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/AuthCompromisedException.php @@ -0,0 +1,20 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\ForbiddenException; + +/** + * Compromised authentication exception. Used when we suspect theft of the rememberMe cookie. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AuthCompromisedException extends ForbiddenException +{ + protected $defaultMessage = 'ACCOUNT.SESSION_COMPROMISED'; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/AuthExpiredException.php b/main/app/sprinkles/account/src/Authenticate/Exception/AuthExpiredException.php new file mode 100755 index 0000000..5583746 --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/AuthExpiredException.php @@ -0,0 +1,21 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Expired authentication exception. Used when the user needs to authenticate/reauthenticate. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class AuthExpiredException extends HttpException +{ + protected $defaultMessage = 'ACCOUNT.SESSION_EXPIRED'; + protected $httpErrorCode = 401; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Exception/InvalidCredentialsException.php b/main/app/sprinkles/account/src/Authenticate/Exception/InvalidCredentialsException.php new file mode 100755 index 0000000..18d4a5c --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Exception/InvalidCredentialsException.php @@ -0,0 +1,21 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate\Exception; + +use UserFrosting\Support\Exception\HttpException; + +/** + * Invalid credentials exception. Used when an account fails authentication for some reason. + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class InvalidCredentialsException extends HttpException +{ + protected $defaultMessage = 'USER_OR_PASS_INVALID'; + protected $httpErrorCode = 403; +} diff --git a/main/app/sprinkles/account/src/Authenticate/Hasher.php b/main/app/sprinkles/account/src/Authenticate/Hasher.php new file mode 100755 index 0000000..e277eef --- /dev/null +++ b/main/app/sprinkles/account/src/Authenticate/Hasher.php @@ -0,0 +1,108 @@ +<?php +/** + * UserFrosting (http://www.userfrosting.com) + * + * @link https://github.com/userfrosting/UserFrosting + * @license https://github.com/userfrosting/UserFrosting/blob/master/licenses/UserFrosting.md (MIT License) + */ +namespace UserFrosting\Sprinkle\Account\Authenticate; + +/** + * Password hashing and validation class + * + * @author Alex Weissman (https://alexanderweissman.com) + */ +class Hasher +{ + /** + * Default crypt cost factor. + * + * @var int + */ + protected $defaultRounds = 10; + + /** + * Returns the hashing type for a specified password hash. + * + * Automatically detects the hash type: "sha1" (for UserCake legacy accounts), "legacy" (for 0.1.x accounts), and "modern" (used for new accounts). + * @param string $password the hashed password. + * @return string "sha1"|"legacy"|"modern". + */ + public function getHashType($password) + { + // If the password in the db is 65 characters long, we have an sha1-hashed password. + if (strlen($password) == 65) { + return 'sha1'; + } elseif (strlen($password) == 82) { + return 'legacy'; + } + + return 'modern'; + } + + /** + * Hashes a plaintext password using bcrypt. + * + * @param string $password the plaintext password. + * @param array $options + * @return string the hashed password. + * @throws HashFailedException + */ + public function hash($password, array $options = []) + { + $hash = password_hash($password, PASSWORD_BCRYPT, [ + 'cost' => $this->cost($options), + ]); + + if (!$hash) { + throw new HashFailedException(); + } + + return $hash; + } + + /** + * Verify a plaintext password against the user's hashed password. + * + * @param string $password The plaintext password to verify. + * @param string $hash The hash to compare against. + * @param array $options + * @return boolean True if the password matches, false otherwise. + */ + public function verify($password, $hash, array $options = []) + { + $hashType = $this->getHashType($hash); + + if ($hashType == 'sha1') { + // Legacy UserCake passwords + $salt = substr($hash, 0, 25); // Extract the salt from the hash + $inputHash = $salt . sha1($salt . $password); + + return (hash_equals($inputHash, $hash) === true); + + } elseif ($hashType == 'legacy') { + // Homegrown implementation (assuming that current install has been using a cost parameter of 12) + // Used for manual implementation of bcrypt. + // Note that this legacy hashing put the salt at the _end_ for some reason. + $salt = substr($hash, 60); + $inputHash = crypt($password, '$2y$12$' . $salt); + $correctHash = substr($hash, 0, 60); + + return (hash_equals($inputHash, $correctHash) === true); + } + + // Modern implementation + return password_verify($password, $hash); + } + + /** + * Extract the cost value from the options array. + * + * @param array $options + * @return int + */ + protected function cost(array $options = []) + { + return isset($options['rounds']) ? $options['rounds'] : $this->defaultRounds; + } +} |