diff --git a/README.md b/README.md index 12a5400..f8d2b1b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Migrating from an earlier version of this project? See our [upgrade guide](Migra * [Creating a random string](#creating-a-random-string) * [Creating a UUID v4 as per RFC 4122](#creating-a-uuid-v4-as-per-rfc-4122) * [Reading and writing session data](#reading-and-writing-session-data) + * [Implementing multi-factor authentication](#implementing-multi-factor-authentication) ### Creating a new instance @@ -490,6 +491,24 @@ $uuid = \Delight\Auth\Auth::createUuid(); For detailed information on how to read and write session data conveniently, please refer to [the documentation of the session library](https://github.com/delight-im/PHP-Cookie#reading-and-writing-session-data), which is included by default. +### Implementing multi-factor authentication + +You can pass a callback, e.g. an anonymous function, to the two methods `login` or `loginWithUsername` as their fourth parameter. Such a callback could look like this: + +```php +function ($userId) { + // ... + + return false; + // or + // return true; +} +``` + +The callback will be executed if (and only if) authentication is successful, but it will run *right before* completing authentication. This lets you hook into the login flow. + +In that callback, you receive the authenticating user's ID as the only parameter. Return `true` from the callback to let authentication proceed, or return `false` to cancel the attempt. Be ready to catch the `AttemptCancelledException` in the latter case. + ## Frequently asked questions ### What about password hashing? diff --git a/src/Auth.php b/src/Auth.php index 99f7db8..09d6b22 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -197,13 +197,15 @@ final class Auth extends UserManager { * @param string $email the user's email address * @param string $password the user's password * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year + * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws InvalidEmailException if the email address was invalid or could not be found * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email + * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws AuthError if an internal problem occurred (do *not* catch) */ - public function login($email, $password, $rememberDuration = null) { - $this->authenticateUserInternal($password, $email, null, $rememberDuration); + public function login($email, $password, $rememberDuration = null, callable $onBeforeSuccess = null) { + $this->authenticateUserInternal($password, $email, null, $rememberDuration, $onBeforeSuccess); } /** @@ -216,14 +218,16 @@ final class Auth extends UserManager { * @param string $username the user's username * @param string $password the user's password * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year + * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws UnknownUsernameException if the specified username does not exist * @throws AmbiguousUsernameException if the specified username is ambiguous, i.e. there are multiple users with that name * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email + * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws AuthError if an internal problem occurred (do *not* catch) */ - public function loginWithUsername($username, $password, $rememberDuration = null) { - $this->authenticateUserInternal($password, null, $username, $rememberDuration); + public function loginWithUsername($username, $password, $rememberDuration = null, callable $onBeforeSuccess = null) { + $this->authenticateUserInternal($password, null, $username, $rememberDuration, $onBeforeSuccess); } /** @@ -605,14 +609,16 @@ final class Auth extends UserManager { * @param string|null $email (optional) the user's email address * @param string|null $username (optional) the user's username * @param int|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year + * @param callable|null $onBeforeSuccess (optional) a function that receives the user's ID as its single parameter and is executed before successful authentication; must return `true` to proceed or `false` to cancel * @throws InvalidEmailException if the email address was invalid or could not be found * @throws UnknownUsernameException if an attempt has been made to authenticate with a non-existing username * @throws AmbiguousUsernameException if an attempt has been made to authenticate with an ambiguous username * @throws InvalidPasswordException if the password was invalid * @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email + * @throws AttemptCancelledException if the attempt has been cancelled by the supplied callback that is executed before success * @throws AuthError if an internal problem occurred (do *not* catch) */ - private function authenticateUserInternal($password, $email = null, $username = null, $rememberDuration = null) { + private function authenticateUserInternal($password, $email = null, $username = null, $rememberDuration = null, callable $onBeforeSuccess = null) { $columnsToFetch = [ 'id', 'email', 'password', 'verified', 'username', 'status' ]; if ($email !== null) { @@ -680,21 +686,26 @@ final class Auth extends UserManager { } if ((int) $userData['verified'] === 1) { - $this->onLoginSuccessful($userData['id'], $userData['email'], $userData['username'], $userData['status'], false); + if (!isset($onBeforeSuccess) || (\is_callable($onBeforeSuccess) && $onBeforeSuccess($userData['id']) === true)) { + $this->onLoginSuccessful($userData['id'], $userData['email'], $userData['username'], $userData['status'], false); - // continue to support the old parameter format - if ($rememberDuration === true) { - $rememberDuration = 60 * 60 * 24 * 28; - } - elseif ($rememberDuration === false) { - $rememberDuration = null; - } + // continue to support the old parameter format + if ($rememberDuration === true) { + $rememberDuration = 60 * 60 * 24 * 28; + } + elseif ($rememberDuration === false) { + $rememberDuration = null; + } - if ($rememberDuration !== null) { - $this->createRememberDirective($userData['id'], $rememberDuration); - } + if ($rememberDuration !== null) { + $this->createRememberDirective($userData['id'], $rememberDuration); + } - return; + return; + } + else { + throw new AttemptCancelledException(); + } } else { throw new EmailNotVerifiedException(); diff --git a/tests/index.php b/tests/index.php index 10aaa95..caf74ae 100644 --- a/tests/index.php +++ b/tests/index.php @@ -49,12 +49,16 @@ function processRequestData(\Delight\Auth\Auth $auth) { $rememberDuration = null; } + $onBeforeSuccess = function ($userId) { + return \mt_rand(1, 100) <= 50; + }; + try { if (isset($_POST['email'])) { - $auth->login($_POST['email'], $_POST['password'], $rememberDuration); + $auth->login($_POST['email'], $_POST['password'], $rememberDuration, $onBeforeSuccess); } elseif (isset($_POST['username'])) { - $auth->loginWithUsername($_POST['username'], $_POST['password'], $rememberDuration); + $auth->loginWithUsername($_POST['username'], $_POST['password'], $rememberDuration, $onBeforeSuccess); } else { return 'either email address or username required'; @@ -77,6 +81,9 @@ function processRequestData(\Delight\Auth\Auth $auth) { catch (\Delight\Auth\EmailNotVerifiedException $e) { return 'email not verified'; } + catch (\Delight\Auth\AttemptCancelledException $e) { + return 'attempt cancelled'; + } catch (\Delight\Auth\TooManyRequestsException $e) { return 'too many requests'; }