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;
    }
}
^