La double authentification avec Symfony 8.2 : un authenticator, un voter, et c'est tout
Jusqu'ici, une double authentification dans Symfony, c'était soit un bundle, scheb/2fa-bundle, soit du code maison qui bricolait le pare-feu. Le bricolage le plus courant ressemble à ceci : après le mot de passe, l'utilisateur est connecté ; un second authenticator vérifie le code et ajoute un rôle comme ROLE_2FA_SUCCEED au jeton ; un subscriber redirige vers le formulaire du code les utilisateurs qui n'ont pas ce rôle.
Cela fonctionne, mais c'est fragile. Le subscriber ne protège que les routes qu'on lui donne, et toute page oubliée s'ouvre avec le seul mot de passe. Le rôle ajouté au jeton n'existe pas sur l'utilisateur : avec une entité qui n'implémente pas EquatableInterface, ce qui est le cas de celle que génère make:user, le ContextListener compare les rôles de l'utilisateur rechargé à ceux du jeton, les trouve différents, et vide le jeton à la requête suivante.
Symfony 8.2, attendu fin novembre, apporte de quoi faire beaucoup plus simple. Le jeton retient désormais comment l'utilisateur a prouvé son identité, et un voter peut demander au pare-feu une nouvelle preuve au lieu de répondre 403. Avec ces deux briques, une 2FA par SMS tient en un authenticator, un voter, un point d'entrée, le service qui envoie le code et quelques lignes de security.yaml. Le code de cet article tourne sur la version de développement de Symfony 8.2, avec des tests fonctionnels.
L'idée : deux preuves, pas un rôle
J'ai présenté ces nouveautés dans Être connecté ne suffit pas : redemander une preuve avant une action sensible. En bref : un authenticator déclare la méthode qu'il vérifie avec un AuthenticationMethodBadge (pwd, sms, otp…, les noms de la RFC 8176), et Symfony range la méthode et l'heure sur le jeton :
$token->getAuthenticationProofs(); // ['pwd' => 1793050000, 'sms' => 1793050042]
Ici, on s'en sert pour autre chose : non pas protéger une action, mais toute l'application, dès la connexion.
Le point important pour une 2FA : quand le même utilisateur s'authentifie une seconde fois dans la session, Symfony ajoute la nouvelle preuve à celles du jeton précédent au lieu de les remplacer. C'est exactement le parcours d'une double authentification : le mot de passe d'abord, le code ensuite, et un jeton qui porte les deux.
form_login et json_login déclarent déjà pwd. Il ne reste qu'à écrire l'authenticator du code et à dire au pare-feu que les pages protégées demandent les deux preuves.
La configuration
# config/packages/security.yaml security: firewalls: main: lazy: true provider: app_user_provider form_login: login_path: app_connexion check_path: app_connexion enable_csrf: true # Le second facteur : un authenticator de plus sur le même pare-feu. custom_authenticators: - App\Security\CodeSmsAuthenticator # Quand le voter réclame une preuve, ce point d'entrée choisit laquelle. re_authentication_entry_point: App\Security\DemanderLaPreuveManquante # Limite les essais du mot de passe et du code (composant symfony/rate-limiter). login_throttling: max_attempts: 5 interval: '15 minutes' logout: path: app_deconnexion access_control: - { path: ^/connexion, roles: PUBLIC_ACCESS } - { path: ^/deconnexion, roles: PUBLIC_ACCESS } - { path: ^/verification, roles: IS_AUTHENTICATED_FULLY } - { path: ^/, roles: DEUX_FACTEURS }
La dernière ligne fait tout le travail : toute l'application demande DEUX_FACTEURS, sauf la connexion, la déconnexion et le formulaire du code. La protection est fermée par défaut. Une page ajoutée demain est protégée sans qu'on y pense.
Le formulaire du code exige IS_AUTHENTICATED_FULLY, pas PUBLIC_ACCESS : il faut avoir donné son mot de passe dans cette session pour recevoir un code, un cookie « se souvenir de moi » ne suffit pas.
DEUX_FACTEURS n'est pas un rôle : c'est un attribut que décide un voter.
Le voter : les deux preuves, ou une nouvelle demande
// src/Security/Voter/DeuxFacteursVoter.php namespace App\Security\Voter; use App\Entity\Utilisateur; use Symfony\Component\Security\Core\Authentication\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; final class DeuxFacteursVoter extends Voter { public const ATTRIBUT = 'DEUX_FACTEURS'; protected function supports(string $attribute, mixed $subject): bool { return self::ATTRIBUT === $attribute; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { $preuves = $token->getAuthenticationProofs(); if (isset($preuves[AuthenticationMethod::PASSWORD], $preuves[AuthenticationMethod::SMS])) { return true; } // Une nouvelle preuve n'aide que qui peut la fournir : sans téléphone, c'est un 403. $utilisateur = $token->getUser(); if ($utilisateur instanceof Utilisateur && null !== $utilisateur->getTelephone()) { $vote?->requestReAuthentication($attribute); } return false; } }
$vote->requestReAuthentication() est l'autre nouveauté de 8.2. Le voter refuse, mais il signale au pare-feu qu'une nouvelle authentification pourrait changer sa réponse. Au lieu d'un 403, le pare-feu retient la page demandée et appelle son point d'entrée de réauthentification.
Le voter ne le fait que pour un utilisateur qui a un téléphone. Pour les autres, un nouveau formulaire ne mènerait nulle part : un 403 est plus honnête qu'une boucle. Dans une vraie application, c'est là qu'on renverrait vers l'enrôlement du second facteur.
Le point d'entrée : redemander ce qui manque
// src/Security/DemanderLaPreuveManquante.php namespace App\Security; 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\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface; final class DemanderLaPreuveManquante implements ReAuthenticationEntryPointInterface { public function __construct( private readonly UrlGeneratorInterface $urls, ) { } public function startReAuthentication(Request $request, TokenInterface $token): Response { // Pas de mot de passe (session rouverte par « se souvenir de moi ») : on commence par lui. $route = isset($token->getAuthenticationProofs()[AuthenticationMethod::PASSWORD]) ? 'app_verification' : 'app_connexion'; return new RedirectResponse($this->urls->generate($route)); } }
Sans re_authentication_entry_point, le pare-feu utiliserait le point d'entrée de form_login, qui sait lui aussi réauthentifier : il renverrait vers la page de connexion, et l'utilisateur redonnerait son mot de passe sans jamais voir le formulaire du code. Le nôtre regarde quelle preuve manque.
Le cas du cookie « se souvenir de moi » est réglé au passage : une session rouverte par ce cookie ne laisse aucune preuve, Symfony l'exclut explicitement. Le voter la refuse donc, et le point d'entrée renvoie d'abord vers le mot de passe.
L'authenticator du code
// src/Security/CodeSmsAuthenticator.php namespace App\Security; use Symfony\Bundle\SecurityBundle\Security; 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\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\AuthenticationMethodBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\SecurityRequestAttributes; use Symfony\Component\Security\Http\Util\TargetPathTrait; final class CodeSmsAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { use TargetPathTrait; public function __construct( private readonly Security $security, private readonly CodesSms $codes, private readonly UrlGeneratorInterface $urls, ) { } public function supports(Request $request): ?bool { return $request->isMethod('POST') && 'app_verification' === $request->attributes->get('_route'); } public function authenticate(Request $request): Passport { // Le code complète le mot de passe : il ne connecte personne à lui seul. $utilisateur = $this->security->getUser() ?? throw new CustomUserMessageAuthenticationException('Connectez-vous d\'abord avec votre mot de passe.'); return new Passport( new UserBadge($utilisateur->getUserIdentifier()), new CustomCredentials( fn (string $code): bool => $this->codes->verifier($code), $request->getPayload()->getString('code'), ), [ new CsrfTokenBadge('verification', $request->getPayload()->getString('_csrf_token')), new AuthenticationMethodBadge(AuthenticationMethod::SMS), ], ); } // Seule une authentification interactive laisse une preuve sur le jeton. public function isInteractive(): bool { return true; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { $cible = $this->getTargetPath($request->getSession(), $firewallName); $this->removeTargetPath($request->getSession(), $firewallName); return new RedirectResponse($cible ?? $this->urls->generate('app_compte')); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); return new RedirectResponse($this->urls->generate('app_verification')); } }
Trois détails comptent :
InteractiveAuthenticatorInterface. Sans elle, le badge est ignoré : Symfony ne retient que les preuves d'une authentification interactive, celle où l'utilisateur vient de faire un geste. C'est l'oubli le plus probable, et il ne lève aucune erreur : le code est accepté, mais la preuvesmsn'apparaît jamais, et l'utilisateur est renvoyé vers le formulaire en boucle.- L'utilisateur vient de la session. L'authenticator ne lit pas d'identifiant dans la requête : il reprend celui que le mot de passe a déjà authentifié. Un code seul ne connecte personne.
- Le passeport a un
UserBadge. C'est ce qui permet àlogin_throttlingde compter aussi les essais de code : au sixième essai en quinze minutes, même le bon code est refusé. Sans limite, un script finit par tomber sur le bon parmi le million de codes possibles.
Le code lui-même
Le service qui envoie et vérifie le code n'a rien de propre à Symfony 8.2. Il passe par le composant Notifier (symfony/notifier), avec le transport SMS de votre fournisseur. Le code est tiré au hasard, seul son hachage est gardé en session avec une échéance, et il sert une seule fois.
// src/Security/CodesSms.php namespace App\Security; use App\Entity\Utilisateur; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Notifier\Message\SmsMessage; use Symfony\Component\Notifier\TexterInterface; final class CodesSms { private const CLE = '_code_sms'; private const VALIDITE = 300; public function __construct( private readonly TexterInterface $texter, private readonly RequestStack $requetes, ) { } public function envoyer(Utilisateur $utilisateur): void { $code = sprintf('%06d', random_int(0, 999999)); // En session, seulement le hachage du code et son échéance. $this->requetes->getSession()->set(self::CLE, [ 'hachage' => hash('sha256', $code), 'expire' => time() + self::VALIDITE, ]); $this->texter->send(new SmsMessage($utilisateur->getTelephone(), "Votre code de connexion : $code")); } public function verifier(string $code): bool { $session = $this->requetes->getSession(); $attendu = $session->get(self::CLE); if (null === $attendu || $attendu['expire'] < time()) { return false; } if (!hash_equals($attendu['hachage'], hash('sha256', $code))) { return false; } $session->remove(self::CLE); // usage unique return true; } public function enAttente(): bool { $attendu = $this->requetes->getSession()->get(self::CLE); return null !== $attendu && $attendu['expire'] >= time(); } }
Le contrôleur affiche le formulaire et n'envoie un SMS que s'il n'y a pas déjà un code valide en attente. Un SMS par rafraîchissement de page, c'est une facture qui grimpe et une porte ouverte à qui veut inonder un numéro. Pour la même raison, un bouton « Renvoyer le code » doit passer par un rate limiter, par exemple un renvoi par minute.
// src/Controller/SecuriteController.php (extrait) #[Route('/verification', name: 'app_verification', methods: ['GET', 'POST'])] public function verification( #[CurrentUser] Utilisateur $utilisateur, CodesSms $codes, AuthenticationUtils $authentification, ): Response { // Sans téléphone, rien à envoyer : dans une vraie application, renvoyez vers l'enrôlement. if (null === $utilisateur->getTelephone()) { throw $this->createAccessDeniedException(); } // Le POST est traité par CodeSmsAuthenticator. if (!$codes->enAttente()) { $codes->envoyer($utilisateur); } return $this->render('securite/verification.html.twig', [ 'erreur' => $authentification->getLastAuthenticationError(), ]); }
Le parcours, de bout en bout
- Ada demande
/compte. Elle n'est pas connectée :form_loginl'envoie vers/connexion. - Elle donne son mot de passe. Le jeton porte
pwd, et le pare-feu la ramène vers/compte. - Le voter refuse
DEUX_FACTEURSet demande une nouvelle preuve. Le pare-feu retient/compte, et le point d'entrée l'envoie vers/verification, qui lui envoie un code. - Elle saisit le code. Le jeton porte
pwdetsms, et elle revient sur/compte.
Le voter ne regarde pas l'heure des preuves : une fois le code donné, il vaut pour toute la session, comme dans la plupart des 2FA, et la déconnexion efface tout. Comme l'heure est stockée, rien n'empêche d'exiger un sms de moins de 24 heures.
Il n'y a plus de subscriber, plus de rôle ajouté au jeton, plus de liste de routes à tenir à jour. L'utilisateur garde ses rôles habituels, et une entité sans EquatableInterface ne pose aucun problème : les tests de l'exemple utilisent justement une classe Utilisateur qui ne l'implémente pas, et la session tient d'une requête à l'autre.
Avant de l'adopter
Cet article montre le mécanisme, pas une 2FA complète. Trois points à connaître.
Le SMS est l'un des seconds facteurs les plus faibles. Il est exposé au détournement de numéro (SIM swapping), et le NIST le classe parmi les authentificateurs « restreints ». Le même schéma fonctionne avec une application d'authentification : l'authenticator vérifie un code TOTP avec une bibliothèque comme spomky-labs/otphp, déclare AuthenticationMethod::ONE_TIME_PASSWORD, et le voter exige otp au lieu de sms. Il faut alors un secret par compte, enregistré à l'enrôlement après confirmation d'un premier code, puis jamais réaffiché.
L'enrôlement et les secours restent à votre charge. Enregistrer le téléphone (ou le secret TOTP), proposer des codes de secours hachés et à usage unique, et protéger la désactivation de la 2FA par une preuve récente. Pour ce dernier point, IS_AUTHENTICATED_RECENTLY et le même mécanisme de réauthentification font l'affaire : j'en parlais dans Être connecté ne suffit pas : redemander une preuve avant une action sensible.
Une limite du mécanisme. La réauthentification ne se déclenche que si un seul attribut est refusé. Une règle access_control qui liste plusieurs attributs dans roles donne toujours un 403 : gardez DEUX_FACTEURS seul sur sa ligne. Passer plusieurs attributs à une même décision est d'ailleurs déprécié en 8.2.
Si vous voulez tout cela clé en main, ou si vous ne pouvez pas attendre Symfony 8.2, scheb/2fa-bundle reste le bon choix : il apporte TOTP, codes de secours, appareils de confiance et code par e-mail. Mais si votre besoin est un second facteur sur une application que vous maîtrisez, Symfony 8.2 vous laisse l'écrire en moins de deux cents lignes, sans contourner le pare-feu.