Mettre en place la 2FA dans une application Symfony (bundle Scheb)
Ce tutoriel vous guide, du début jusqu'à un écran de connexion qui exige un second facteur, pour ajouter la double authentification (2FA) à une application Symfony. À la fin, un utilisateur qui a activé la 2FA devra saisir un code reçu par e-mail après son mot de passe — sans être harcelé sur les appareils qu'il a marqués « de confiance ».
Nous utilisons scheb/2fa-bundle, la brique de référence pour la 2FA en
Symfony. On avance en un seul chemin, celui qui marche ; une extension TOTP
(application d'authentification) est proposée à la fin, une fois l'e-mail en place.
Avant de commencer
Il vous faut :
- une application Symfony fonctionnelle avec une authentification par
e-mail + mot de passe déjà opérationnelle (
form_login) ; - une entité
Userpersistée avec Doctrine ; - une chaîne d'envoi d'e-mails configurée (Symfony Mailer), puisque le code du second facteur part par e-mail.
Tout au long du tutoriel, remplacez <VOTRE_APP> par le nom de votre application
et utilisez vos propres variables d'environnement pour les valeurs sensibles
(adresse d'expéditeur, DSN mail, etc.) — ne codez jamais ces valeurs en dur.
Étape 1 — Installer le bundle
Installez le bundle et les deux briques dont nous avons besoin : la 2FA par e-mail et la reconnaissance des appareils de confiance.
composer require scheb/2fa-bundle scheb/2fa-email scheb/2fa-trusted-device
Étape 2 — Préparer l'entité User
Le bundle a besoin que votre utilisateur sache s'il a la 2FA activée, où envoyer le code et quel code est en cours. On implémente pour cela deux interfaces : celle de la 2FA e-mail et celle des appareils de confiance.
Point clé : le second facteur ne sera exigé que si l'utilisateur l'a activé.
C'est le rĂ´le du champ is2faEnabled, que renvoie isEmailAuthEnabled().
use Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface; use Scheb\TwoFactorBundle\Model\TrustedDeviceInterface; class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface, TrustedDeviceInterface { #[ORM\Column] private bool $is2faEnabled = false; #[ORM\Column(nullable: true)] private ?string $authCode = null; #[ORM\Column] private int $trustedVersion = 0; // --- 2FA e-mail --- public function isEmailAuthEnabled(): bool { return $this->is2faEnabled; } public function getEmailAuthRecipient(): string { return $this->email; } public function getEmailAuthCode(): ?string { return $this->authCode; } public function setEmailAuthCode(string $authCode): void { $this->authCode = $authCode; } // --- appareil de confiance --- public function getTrustedTokenVersion(): int { return $this->trustedVersion; } }
Deux champs méritent une explication :
authCodestocke le code e-mail en cours de validité ; il est éphémère et écrasé à chaque nouvelle demande.trustedVersionpermet d'invalider d'un coup tous les appareils de confiance d'un utilisateur : il suffira plus tard d'incrémenter ce nombre.
Générez et appliquez la migration Doctrine correspondante pour créer ces colonnes :
php bin/console make:migration php bin/console doctrine:migrations:migrate
Étape 3 — Configurer le bundle
Créez (ou complétez) le fichier de configuration du bundle. On active l'e-mail et l'appareil de confiance.
# config/packages/scheb_2fa.yaml scheb_two_factor: email: enabled: true sender_email: '%env(MAILER_SENDER)%' # adresse d'expéditeur via variable d'env digits: 6 trusted_device: enabled: true lifetime: 5184000 # durée de confiance en secondes (ici 60 jours) cookie_secure: true # cookie transmis en HTTPS uniquement
Notez cookie_secure: true : le cookie d'appareil de confiance ne doit jamais
transiter en clair. Et sender_email pointe vers une variable d'environnement —
pas une adresse écrite en dur.
Étape 4 — Brancher le firewall
C'est ici que Symfony apprend à intercaler l'étape 2FA dans le processus de connexion. On déclare le formulaire de code dans le firewall, puis on rend le process 2FA joignable pendant que la connexion est « en attente ».
# config/packages/security.yaml security: firewalls: main: # … votre authentification primaire (form_login) reste en place … two_factor: auth_form_path: 2fa_login # route du formulaire de saisie du code check_path: 2fa_login_check # cible du POST (gérée par le bundle) access_control: # le process 2FA doit être accessible PENDANT la connexion en attente - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
Déclarez ensuite les routes du bundle :
# config/routes/scheb_2fa.yaml 2fa_login: path: /2fa controller: "scheb_two_factor.form_controller::form" 2fa_login_check: path: /2fa_check
Vérification intermédiaire : donnez is2faEnabled = true à un compte de test,
puis connectez-vous. Après l'e-mail + le mot de passe, l'utilisateur doit recevoir
un code par e-mail et ĂŞtre redirigĂ© vers /2fa pour le saisir avant d'accĂ©der Ă
l'application. Si c'est le cas, le cœur de la 2FA fonctionne.
Étape 5 — Laisser l'utilisateur activer / désactiver la 2FA
La 2FA est opt-in : chaque utilisateur choisit de l'activer. Ajoutez une route
qui bascule is2faEnabled. On la protège par un jeton CSRF et on exige une
session pleinement authentifiée (pas une simple 2FA en cours).
#[Route('/2fa/toggle', name: 'app_2fa_toggle', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function toggle(Request $request, EntityManagerInterface $em): Response { if (!$this->isCsrfTokenValid('2fa_toggle', $request->request->get('_token'))) { throw $this->createAccessDeniedException(); } $user = $this->getUser(); $user->setIs2faEnabled(!$user->isEmailAuthEnabled()); $em->flush(); return $this->redirectToRoute('app_account'); }
Pourquoi IS_AUTHENTICATED_FULLY et non IS_AUTHENTICATED_2FA_IN_PROGRESS ?
Parce qu'activer ou désactiver son second facteur est une action sensible : elle ne
doit être possible qu'une fois la connexion complètement établie.
Étape 6 — Rendre le formulaire de code accessible
Le champ où l'utilisateur saisit son code est un formulaire comme un autre : il doit être accessible. Quelques règles simples suffisent à le rendre confortable, y compris sur mobile et pour les lecteurs d'écran :
- un
<label for>explicite (« Code de vérification reçu par e-mail »), et non un simpleplaceholder; inputmode="numeric"pour afficher le clavier numérique sur mobile ;autocomplete="one-time-code"pour proposer le remplissage automatique du code ;- en cas de code refusé, reliez le message d'erreur au champ via
aria-describedbyet ajoutezaria-invalid="true"; - une case « Faire confiance à cet appareil » avec un
<label>clair : elle active le cookie de confiance configuré à l'étape 3 ; - un focus visible et aucune limite de temps cachée.
Étape 7 — Tester le parcours complet
Vérifiez les quatre cas suivants ; s'ils passent tous, votre 2FA est en place :
- 2FA activée (
is2faEnabled = true) → la connexion exige le code e-mail avant d'accéder à l'application. - Appareil de confiance (case cochée précédemment) → le code n'est pas redemandé sur ce même appareil.
- Code erroné → refus, avec un message d'erreur relié au champ.
- Révocation → incrémentez
trustedVersionpour l'utilisateur : tous ses appareils de confiance redemandent le code Ă la prochaine connexion.
Félicitations : votre application exige désormais un second facteur, tout en respectant les appareils de confiance et le choix de l'utilisateur.
Aller plus loin — ajouter le TOTP (application d'authentification)
Une fois la 2FA e-mail maîtrisée, vous pouvez proposer le TOTP : un code généré par une application (Google Authenticator, Aegis, etc.), sans dépendre de l'e-mail.
Installez la brique dédiée :
composer require scheb/2fa-totp
Votre entité User implémentera alors l'interface TOTP du bundle et exposera un
secret TOTP propre à chaque utilisateur (stocké dans une nouvelle colonne).
Activez le TOTP dans scheb_2fa.yaml de la mĂŞme manière que l'e-mail, et proposez Ă
l'utilisateur un QR code d'enrôlement. Traitez le secret TOTP comme une donnée
sensible : il ne doit jamais apparaître dans les logs ni transiter en clair.
Ressources
- Documentation officielle du bundle : https://symfony.com/bundles/SchebTwoFactorBundle/current/index.html
- Symfony Mailer (envoi du code par e-mail) : https://symfony.com/doc/current/mailer.html