Skip to content

Authentication

Samuel Gfeller edited this page Dec 11, 2023 · 11 revisions

This application is designed to contain private 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 use-case this application is made for doesn't require registration. The users are created by an administrator and receive an email with a link to activate their account.

Protect routes

Not strictly every route needs to be protected. For instance, the login page should be accessible without authentication otherwise the user wouldn't even be able to log in. The same goes for the password-forgotten page.

Routes that need authentication 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');
    
    // Protected group
    $app->group('/users', function (RouteCollectorProxy $group) {
        // Users routes
    })->add(UserAuthenticationMiddleware::class);
    
    // Protected route
    $app->get('/clients/list', ClientListPageAction::class)
    ->setName('client-list-page')
    ->add(UserAuthenticationMiddleware::class);
};

Authentication middleware

When added to a route or route group, the authentication middleware is responsable for checking if the user is authenticated and if not, redirecting 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 class UserAuthenticationMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly SessionManagerInterface $sessionManager,
        private readonly SessionInterface $session,
        private readonly JsonResponder $jsonResponder,
        private readonly RedirectHandler $redirectHandler,
        private readonly RouteParserInterface $routeParser,
        private readonly ResponseFactoryInterface $responseFactory,
        private readonly 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.');

        $queryParams = [];
        // Handling Redirect-to-route-if-unauthorized header for JSON requests redirect query params
        // ...

        // 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', [], $queryParams)],
                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);
    }
}

Login

Login action

Anyone that wants to access the application must go through the login page.
When the login form is submitted with credentials, the POST request is routed to the LoginSubmitAction class.

This class is responsible for forwarding the login request to the appropriate service which verifies if the credentials are correct and if they are, creating the session for the user.

File src/Application/Action/LoginSubmitAction.php

use Odan\Session\SessionInterface;
use App\Domain\Authentication\Service\LoginVerifier;
use Odan\Session\SessionManagerInterface;

final class LoginSubmitAction
{
    public function __construct(
        private readonly SessionManagerInterface $sessionManager, 
        private readonly SessionInterface $session,
        private readonly LoginVerifier $loginVerifier,        
        private readonly RedirectHandler $redirectHandler,
    ) {
    }

    public function __invoke(Request $request, Response $response): Response
    {
          $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', []);
          } // When the response is not JSON but rendered, the validation exception has to be caught in action
          catch (ValidationException $ve) {
              // Render with 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
          }
    }
}

Login service

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

Firstly, the submitted values are validated in the UserValidator.php::validateUserLogin()

Then, the login security checker verifies if the login request is allowed 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, which is the sign 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.

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;

class LoginVerifier
{
    public function __construct(
        private readonly UserValidator $userValidator,
        private readonly SecurityLoginChecker $loginSecurityChecker,
        private readonly UserFinderRepository $userFinderRepository,
        private readonly AuthenticationLoggerRepository $authenticationLoggerRepository,
        private readonly LoginNonActiveUserHandler $loginNonActiveUserHandler,
    ) {
    }

    /**
     * Verifies the user's login credentials and returns the user's 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 user exists and verify 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 - insert login request
        $this->authenticationLoggerRepository->logLoginRequest(
                $userLoginValues['email'], $this->userNetworkSessionData->ipAddress, false, $dbUser->id
        );

        // Perform second login 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.php class which forwards the request to the service class.

File: src/Application/Action/Authentication/Ajax/PasswordForgottenEmailSubmitAction.php

public function __invoke(ServerRequest $request, Response $response): Response
{
    $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.php 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\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;

class PasswordRecoveryEmailSender
{
    private Email $email;

    public function __construct(
        private readonly Mailer $mailer,
        private readonly UserValidator $userValidator,
        private readonly UserFinderRepository $userFinderRepository,
        private readonly VerificationTokenCreator $verificationTokenCreator,
        Settings $settings,
        private readonly SecurityEmailChecker $securityEmailChecker,
        private readonly 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);
    }

    /**
     * When a user requests a new password for email.
     *
     * @param array $userValues
     * @param string|null $captcha
     *
     * @throws ValidationException|TransportExceptionInterface
     */
    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');
    }
}

Verification token creator

The createUserVerification() function creates a verification token and inserts the hash into the database.
It returns the query params that should be added to the password reset link.

File: src/Domain/Authentication/Service/VerificationTokenCreator.php

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

Password reset

Reset password form

When the user clicks on the password reset link in the email, the GET request is routed to the PasswordResetPageAction.php class which renders the password reset form and adds the token and user id to the form's hidden fields.

File: src/Application/Action/Authentication/Page/PasswordResetPageAction.php

public function __invoke(ServerRequest $request, Response $response): Response
{
    $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'],
        ]);
    }
    // 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.'),
    ]);
}

Submit new password action

When the user submits the new password, NewPasswordResetSubmitAction.php handles the POST request. It calls the service function resetPasswordWithToken().

File: src/Application/Action/Authentication/Ajax/NewPasswordResetSubmitAction.php

public function __invoke(ServerRequest $request, Response $response): Response
{
    $parsedBody = (array)$request->getParsedBody();
    $flash = $this->session->getFlash();
    try {
        $this->passwordResetterWithToken->resetPasswordWithToken($parsedBody);
        $flash->add(
            'success', sprintf(__('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
    }
}

Verify token and reset password

The resetPasswordWithToken() function coordinates the token verification, and it's valid, resets the password.

File: src/Domain/Authentication/Service/PasswordResetterWithToken.php

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 verification

The verifyTokenAndGetUserId() function verifies the token and returns the user id if the token is valid. It checks if the token exists, is correct, not expired, and not already used.

File: src/Domain/Authentication/Service/VerificationTokenVerifier.php

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