Authentication
Table of contents
Loading...Introduction
If the application is designed to manage information that must be accessible only by authorized users, it has to be protected by an authentication system which requires the user to verify their identity via credentials (email and password).
This page documents the implementation of the authentication system in the Slim Example Project. The entire code can be found in the GitHub repository.
Before diving into the code, it's important to have a basic understanding of Slim PHP and understand Dependency Injection, Sessions, Middlewares, Actions, Slim Routing and Repositories.
Registration is not covered here. Users are created by an administrator and then receive an email containing an activation link.
Authentication middleware
When added to a route or route group, the authentication middleware is responsible for checking if the user is logged in and if not, redirecting them to the login page.
File: src/Application/Middleware/UserAuthenticationMiddleware.php
<?php
namespace App\Application\Middleware;
use App\Application\Responder\JsonResponder;
use App\Application\Responder\RedirectHandler;
use App\Domain\User\Enum\UserStatus;
use App\Domain\User\Service\UserFinder;
use Odan\Session\SessionInterface;
use Odan\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Interfaces\RouteParserInterface;
final readonly class UserAuthenticationMiddleware implements MiddlewareInterface
{
public function __construct(
private SessionManagerInterface $sessionManager,
private SessionInterface $session,
private JsonResponder $jsonResponder,
private RedirectHandler $redirectHandler,
private RouteParserInterface $routeParser,
private ResponseFactoryInterface $responseFactory,
private UserFinder $userFinder,
) {
}
/**
* User authentication middleware. Check if the user is logged in and if not
* redirect to login page with redirect back query params.
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Check if user is logged in
if (($loggedInUserId = $this->session->get('user_id')) !== null) {
// Check that the user status is active
if ($this->userFinder->findUserById($loggedInUserId)->status === UserStatus::Active) {
return $handler->handle($request);
}
// Log user out if not active
$this->sessionManager->destroy();
$this->sessionManager->start();
$this->sessionManager->regenerateId();
}
$response = $this->responseFactory->createResponse();
// Inform the user that he/she has to log in before accessing the page
$this->session->getFlash()->add('info', 'Please login to access this page.');
// If it's a JSON request, return 401 with the login url and its possible query params
if ($request->getHeaderLine('Content-Type') === 'application/json') {
return $this->jsonResponder->encodeAndAddToResponse(
$response,
['loginUrl' => $this->routeParser->urlFor('login-page')],
401
);
}
// If no redirect header is set, and it's not a JSON request, redirect to the same url as the request after login
$queryParams = ['redirect' => $request->getUri()->getPath()];
return $this->redirectHandler->redirectToRouteName($response, 'login-page', [], $queryParams);
}
}
Documentation: Slim Middlewares.
Protected routes
Routes that require users to be authenticated can be protected by adding
UserAuthenticationMiddleware
to the
route or route group definition.
Not strictly every route needs to be protected. For instance, the login page should be accessible without authentication otherwise the users wouldn't be able to log in. The same goes for the password-forgotten page etc.
File: config/routes.php
use Slim\App;
use App\Application\Middleware\UserAuthenticationMiddleware;
use App\Application\Action\Authentication\Page\LoginPageAction;
use App\Application\Action\Authentication\Ajax\LoginSubmitAction;
use App\Application\Action\Client\Page\ClientListPageAction;
return function (App $app) {
// Unprotected routes
$app->get('/login', LoginPageAction::class)->setName('login-page');
$app->post('/login', LoginSubmitAction::class)->setName('login-submit');
// Submit email for forgotten request
$app->post('/password-forgotten', \App\Application\Action\Authentication\Ajax\PasswordForgottenEmailSubmitAction::class)
->setName('password-forgotten-email-submit');
// Set the new password page after clicking on email link with token
$app->get('/reset-password', \App\Application\Action\Authentication\Page\PasswordResetPageAction::class)
->setName('password-reset-page');
// Submit new password after clicking on email link with token
$app->post('/reset-password', \App\Application\Action\Authentication\Ajax\NewPasswordResetSubmitAction::class)
->setName('password-reset-submit');
// Protected group
$app->group('/users', function (RouteCollectorProxy $group) {
// Users routes
})->add(UserAuthenticationMiddleware::class); // Require authentication
// Protected route
$app->get('/clients/list', ClientListPageAction::class)
->setName('client-list-page')
->add(UserAuthenticationMiddleware::class); // Require authentication
};
Documentation: Slim Routing.
Login
Login action
Anyone that wants to access a protected area must first authenticate on the login page.
When the login form is submitted with credentials, the POST request is routed
to the LoginSubmitAction
class.
The LoginSubmitAction
is responsible for checking if the credentials are correct
with the LoginVerifier
service and if they are, creating the session for the user.
File: src/Application/Action/Authentication/Ajax/LoginSubmitAction.php
use App\Application\Responder\RedirectHandler;
use Odan\Session\SessionInterface;
use App\Domain\Authentication\Service\LoginVerifier;
use Odan\Session\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final readonly class LoginSubmitAction
{
public function __construct(
private SessionManagerInterface $sessionManager,
private SessionInterface $session,
private LoginVerifier $loginVerifier,
private RedirectHandler $redirectHandler,
) {
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$submitValues = (array)$request->getParsedBody();
$queryParams = $request->getQueryParams();
$flash = $this->session->getFlash();
try {
// Throws InvalidCredentialsException if not allowed
$userId = $this->loginVerifier->verifyLoginAndGetUserId($submitValues, $queryParams);
// Regenerate session ID
$this->sessionManager->regenerateId();
// Add user to session
$this->session->set('user_id', $userId);
// Add success message to flash
$flash->add('success', __('Login successful'));
// After register and login success, check if user should be redirected
if (isset($queryParams['redirect'])) {
return $this->redirectHandler->redirectToUrl($response, $queryParams['redirect']);
}
return $this->redirectHandler->redirectToRouteName($response, 'home-page', []);
} // The validation exception middleware responds with JSON, and the login page needs to be rendered
catch (ValidationException $ve) {
// Render login page with form validation errors status 422
} catch (InvalidCredentialsException $e) {
// Render login page with error message status 401
} catch (SecurityException $securityException) {
// Render login form with time throttling or captcha status 422
} catch (UnableToLoginStatusNotActiveException $unableToLoginException) {
// Render login form with error message status 401
}
}
}
Documentation: Action.
Login service
The login service coordinates the different components that are involved in the login process.
Firstly, the submitted values are
validated
within the AuthenticationValidator
class (requires the validation library to be installed
composer require cakephp/validation
).
Click to see file: src/Domain/Authentication/Service/AuthenticationValidator.php
Strings wrapped in __()
are translated.
If localization is not used, the function calls can be removed.
<?php namespace App\Domain\Authentication\Service;
use App\Domain\Exception\ValidationException;
use App\Domain\User\Repository\UserFinderRepository;
use Cake\Validation\Validator;
final readonly class AuthenticationValidator
{
public function __construct(
private UserFinderRepository $userFinderRepository,
) {
}
/**
* Validate passwords.
*
* @param array $passwordValues
*
* @return void
*/
public function validatePasswordChange(array $passwordValues): void
{
$validator = new Validator();
// Passwords are always required when this validation method is called
$this->addPasswordValidationRules($validator, true);
// No rule for old password as it's optional and validated later in the service class
// Validate and throw exception if there are errors
$errors = $validator->validate($passwordValues);
if ($errors) {
throw new ValidationException($errors);
}
}
/**
* Validate passwords.
*
* @param array $passwordResetValues
*
* @return void
*/
public function validatePasswordReset(array $passwordResetValues): void
{
$validator = new Validator();
// Passwords are always required when this validation method is called
$this->addPasswordValidationRules($validator, true);
// Add token validation rules
$validator
->requirePresence('id', true, __('Field is required'))
->numeric('id', __('Token id is not numeric'))
->requirePresence('token', true, __('Field is required'))
->notEmptyString('token', __('Token is required'));
// Validate and throw exception if there are errors
$errors = $validator->validate($passwordResetValues);
if ($errors) {
throw new ValidationException($errors);
}
}
/**
* Add password and password2 validation rules.
* In own function as it's used by different validation methods.
*
* @param Validator $validator
* @param bool $required
* Validator doesn't have to be returned as it changes the values of the passed object reference
*/
public function addPasswordValidationRules(Validator $validator, bool $required = true): void
{
$validator
->requirePresence('password', $required, __('Field is required'))
->notEmptyString('password', __('Password required'))
->minLength('password', 3, __('Minimum length is %d', 3))
->maxLength('password', 1000, __('Maximum length is %d', 1000))
->requirePresence('password2', $required, __('Field is required'))
->notEmptyString('password2', __('Password required'))
->minLength('password2', 3, __('Minimum length is %d', 3))
->maxLength('password2', 1000, __('Maximum length is %d', 1000))
->add('password2', 'passwordsMatch', [
'rule' => function ($value, $context) {
// Check if passwords match
return $value === $context['data']['password'];
},
'message' => __('Passwords do not match'),
]);
}
/**
* Validate if user inputs for the login
* are valid.
*
* @param array $userLoginValues
*
* @throws ValidationException
*/
public function validateUserLogin(array $userLoginValues): void
{
$validator = new Validator();
// Intentionally not validating user existence as invalid login should be vague
$validator
->requirePresence('email', true, __('Field is required'))
->email('email', false, __('Invalid email'))
->requirePresence('password', true, __('Field is required'))
->notEmptyString('password', __('Invalid password'))
// Further password validating seems not very useful and could lead to issues if password validation rules
// change and user want's to log in with a password that was created before the rule change
->requirePresence('g-recaptcha-response', false); // Optional key
// Validate and throw exception if there are errors
$errors = $validator->validate($userLoginValues);
if ($errors) {
throw new ValidationException($errors);
}
}
/**
* Validate email for password recovery.
*
* @param array $userValues
*/
public function validatePasswordResetEmail(array $userValues): void
{
$validator = new Validator();
// Intentionally not validating user existence as it would be a security flaw to tell the user if email exists
$validator->requirePresence('email', true, __('Field is required'))
->email('email', false, __('Invalid email'));
// Validate and throw exception if there are errors
$errors = $validator->validate($userValues);
if ($errors) {
throw new ValidationException($errors);
}
}
/**
* Verifies if the given old password is correct.
* Previously in own service class passwordVerifier, but it's simpler
* to display normal validation errors in the client form.
*
* @param array $oldPassword array with as key old_password
* @param int $userId
*
* @return void
*/
public function checkIfOldPasswordIsCorrect(array $oldPassword, int $userId): void
{
$validator = new Validator();
$validator
// If this validation method is called, we already know that the key is present
->notEmptyString('old_password', __('Old password required'))
->add('old_password', 'oldPasswordIsCorrect', [
'rule' => function ($value, $context) use ($userId) {
// Get password from database
$dbUser = $this->userFinderRepository->findUserByIdWithPasswordHash($userId);
// Check if old password is correct
return password_verify($value, (string)$dbUser->passwordHash);
},
'message' => __('Incorrect password'),
]);
// Validate and throw exception if there are errors
$errors = $validator->validate($oldPassword);
if ($errors) {
throw new ValidationException($errors);
}
}
}
Then, the login security checker verifies if the user is allowed to make a login request as protection against brute force attacks (if implemented).
After that, the user's existence and password are verified.
If the password is correct and the user status is active, the user id is returned to the action,
indicating that the verification was successful.
If the status is not active, an email is sent to the user with a link to activate the account
or inform them that the account is locked.
The request is processed as if the credentials
were incorrect to avoid giving away information about the user's existence.
If the password is not correct or the user doesn't exist, the login request is logged and an
InvalidCredentialsException
is thrown.
File: src/Domain/Authentication/Service/LoginVerifier.php
<?php
namespace App\Domain\Authentication\Service;
use App\Application\Data\UserNetworkSessionData;
use App\Domain\Authentication\Exception\InvalidCredentialsException;
use App\Domain\Security\Repository\AuthenticationLoggerRepository;
use App\Domain\Security\Service\SecurityLoginChecker;
use App\Domain\User\Enum\UserStatus;
use App\Domain\User\Repository\StatsFinderRepository;
use App\Domain\User\Service\UserValidator;
final readonly class LoginVerifier
{
public function __construct(
private UserValidator $userValidator,
private SecurityLoginChecker $loginSecurityChecker,
private StatsFinderRepository $userFinderRepository,
private AuthenticationLoggerRepository $authenticationLoggerRepository,
private LoginNonActiveUserHandler $loginNonActiveUserHandler,
) {
}
/**
* Verifies the user's login credentials and returns the user id if the login is successful.
*
* @param array $userLoginValues
* @param array $queryParams
*
* @throws TransportExceptionInterface If an error occurs while sending an email to a non-active user.
* @throws InvalidCredentialsException If the user does not exist or the password is incorrect.
*
* @return int The id of the user if the login is successful.
*/
public function verifyLoginAndGetUserId(array $userLoginValues, array $queryParams = []): int
{
// Validate submitted values
$this->userValidator->validateUserLogin($userLoginValues);
$captcha = $userLoginValues['g-recaptcha-response'] ?? null;
// Perform login security check before verifying credentials
// https://samuel-gfeller.ch/docs/Security#authentication
// Uncomment if login security checks are implemented
// $this->loginSecurityChecker->performLoginSecurityCheck($userLoginValues['email'], $captcha);
$dbUser = $this->userFinderRepository->findUserByEmail($userLoginValues['email']);
// Check if the user exists and check if the password is correct
if (isset($dbUser->email, $dbUser->passwordHash)
&& password_verify($userLoginValues['password'], $dbUser->passwordHash)) {
// If password correct and status active, log user in by
if ($dbUser->status === UserStatus::Active) {
// Log successful login request
// Uncomment if login security checks are implemented
// $this->authenticationLoggerRepository->logLoginRequest(
// $dbUser->email, $this->userNetworkSessionData->ipAddress, true, $dbUser->id
// );
// Return id
return (int)$dbUser->id;
}
// If the password is correct but the status not verified, send email to user
// captcha needed if email security check requires captcha
// Uncomment if email verification is needed. LoginNonActiveUserHandler code in slim example project
// $this->loginNonActiveUserHandler->handleLoginAttemptFromNonActiveUser($dbUser, $queryParams, $captcha);
}
// Uncomment if login security checks are implemented
// The password is not correct or user not existing. Log failed login request.
// $this->authenticationLoggerRepository->logLoginRequest(
// $userLoginValues['email'], $this->userNetworkSessionData->ipAddress, false, $dbUser->id
// );
// Perform second login security request check after additional verification to display
// the correct error message to the user if throttle is in place
// $this->loginSecurityChecker->performLoginSecurityCheck($userLoginValues['email'], $captcha);
// Throw exception if the user doesn't exist or wrong password
// Vague exception on purpose for security
throw new InvalidCredentialsException($userLoginValues['email']);
}
}
Click to see file: src/Domain/Authentication/Service/AuthenticationValidator.php
<?php namespace App\Domain\Authentication\Exception;
class InvalidCredentialsException extends \RuntimeException
{
// Voluntarily not more information
private string $userEmail;
// Invalid credentials asserted in LoginSubmitActionTest
public function __construct(string $email, string $message = 'Invalid credentials')
{
parent::__construct($message);
$this->userEmail = $email;
}
/**
* @return string
*/
public function getUserEmail(): string
{
return $this->userEmail;
}
}
Password forgotten
Account email submit action
When users forget their password, they can request a password reset link by entering their email address in the password-forgotten form.
The form is submitted via POST request to the PasswordForgottenEmailSubmitAction
which calls the service function to send the password recovery email.
File: src/Application/Action/Authentication/Ajax/PasswordForgottenEmailSubmitAction.php
<?php
namespace App\Application\Action\Authentication\Ajax;
use App\Application\Responder\RedirectHandler;
use App\Domain\Authentication\Service\PasswordRecoveryEmailSender;
use App\Domain\Exception\DomainRecordNotFoundException;
use App\Domain\Security\Exception\SecurityException;
use App\Domain\Validation\ValidationException;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
final readonly class PasswordForgottenEmailSubmitAction
{
public function __construct(
private RedirectHandler $redirectHandler,
private SessionInterface $session,
private PasswordRecoveryEmailSender $passwordRecoveryEmailSender,
) {
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$flash = $this->session->getFlash();
$userValues = (array)$request->getParsedBody();
try {
$this->passwordRecoveryEmailSender->sendPasswordRecoveryEmail($userValues);
} catch (DomainRecordNotFoundException $domainRecordNotFoundException) {
// User was not found, but no error returned
// Intentionally not giving any information about the user's existence
// Log that user with email tried to reset the password of non-existent user
} catch (ValidationException $validationException) {
// Render login page with validation error
} catch (SecurityException $securityException) {
// Render login page with form throttling and error message
} catch (TransportExceptionInterface $transportException) {
$flash->add('error', __('There was an error when sending the email.'));
// Render login page
}
$flash->add('success', __("Password recovery email is being sent to you."));
return $this->redirectHandler->redirectToRouteName($response, 'login-page');
}
}
Send password recovery email
The service class PasswordRecoveryEmailSender
is responsible for the coordination of
security checks,
creating the password recovery token, and sending the email.
File: src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php
<?php
namespace App\Domain\Authentication\Service;
use App\Domain\Exception\DomainRecordNotFoundException;
use App\Domain\Security\Service\SecurityEmailChecker;
use App\Domain\User\Repository\StatsFinderRepository;
use App\Domain\User\Service\UserValidator;
use App\Domain\Validation\ValidationException;
use App\Infrastructure\Service\LocaleConfigurator;
use App\Infrastructure\Service\Mailer;
use App\Infrastructure\Utility\Settings;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
final readonly class PasswordRecoveryEmailSender
{
private Email $email;
public function __construct(
private Mailer $mailer,
private UserValidator $userValidator,
private StatsFinderRepository $userFinderRepository,
private VerificationTokenCreator $verificationTokenCreator,
Settings $settings,
private SecurityEmailChecker $securityEmailChecker,
private LocaleConfigurator $localeConfigurator,
) {
$settings = $settings->get('public')['email'];
// Create email object
$this->email = new Email();
// Send auth emails from domain
$this->email->from(new Address($settings['main_sender_address'], $settings['main_sender_name']))->replyTo(
$settings['main_contact_address']
)->priority(Email::PRIORITY_HIGH);
}
public function sendPasswordRecoveryEmail(array $userValues, ?string $captcha = null): void
{
$this->userValidator->validatePasswordResetEmail($userValues);
$email = $userValues['email'];
// Verify that user (concerned email) or ip address doesn't spam email sending
$this->securityEmailChecker->performEmailAbuseCheck($email, $captcha);
$dbUser = $this->userFinderRepository->findUserByEmail($email);
if (isset($dbUser->email, $dbUser->id)) {
// Create a verification token, so they don't have to register again
$queryParamsWithToken = $this->verificationTokenCreator->createUserVerification($dbUser->id);
// Change language to one the user chose in settings
$originalLocale = setlocale(LC_ALL, 0);
$this->localeConfigurator->setLanguage($dbUser->language->value);
// Send verification mail
$this->email->subject(__('Reset password'))->html(
$this->mailer->getContentFromTemplate(
'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() .
'password-reset.email.php',
['user' => $dbUser, 'queryParams' => $queryParamsWithToken]
)
)->to(new Address($dbUser->email, $dbUser->getFullName()));
// Send email
$this->mailer->send($this->email);
// Reset locale to browser language
$this->localeConfigurator->setLanguage($originalLocale);
// User activity entry is done when a user verification token is created
return;
}
throw new DomainRecordNotFoundException('User not existing');
}
}
Documentation: Mailing.
Verification token creator
The createUserVerification
function creates a verification token and inserts the hash
into the database.
It returns the query params array which will be added to the password reset link.
File: src/Domain/Authentication/Service/VerificationTokenCreator.php
<?php
namespace App\Domain\Authentication\Service;
use App\Domain\Authentication\Repository\VerificationToken\VerificationTokenCreatorRepository;
use App\Domain\Authentication\Repository\VerificationToken\VerificationTokenDeleterRepository;
use App\Domain\User\Enum\UserActivity;
use App\Domain\UserActivity\Service\UserActivityLogger;
final readonly class VerificationTokenCreator
{
public function __construct(
private VerificationTokenDeleterRepository $verificationTokenDeleterRepository,
private VerificationTokenCreatorRepository $verificationTokenCreatorRepository,
private UserActivityLogger $userActivityLogger,
) {
}
public function createUserVerification(int $userId, array $queryParams = []): array
{
// Create token
$token = bin2hex(random_bytes(50));
// Set token expiration datetime
$expiresAt = new \DateTime('now');
$expiresAt->add(new \DateInterval('PT02H')); // 2 hours
// Delete any existing tokens for this user
$this->verificationTokenDeleterRepository->deleteVerificationToken($userId);
// Insert verification token into database
$userVerificationRow = [
'user_id' => $userId,
'token' => password_hash($token, PASSWORD_DEFAULT),
// expiresAt format 'U' is the same as time() so it can be used later to compare easily
'expires_at' => $expiresAt->format('U'),
];
$tokenId = $this->verificationTokenCreatorRepository->insertUserVerification($userVerificationRow);
// Add relevant query params to $queryParams array
$queryParams['token'] = $token;
$queryParams['id'] = $tokenId;
return $queryParams;
}
}
Reset password
Password reset form
When the user clicks on the password reset link in the email, the GET request is routed to the
PasswordResetPageAction.php
which renders the password reset form and adds
the token and user id to the form's hidden fields so that they're submitted with the form.
File: src/Application/Action/Authentication/Page/PasswordResetPageAction.php
<?php
namespace App\Application\Action\Authentication\Page;
use App\Application\Responder\TemplateRenderer;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
final readonly class PasswordResetPageAction
{
public function __construct(
private TemplateRenderer $templateRenderer,
private LoggerInterface $logger,
) {
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$queryParams = $request->getQueryParams();
// There may be other query params, e.g. redirect
if (isset($queryParams['id'], $queryParams['token'])) {
return $this->templateRenderer->render($response, 'authentication/reset-password.html.php', [
'token' => $queryParams['token'],
'id' => $queryParams['id'],
]);
}
$this->logger->error(
'GET request malformed: ' . json_encode($queryParams, JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR)
);
// If the user clicks on the link and the token is missing, load page with 400 Bad Request
$response = $response->withStatus(400);
return $this->templateRenderer->render($response, 'authentication/reset-password.html.php', [
'formErrorMessage' => __('Token not found. Please click on the link you received via email.'),
]);
}
}
Password reset submit action
When the user submits the new password, NewPasswordResetSubmitAction
handles the POST request
and calls the service function resetPasswordWithToken
.
File: src/Application/Action/Authentication/Ajax/NewPasswordResetSubmitAction.php
<?php
namespace App\Application\Action\Authentication\Ajax;
use App\Application\Responder\RedirectHandler;
use App\Domain\Authentication\Exception\InvalidTokenException;
use App\Domain\Authentication\Service\PasswordResetterWithToken;
use App\Domain\Validation\ValidationException;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final readonly class NewPasswordResetSubmitAction
{
public function __construct(
private RedirectHandler $redirectHandler,
private SessionInterface $session,
private PasswordResetterWithToken $passwordResetterWithToken,
) {
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$parsedBody = (array)$request->getParsedBody();
$flash = $this->session->getFlash();
try {
$this->passwordResetterWithToken->resetPasswordWithToken($parsedBody);
$flash->add(
'success', __('Successfully changed password. <b>%s</b>', __('Please log in.'))
);
return $this->redirectHandler->redirectToRouteName($response, 'login-page');
} catch (InvalidTokenException $ite) {
// Render login page with expired, used or invalid token error message
}
catch (ValidationException $validationException) {
// Render reset-password form with token, and id so that it can be submitted again
}
}
}
Token verification and password reset
The resetPasswordWithToken
function coordinates the token verification, and it's valid, resets the password.
File: src/Domain/Authentication/Service/PasswordResetterWithToken.php
<?php
namespace App\Domain\Authentication\Service;
use App\Domain\User\Repository\StatsUpdaterRepository;
use App\Domain\User\Service\UserValidator;
use App\Domain\UserActivity\Service\UserActivityLogger;
use Psr\Log\LoggerInterface;
final readonly class PasswordResetterWithToken
{
public function __construct(
private StatsUpdaterRepository $userUpdaterRepository,
private UserValidator $userValidator,
private VerificationTokenVerifier $verificationTokenVerifier,
private LoggerInterface $logger,
) {
}
public function resetPasswordWithToken(array $passwordResetValues): bool
{
// Validate passwords BEFORE token verification as it would be set to usedAt even if passwords are not valid
$this->userValidator->validatePasswordReset($passwordResetValues);
// If passwords are valid strings, verify token and set token to used
$userId = $this->verificationTokenVerifier->verifyTokenAndGetUserId(
$passwordResetValues['id'],
$passwordResetValues['token']
);
// Intentionally NOT logging user in so that he has to confirm the correctness of his credential
$passwordHash = password_hash($passwordResetValues['password'], PASSWORD_DEFAULT);
$updated = $this->userUpdaterRepository->changeUserPassword($passwordHash, $userId);
if ($updated) {
$this->logger->info(sprintf('Password was reset for user %s', $userId));
return true;
}
$this->logger->info(sprintf('Password reset failed for user %s', $userId));
return false;
}
}
Token verifier
The verifyTokenAndGetUserId
function verifies the token and returns the user id if the token is valid.
Token verification includes checking if the token exists, is correct, not expired, and not already used.
File: src/Domain/Authentication/Service/VerificationTokenVerifier.php
<?php
namespace App\Domain\Authentication\Service;
use App\Domain\Authentication\Exception\InvalidTokenException;
use App\Domain\Authentication\Repository\VerificationToken\VerificationTokenFinderRepository;
final readonly class VerificationTokenVerifier
{
public function __construct(
private VerificationTokenFinderRepository $verificationTokenFinderRepository,
private VerificationTokenUpdater $verificationTokenUpdater,
) {
}
public function verifyTokenAndGetUserId(int $verificationId, string $token): int
{
$verification = $this->verificationTokenFinderRepository->findUserVerification($verificationId);
// Verify given token with token in database
if (
($verification->token !== null) && $verification->usedAt === null && $verification->expiresAt > time()
&& true === password_verify($token, $verification->token)
) {
// Mark token as being used if it was correct and not expired
$this->verificationTokenUpdater->setVerificationEntryToUsed($verificationId, $verification->userId);
return $this->verificationTokenFinderRepository->getUserIdFromVerification($verificationId);
}
$invalidTokenException = new InvalidTokenException('Not existing, invalid, used or expired token.');
// Add user details to invalid token exception
$invalidTokenException->userData = $this->verificationTokenFinderRepository
->findUserDetailsByVerificationIncludingDeleted($verificationId);
throw $invalidTokenException;
}
}