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

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

--

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énérer 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
{
const QR_CODE_KEY = '_qr_code_secret';

/**
* @Route("/login", name="app_login")
*/
public function login(AuthenticationUtils $authenticationUtils): Response
{
// if ($this->getUser()) {
// return $this->redirectToRoute('target_path');
// }

// 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("/authentification", name="app_security_authentification")
*/
public function index(SessionInterface $session)
{
$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)',
'hello@silarhi.fr',
$secretKey
);

$writer = new Writer(
new ImageRenderer(
new RendererStyle(400),
new ImagickImageBackEnd()
)
);

$qrCodeImage = base64_encode($writer->writeString($qrCodeUrl));

return $this->render('security/qrCode.html.twig', [
'qrCodeImage' => $qrCodeImage,
]);
}

/**
* @Route("/valide-authentification", name="app_security_validate_authentification")
*/
public function valideAuthentification(AuthenticationUtils $authenticationUtils)
{
$error = $authenticationUtils->getLastAuthenticationError();

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

/**
* @Route("/2FA-protected", name="app_security_authentification_protected")
*/
public function authentificationProtected()
{
return $this->render('security/protected.html.twig');
}
}

Ici, dans la méthode index, on stocke la génération de la clé secrète dans une session, car nous en aurons besoin dans l'authenticator. Suite à cela, on récupère l'image du QR Code, que l'on donne en paramètre à la vue twig pour l'afficher.

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.html#where-do-users-come-from-user-providers
providers:
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:
anonymous: true
lazy: true
provider: in_memory
guard:
entry_point: App\Security\LoginFormAuthenticator
authenticators:
- App\Security\LoginFormAuthenticator
- App\Security\QRCodeAuthenticator

# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#firewalls-authentication

# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true

# 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: ^/authentification, roles: ROLE_USER }
- { path: ^/valide-authentification, roles: ROLE_USER }
# - { path: ^/profile, 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 QRCodeAuthenticator que nous allons créer tout de suite.

<?php

//src/Security/QRCodeAuthenticator.php

namespace App\Security;

use App\Controller\SecurityController;
use App\EventSubscriber\DoubleAuthentificationSuscriber;
use PragmaRX\Google2FA\Google2FA;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
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\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class QRCodeAuthenticator extends AbstractFormLoginAuthenticator
{
use TargetPathTrait;

public const LOGIN_ROUTE = 'app_security_validate_authentification';

/** @var UrlGeneratorInterface */
private $urlGenerator;

/** @var CsrfTokenManagerInterface */
private $csrfTokenManager;

/** @var TokenStorageInterface */
private $tokenStorage;

public function __construct(TokenStorageInterface $tokenStorage, UrlGeneratorInterface $urlGenerator)
{
$this->tokenStorage = $tokenStorage;
$this->urlGenerator = $urlGenerator;
}

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

public function getCredentials(Request $request)
{
return [
'qrCode' => $request->request->get('qrCode'),
'secretKey' => $request->getSession()->get(SecurityController::QR_CODE_KEY),
];
}

public function getUser($credentials, UserProviderInterface $userProvider)
{
//Get user from login form
$existingToken = $this->tokenStorage->getToken();
if (null === $existingToken) {
return null;
}

return $existingToken->getUser();
}

public function checkCredentials($credentials, UserInterface $user)
{
$qrCode = $credentials['qrCode'];

if (!$user) {
return false;
}

$google2fa = new Google2FA();
$google2fa->setSecret($credentials['secretKey']);

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

return true;
}

public function createAuthenticatedToken(UserInterface $user, string $providerKey)
{
$currentToken = parent::createAuthenticatedToken($user, $providerKey);

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

return new PostAuthenticationGuardToken(
$currentToken->getUser(),
$currentToken->getProviderKey(),
$roles
);
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
return new RedirectResponse($targetPath);
}

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

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

Dans cette classe, nous récupérons les données postées du formulaire (donc le code de validation), dans la méthode checkCredentials, 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\Guard\Token\PostAuthenticationGuardToken;

class DoubleAuthentificationSuscriber implements EventSubscriberInterface
{
const ROLE_2FA_SUCCEED = '2FA_SUCCEED';
const FIREWALL_NAME = 'main';

/** @var RouterInterface */
private $router;

/** @var TokenStorageInterface */
private $tokenStorage;

public function __construct(RouterInterface $router, TokenStorageInterface $tokenStorage)
{
$this->router = $router;
$this->tokenStorage = $tokenStorage;
}

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

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

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

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

return;
}

if (!$currentToken->isAuthenticated() || self::FIREWALL_NAME !== $currentToken->getProviderKey()) {
return;
}

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

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

private function hasRole(TokenInterface $token, string $role): bool
{
foreach ($token->getRoleNames() as $userRole) {
if ($userRole === $role) {
return true;
}
}

return false;
}
}

Cet EventSubscriber s’exé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 intéresse, 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

--

--

Guillaume

Développeur Web depuis 2011, j’adore construire des applications Web. J’ai fondé Silarhi en 2018 pour aider mes clients à développer leurs activités digitales.