L'authentification à double facteur avec Symfony (2FA)

Ajouter une authentification à double facteur avec Symfony n'a jamais été aussi simple !

L'authentification à double facteur avec Symfony (2FA)

L’authentification à double facteur permet d’ajouter une sécurité supplémentaire à votre système de connexion. En plus d’un mot de passe, vous devrez fournir un code de vérification généré via une app de type Authenticator installée sur votre smartphone. On reviendra sur les termes et détails plus tard dans l'article.

Démo

Pour illustrer mes propos, voici le type de résultat que l'on peut obtenir grâce à cette librairie.

GIF de démo de l'authentification à double facteur
La magnifique démo de cet article : 👉 https://labs.silarhi.fr/login

Installation

Pour commencer, il nous faut installer deux librairies. Dans un premier temps, pour la gestion du 2FA, nous allons utiliser Google2FA et pour la génération du QR Code pendant le setup, nous utiliserons BaconQrCode. Pour cela, nous allons utiliser composer :

composer require pragmarx/google2fa bacon/bacon-qr-code

Mise en place

Afin d'utiliser ces librairies, nous allons créer le controller SecurityController. Dans ce controller, nous retrouvons la méthode qui permet l'authentification sur votre site, c'est Symfony qui se charge de vous le donner à l'aide de la commande bin/console make:auth.

Allons plus loin et ajoutons 2 méthodes :

  1. La première qui va génerer et afficher le QR Code.
  2. La seconde qui va faire appel à un Authenticator afin de vérifier le code soumis.

Pour scanner le QR Code vous aurez besoin d'une application mobile. Plusieurs sont disponibles, comme Authy, Google Authenticator, Microsoft Authenticator, etc. Toutes ces applications implémentent le même protocole et utilisent un QR code pour la mise en place du 2FA pour vote site.

Controller

<?php

//src/Controller/SecurityController.php

namespace App\Controller;

use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PragmaRX\Google2FA\Google2FA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
    public const QR_CODE_KEY = '_qr_code_secret';

    #[Route(path: '/login', name: 'app_login')]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
    }

    #[Route(path: '/setup-2FA', name: 'app_security_setup_fa')]
    public function setup(SessionInterface $session, AuthenticationUtils $authenticationUtils)
    {
        $error = $authenticationUtils->getLastAuthenticationError();

        $google2fa = new Google2FA();
        if (!$session->has(self::QR_CODE_KEY)) {
            $secretKey = $google2fa->generateSecretKey();
            $session->set(self::QR_CODE_KEY, $secretKey);
        } else {
            $secretKey = $session->get(self::QR_CODE_KEY);
        }
        //Generate QR CODE based on secretKey
        $qrCodeUrl = $google2fa->getQRCodeUrl(
            '2FA DEMO (Silarhi)',
            '[email protected]',
            $secretKey
        );
        $writer = new Writer(
            new ImageRenderer(
                new RendererStyle(400),
                new ImagickImageBackEnd()
            )
        );
        $qrCodeImage = base64_encode($writer->writeString($qrCodeUrl));

        return $this->render('security/setup-2fa.html.twig', [
            'qrCodeImage' => $qrCodeImage,
            'secretKey' => $secretKey,
            'error' => $error,
        ]);
    }

    #[Route(path: '/2FA-protected', name: 'app_security_authentification_protected')]
    public function authentificationProtected()
    {
        return $this->render('security/protected.html.twig');
    }
}

Ici, dans la méthode setup, on stocke la génération d'une clé secrète dans la session utilisateur, car nous en aurons besoin dans l'Authenticator Symfony.
Suite à cela, on génère l'image du QR Code associé, que l'on donne en paramètre à la vue twig pour l'afficher. On prévoit ensuite de créer un input pour venir vérifier le code qui sera généré par l'application d'authentification choisie par l'utilisateur. On délègue ensuite à la classe TwoFactorsAuthenticator la responsabilité de vérifier le code qui vient d'être saisi !

security.yaml

Nous allons principalement ajouter 2 authenticators : 1 pour gérer l'authentification de base (formulaire de login classique), l'autre qui s'activera lorsque l'utilisateur aura saisi le code du QR code.

# config/packages/security.yaml

security:
    # https://symfony.com/doc/current/security/experimental_authenticators.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        users_in_memory:
            memory:
                users:
                    test: { password: 'test', roles: [ 'ROLE_USER' ] }
    encoders:
        # this internal class is used by Symfony to represent in-memory users
        Symfony\Component\Security\Core\User\User: 'plaintext'

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js|assets)/
            security: false
        main:
            lazy: true
            provider: users_in_memory
            logout:
                path: app_logout
            entry_point: App\Security\LoginFormAuthenticator
            custom_authenticator:
                - App\Security\LoginFormAuthenticator
                - App\Security\TwoFactorsAuthenticator

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        - { path: ^/setup-2FA, roles: ROLE_USER }

L'authenticator

Afin de vérifier si l'utilisateur a rentré le code de vérification, nous utilisons la méthode valideAuthentification, la route de cette méthode va être ajouté dans la constante LOGIN_ROUTE de la classe TwoFactorsAuthenticator que nous allons créer tout de suite.

<?php

//src/Security/TwoFactorsAuthenticator.php

namespace App\Security;

use App\Controller\SecurityController;
use App\EventSubscriber\DoubleAuthentificationSubscriber;
use PragmaRX\Google2FA\Google2FA;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\NullToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class TwoFactorsAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_security_setup_fa';

    public function __construct(private TokenStorageInterface $tokenStorage, private UrlGeneratorInterface $urlGenerator)
    {
    }

    public function supports(Request $request): bool
    {
        return parent::supports($request)
            && $request->getSession()->has(SecurityController::QR_CODE_KEY);
    }

    public function authenticate(Request $request): Passport
    {
        //Get user from login form
        $existingToken = $this->tokenStorage->getToken();
        if (null === $existingToken || $existingToken instanceof NullToken) {
            throw new UserNotFoundException();
        }

        $user = $existingToken->getUser();
        $qrCode = $request->request->get('qrCode', '');
        $secretKey = $request->getSession()->get(SecurityController::QR_CODE_KEY);

        $google2fa = new Google2FA();
        $google2fa->setSecret($secretKey);

        if (true !== $google2fa->verifyKey($google2fa->getSecret(), $qrCode)) {
            throw new CustomUserMessageAuthenticationException('This code is not valid');
        }

        $email = $user->getUserIdentifier();

        return new SelfValidatingPassport(
            new UserBadge($email)
        );
    }

    public function createToken(Passport $passport, string $firewallName): TokenInterface
    {
        $currentToken = parent::createToken($passport, $firewallName);

        $roles = array_merge($currentToken->getRoleNames(), [DoubleAuthentificationSubscriber::ROLE_2FA_SUCCEED]);

        return new PostAuthenticationToken(
            $currentToken->getUser(),
            $currentToken->getFirewallName(),
            $roles
        );
    }

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

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            $this->removeTargetPath($request->getSession(), $firewallName);

            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('app_security_authentification_protected'));
    }
}

Dans cette classe, nous récupérons les données postées du formulaire (donc le code de validation), dans la méthode authenticate, on récupère la clé secrète stockée en session pour vérifier si tout correspond. Si tel est le cas, alors on lui attribut le rôle de 2FA_SUCCEED qui appartient à la classe DoubleAuthentificationSubscriber

Le Subscriber

<?php

//src/EventSubscriber/DoubleAuthentificationSubscriber.php

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;

class DoubleAuthentificationSubscriber implements EventSubscriberInterface
{
    public const ROLE_2FA_SUCCEED = 'ROLE_2FA_SUCCEED';
    public const FIREWALL_NAME = 'main';

    public function __construct(private RouterInterface $router, private TokenStorageInterface $tokenStorage)
    {
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', -10],
        ];
    }

    public function onKernelRequest(RequestEvent $event)
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $route = $event->getRequest()->attributes->get('_route');
        if (!\in_array($route, ['app_security_authentification_protected'], true)) {
            return;
        }

        $currentToken = $this->tokenStorage->getToken();
        if (!$currentToken instanceof PostAuthenticationToken) {
            $response = new RedirectResponse($this->router->generate('app_login'));
            $event->setResponse($response);

            return;
        }

        if (null === $currentToken->getUser() || self::FIREWALL_NAME !== $currentToken->getFirewallName()) {
            return;
        }

        if ($this->hasRole($currentToken, self::ROLE_2FA_SUCCEED)) {
            return;
        }

        $response = new RedirectResponse($this->router->generate('app_security_setup_fa'));
        $event->setResponse($response);
    }

    private function hasRole(TokenInterface $token, string $role): bool
    {
        return \in_array($role, $token->getRoleNames(), true);
    }
}

Cet EventSubscriber s'éxécute à chaque fois qu'une page est chargée, il va venir vérifier plusieurs éléments, comme la route qui est demandée ou encore, ce qui nous interesse, si l'utilisateur a le rôle 2FA_SUCCEED.

L'ajout du rôle est important, il permet à l'utilisateur de ne pas contourner la page où l'on vérifie le code de vérification. Car si le rôle n'est pas attribué, alors il sera redirigé vers le formulaire qui permet d'entrer le code de vérification.

Pour aller plus loin