Skip to content

Authentication

Samuel Gfeller edited this page Dec 1, 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 a middleware 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 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']);
    }
}
Clone this wiki locally