aboutsummaryrefslogtreecommitdiffhomepage
path: root/main/app/sprinkles/account/src/Authenticate
diff options
context:
space:
mode:
authormarvin-borner@live.com2018-04-16 21:09:05 +0200
committermarvin-borner@live.com2018-04-16 21:09:05 +0200
commitcf14306c2b3f82a81f8d56669a71633b4d4b5fce (patch)
tree86700651aa180026e89a66064b0364b1e4346f3f /main/app/sprinkles/account/src/Authenticate
parent619b01b3615458c4ed78bfaeabb6b1a47cc8ad8b (diff)
Main merge to user management system - files are now at /main/public/
Diffstat (limited to 'main/app/sprinkles/account/src/Authenticate')
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/AuthGuard.php56
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Authenticator.php419
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/AccountDisabledException.php21
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/AccountInvalidException.php21
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/AccountNotVerifiedException.php21
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/AuthCompromisedException.php20
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/AuthExpiredException.php21
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Exception/InvalidCredentialsException.php21
-rwxr-xr-xmain/app/sprinkles/account/src/Authenticate/Hasher.php108
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;
+ }
+}