Double authentification avec Symfony

Qu’est-ce que la double authentification ?

Tout le monde connaît l’authentification avec un identifiant et un mot de passe. Cependant, ce système est faillible pour plusieurs raisons :

  • Ils sont souvent les mêmes pour plusieurs sites.
  • Ils sont souvent mal stockés
  • Ils sont souvent trop faciles à trouver
  • Pour les mots de passe forts, les retenir tous est compliqué.

Ainsi, pour augmenter la sécurité lors de l’authentification, il est de plus en plus souvent demandé une seconde preuve que vous êtes bien la personne que vous prétendez être.

C’est la double authentification.

Quand la mettre en place ?

En trois mots : tout le temps. Il y a toujours un usage pirate possible pour toutes les pages protégées par une authentification.
Certains trouveront la réponse extrême. Cependant, pourquoi protéger par un mot de passe quelque chose qui n’a aucune valeur ?

Quels sont les différents types de double authentification ?

Beaucoup connaissent la double authentification avec l’envoi d’un message texte sur votre téléphone portable. Il en existe d’autres :

  • L’utilisation d’une clé physique (USB ou sans fil).
  • L’utilisation d’un code temporaire basé sur le temps.
  • L’utilisation d’un code temporaire envoyé à l’utilisateur selon un moyen que l’utilisateur a choisi (message texte, courriel).
  • L’utilisation d’une authentification via une application mobile liée au service en ligne.

Comment implémenter une double authentification en deux étapes avec Symfony ?

Avant de commencer, nous allons mettre en place une double authentification par l’envoi d’un code temporaire par courriel.
Voici le schéma du dialogue entre le navigateur et les serveurs.

Dialogue Double Authentification

La première authentification via un formulaire est déjà implémentée par Symfony. Cependant, il est nécessaire de personnaliser la vérification du mot de passe via le module Guard.

Dans notre Guard, nous allons stopper l’authentification si le mot de passe est correct et rediriger l’utilisateur vers la page de saisie du second facteur.

Avant la redirection, il est nécessaire de sauvegarder le nom de l’utilisateur en cours de connexion et envoyer un code temporaire sur la boîte courriel de l’utilisateur.

Une fois saisi par l’utilisateur, un autre Guard se chargera de vérifier le code saisi et valider la connexion effective de l’utilisateur.

Simple, non ?

Faisons les courses

Pour mettre en place correctement la double authentification, nous avons besoin de :

  • Sauvegarder le nom de l’utilisateur en cours de connexion.
  • Générer un code et l’associer à l’utilisateur en cours de connexion.
  • Définir un délai de réponse et sauvegarder la valeur limite.
  • Vérifier que l’utilisateur ne teste pas trop de codes (brute force).

Si j’oublie une mesure de sécurité, dites-le moi dans les commentaires.

Ajoutons notre second Guard

La classe AppTwoFactorAuthenticator

<?php declare(strict_types=1);

namespace AppSecurity;

use AppEntityUser;
use DoctrineORMEntityManagerInterface;
use SymfonyComponentHttpFoundationRedirectResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentHttpFoundationSessionSessionInterface;
use SymfonyComponentRoutingGeneratorUrlGeneratorInterface;
use SymfonyComponentSecurityCoreAuthenticationTokenTokenInterface;
use SymfonyComponentSecurityCoreExceptionCustomUserMessageAuthenticationException;
use SymfonyComponentSecurityCoreExceptionInvalidCsrfTokenException;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSecurityCoreUserUserProviderInterface;
use SymfonyComponentSecurityCsrfCsrfToken;
use SymfonyComponentSecurityCsrfCsrfTokenManagerInterface;
use SymfonyComponentSecurityGuardAuthenticatorAbstractFormLoginAuthenticator;
use SymfonyComponentSecurityHttpUtilTargetPathTrait;

final class AppTwoFactorAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_two_factor';

    public const USER_SESSION_KEY = 'two_auth_user';
    public const CODE_SESSION_KEY = 'two_auth_code';
    public const TIMEOUT_SESSION_KEY = 'two_auth_timeout';
    public const COUNT_SESSION_KEY = 'two_auth_count';

    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    /**
     * @var SessionInterface
     */
    private $session;

    public function __construct(
        EntityManagerInterface $entityManager,
        UrlGeneratorInterface $urlGenerator,
        CsrfTokenManagerInterface $csrfTokenManager,
        SessionInterface $session
    ) {
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->session = $session;
    }

    public function supports(Request $request)
    {
        return self::LOGIN_ROUTE === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        return [
            'email' => $request->getSession()->get(self::USER_SESSION_KEY),
            'count' => $request->getSession()->get(self::COUNT_SESSION_KEY, 1),
            'timeout' => $request->getSession()->get(self::TIMEOUT_SESSION_KEY),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        if (time() > $credentials['timeout']) {
            throw new TwoFactorTimedoutException();
        }

        if ($credentials['count'] >= 3) {
            throw new TwoFactorMaxAttemptReachedException();
        }

        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($credentials['password'] === $this->session->get(self::CODE_SESSION_KEY, null)) {
            return true;
        }

        $this->session->set(self::COUNT_SESSION_KEY, $credentials['count'] + 1);

        return false;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $request->getSession()->remove('need_auth_two');
        $request->getSession()->remove(self::USER_SESSION_KEY);
        $request->getSession()->remove(self::CODE_SESSION_KEY);
        $request->getSession()->remove(self::TIMEOUT_SESSION_KEY);
        $request->getSession()->remove(self::COUNT_SESSION_KEY);

        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse('/');
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

La constante LOGIN_ROUTE contient le nom de la route dédiée à ce Guard. Le contrôleur doit être défini, nous le verrons plus bas.

Cette classe définit 4 constantes qui permettront de stocker les informations utiles dans la session. Vous pouvez tout à fait sauvegarder ces informations en base de données.

Activer le second Guard

Dans le fichier config/packages/security.yaml, ajouter le nom complet de la classe dans la liste des authenticators :

#[...]
    firewalls:
        main:
            guard:
                authenticators:
                    - AppSecurityAppLoginAuthenticator
                    - AppSecurityAppTwoFactorAuthenticator
                entry_point: AppSecurityAppLoginAuthenticator

Maintenant, Symfony a besoin de connaître le Guard principal. Ajouter la clé entry_point avec comme valeur le nom complet de la classe du Guard gérant la connexion par identifiant et mot de passe.

Ajouter le contrôleur

Maintenant, ajouter le contrôleur lié au Guard du second facteur. Ce contrôleur se comporte comme celui du Guard de l’authentification par identifiant et mot de passe avec une différence. Il n’est pas possible de modifier l’utilisateur.

Voici un exemple de la méthode contrôleur :

//Fichier : src/Controller/SecurityController.php

    /**
     * @Route("/two_factor", name="app_two_factor")
     */
    public function twoFactor(SessionInterface $session, CodeGeneratorInterface $codeGenerator, AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        if ($session->get(AppTwoFactorAuthenticator::CODE_SESSION_KEY) === null) {
            $error = null;
            $session->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, $codeGenerator->generate());
            $session->set(AppTwoFactorAuthenticator::TIMEOUT_SESSION_KEY, time() + (60 * 5));
            $session->set(AppTwoFactorAuthenticator::COUNT_SESSION_KEY, 1);
            //Send here the code by email.
        }
        return $this->render('security/two_factor.html.twig', ['error' => $error]);
    }

Ici, nous retrouvons le nom de la route définie dans le Guard pour la double authentification.

Le rôle du contrôleur est d’afficher les erreurs de saisie du code et si le code n’existe pas, il l’initialise et l’envoie par courriel.

Le fichier Twig lié ressemble à ceci :

{% extends 'base.html.twig' %}

{% block title %}
Two factor auth
{% endblock %}

{% block body %}
    <form method="post">
        {% if error %}
            <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
        {% endif %}

        <h1 class="h3 mb-3 font-weight-normal">Please confirm your identity</h1>
        <label for="inputPassword">Password</label>
        <input type="password" name="password" id="inputPassword" class="form-control" required>

        <input type="hidden" name="_csrf_token"
               value="{{ csrf_token('authenticate') }}"
        >
        <button class="btn btn-lg btn-primary" type="submit">
            Send code
        </button>
    </form>
{% endblock %}

Activer la double authentification

La seconde authentification est presque prête. Cependant, il n’est pas possible de l’utiliser. Pour l’utiliser, il est nécessaire de modifier la classe Guard liée à l’authentification par utilisateur et mot de passe.

La première modification consiste à indiquer que l’authentification a échoué dans tous les cas. Nous allons cependant indiquer qu’il faut rediriger vers le second facteur d’authentification grâce à une information stockée en session.

Voici le code de la méthode liée à la vérification du mot de passe :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function checkCredentials($credentials, UserInterface $user)
    {
        if ($this->encoder->getEncoder($user)->isPasswordValid($user->getPassword(), $credentials['password'], null)) {
            $this->session->set('need_auth_two', true);
            return false;
        }
        return false;
    }

Maintenant que l’authentification échoue systématiquement, comment renvoyer l’utilisateur ayant saisi son mot de passe correct vers la double authentification ?

Cela se passe dans la fonction getLoginUrl du Guard

// Fichier : src/Security/AppLoginAuthenticator.php
    protected function getLoginUrl()
    {
        if ($this->session->get('need_auth_two', false) === true) {
            return $this->urlGenerator->generate(AppTwoFactorAuthenticator::LOGIN_ROUTE);
        }
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }

Dans le cas où la session contient la clé need_auth_two et que sa valeur est à vrai, alors l’URL retournée est celle correspondant à la page de l’authentification à deux facteurs.

Éviter la réutilisation du code ou la mauvaise redirection

Avec le code tel qu’il est actuellement, il est possible d’être redirigé vers la page de la double authentification alors que le mot de passe est erroné.

Il est également possible de réutiliser un code déjà utilisé si la session n’est pas complètement renouvelée lors de la déconnexion.

Pour éviter ces mauvais effets, nous allons réinitialiser le code et la demande de double authentification lors de la récupération de l’identifiant et du mot de passe saisis par l’utilisateur.

Voici donc le contenu de la méthode getCredentials du Guard AppLoginAuthenticator :

// Fichier : src/Security/AppLoginAuthenticator.php
    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );
        $request->getSession()->set(
            AppTwoFactorAuthenticator::USER_SESSION_KEY,
            $credentials['email']
        );

        $request->getSession()->set(AppTwoFactorAuthenticator::CODE_SESSION_KEY, null);
        $request->getSession()->set('need_auth_two', false);

        return $credentials;
    }

C’est également ici que nous sauvegardons le nom d’utilisateur saisi pour être utilisé lors de la double authentification.

Conclusion

La double authentification est maintenant prête à minima. Il reste encore quelques améliorations telles que :

  • Utiliser un événement pour déclencher la génération du code et son envoi.
  • Permettre le changement d’utilisateur lors de la double authentification.

Mais cela fera peut-être l’objet d’un autre billet de blog.

Dites-moi dans les commentaires comment vous avez implémenté la double authentification sur vos projets. Je suis à votre service si vous avez des questions.

PS J’ai utilisé ce projet pour écrire ce billet de blog.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.