Skip to content

Authentication

samuelgfeller edited this page Mar 30, 2024 · 11 revisions

Introduction

This application is designed to manage information that must be accessible only by authorized users.

The pages are protected by authentication which requires the user to verify their identity via credentials (email and password).

The initial use-case this application was made for doesn't require registration. The users are created by an administrator and then receive an email containing an activation link.

Protected routes

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.

Routes that require users to be authenticated can be protected by adding UserAuthenticationMiddleware to the route or route group definition.

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

Related documentation: Routing.

Authentication middleware

When added to a route or route group, the authentication middleware is responsable 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->respondWithJson(
                $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);
    }
}

Related documentation: Middleware.

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/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
          }
    }
}

Related documentation: Action.

Login service

The login service coordinates the different components that are involved in the login process.

Firstly, the submitted values are validated with the UserValidator class.

Then, the login security checker verifies if the user is allowed to make a login request as protection against brute force attacks.

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 treated 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\UserFinderRepository;
use App\Domain\User\Service\UserValidator;

final readonly class LoginVerifier
{
    public function __construct(
        private UserValidator $userValidator,
        private SecurityLoginChecker $loginSecurityChecker,
        private UserFinderRepository $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
        $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
                $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
            $this->loginNonActiveUserHandler->handleLoginAttemptFromNonActiveUser($dbUser, $queryParams, $captcha);
        }
        // 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']);
    }
}

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\UserFinderRepository;
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 UserFinderRepository $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');
    }
}

Related 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's 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\UserUpdaterRepository;
use App\Domain\User\Service\UserValidator;
use App\Domain\UserActivity\Service\UserActivityLogger;
use Psr\Log\LoggerInterface;

final readonly class PasswordResetterWithToken
{
    public function __construct(
        private UserUpdaterRepository $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;
    }
}
Clone this wiki locally