Skip to content

Authentication — new authenticator system

TL;DR — Depuis Symfony 5.3 (stable en 5.4), le new authenticator system remplace le défunt Guard. Chaque authenticator est un objet implémentant AuthenticatorInterface (souvent via AbstractAuthenticator). La firewall pipeline appelle supports() → authenticate() → onAuthenticationSuccess/Failure. Password hashers via argon2id (PHP natif). Remember-me par signed cookie ou token persistent.

Mental model — ASCII diagram + analogy

Analogie : Une firewall = vigile à l'entrée. Chaque authenticator est un test : "as-tu un badge ?", "as-tu un token JWT ?", "as-tu un cookie remember-me ?". Le premier qui dit "supports = yes" prend la requête.

   HTTP Request


   ┌─────────────────────────────────────────────────────┐
   │ Firewall (security.yaml: pattern, providers, etc.)  │
   └──┬──────────────────────────────────────────────────┘


   For each authenticator in order:
      supports($request) ── false ──► next
            │ true

      authenticate($request)


      returns Passport (User + credentials + badges)


      verifyPassport (CredentialChecker, etc.)

   ┌────────┴────────┐
   ▼                 ▼
 onSuccess()     onFailure()
   │                 │
   ▼                 ▼
 TokenStorage     ExceptionResponse
   = authenticated

Passport = sac de badges : UserBadge, PasswordCredentials, RememberMeBadge, CsrfTokenBadge, custom.

Le Passport, en profondeur — pourquoi cette indirection ?

L'erreur de débutant est de croire qu'authenticate() authentifie. Faux : authenticate() ne fait que déclarer une intention sous forme de Passport. La vérification réelle est déléguée à des badge resolvers (listeners sur CheckPassportEvent). C'est de l'inversion de contrôle : ton authenticator dit « voici un user et un mot de passe à vérifier », le framework décide comment vérifier (hash check, CSRF, rate-limit) et dans quel ordre.

BadgeRôleResolver / listener
UserBadgeCharge le user (loader closure OU user provider)UserProviderListener
PasswordCredentialsMot de passe en clair à vérifier contre le hashCheckCredentialsListener
CsrfTokenBadgeJeton CSRF à validerCsrfProtectionListener
RememberMeBadgeAutorise l'émission d'un cookie remember-meRememberMeListener
PasswordUpgradeBadgeRe-hash transparent si l'algo a évoluéPasswordMigratingListener
PreAuthenticatedUserBadgeCourt-circuite la vérif (token déjà prouvé)— (utilisé par SelfValidatingPassport)

Deux familles de Passport :

  • Passport : contient des credentials à vérifier (mot de passe, CSRF). Le framework doit trouver un badge qui valide les credentials, sinon BadCredentialsException.
  • SelfValidatingPassport : « la preuve est déjà faite » (token API valide, assertion SAML signée). Aucune vérification de credential supplémentaire — c'est pour ça que l'exemple ApiTokenAuthenticator l'utilise : le token est la preuve.
php
// Passport avec mot de passe + CSRF + upgrade transparent + remember-me
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;

$password = (string) $request->request->get('_password', '');

return new Passport(
    new UserBadge($email),                         // résolu par le user provider
    new PasswordCredentials($password),            // vérifié contre le hash
    [
        new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
        new PasswordUpgradeBadge($password),       // re-hash si argon params changent
        new RememberMeBadge(),                      // autorise le cookie remember-me
    ],
);

Règle de raisonnement staff : si tu écris ta logique de vérification de mot de passe à la main dans authenticate(), tu te trompes — tu réimplémentes (mal) CheckCredentialsListener, tu perds le timing-attack-safe compare, le password upgrade automatique et le rate-limiting. Délègue via badges.

Code minimal — realistic snippet

security.yaml — multiple firewalls

yaml
# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
            algorithm: auto       # PHP choisit argon2id si dispo

    providers:
        app_users:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api:
            pattern: ^/api
            stateless: true
            provider: app_users
            jwt: ~                      # via lexik/jwt-authentication-bundle

        main:
            lazy: true
            provider: app_users
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true
            json_login:
                check_path: /login_json
                username_path: email
                password_path: password
            logout:
                path: app_logout
                target: app_home
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800        # 7 days
                path: /
                samesite: strict
                always_remember_me: false
            custom_authenticators:
                - App\Security\TwoFactorAuthenticator

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

Clé custom_authenticators (pluriel)

La clé de configuration est custom_authenticators (pluriel) depuis Symfony 5.3+. Beaucoup de vieux tutoriels / réponses StackOverflow montrent custom_authenticator (singulier) hérité de la période Guard : cela lève une InvalidConfigurationException silencieuse au cache:clear. Si votre authenticator « ne se déclenche jamais », vérifiez d'abord cette clé, puis l'ordre de déclaration.

Comment lire un firewall comme un staff engineer

Un firewall n'est pas une route ni un middleware unique : c'est un ensemble de listeners attachés au RequestEvent/kernel.request, orchestrés par le FirewallMap. La requête traverse un seul firewall — celui dont le pattern (regex sur le path) matche en premier dans l'ordre de déclaration. D'où trois invariants à graver :

InvariantConséquence pratique
Un firewall par requêtedev et main ne se cumulent pas ; mets dev en premier sinon ^/main mange /_profiler.
Ordre = prioritépattern: ^/api doit précéder main (qui n'a pas de pattern = catch-all).
lazy: true ⇒ pas de session tant que getUser() n'est pas appeléIndispensable pour le cache HTTP : une session démarrée tue le Cache-Control: public.

Le piège classique : un firewall sans pattern est un catch-all. Si tu le déclares avant api, l'API hérite du comportement stateful du main. Toujours : firewalls spécifiques d'abord, catch-all en dernier.

Custom authenticator (e.g., API token + 2FA verification)

php
<?php
namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
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\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

final class ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function __construct(private ApiTokenRepository $tokens) {}

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-AUTH-TOKEN');
    }

    public function authenticate(Request $request): Passport
    {
        $token = $request->headers->get('X-AUTH-TOKEN');
        if (!$token) {
            throw new CustomUserMessageAuthenticationException('No API token');
        }

        return new SelfValidatingPassport(
            new UserBadge($token, function (string $token) {
                $apiToken = $this->tokens->findValid($token);
                if (!$apiToken) {
                    throw new CustomUserMessageAuthenticationException('Invalid token');
                }
                return $apiToken->getUser();
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;        // let request continue
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse([
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
        ], Response::HTTP_UNAUTHORIZED);
    }
}

User entity (PHP 8.2+, PasswordAuthenticatedUserInterface)

php
<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    private string $email;

    /** @var list<string> */
    #[ORM\Column(type: 'json')]
    private array $roles = ['ROLE_USER'];

    #[ORM\Column]
    private string $password;        // hashed

    public function __construct(string $email)
    {
        $this->email = $email;
    }

    public function getUserIdentifier(): string { return $this->email; }
    public function getRoles(): array { return array_unique([...$this->roles, 'ROLE_USER']); }
    public function getPassword(): string { return $this->password; }
    public function setPassword(string $hashed): void { $this->password = $hashed; }

    // Symfony < 7.3 : méthode obligatoire de UserInterface.
    // Symfony >= 7.3 : dépréciée au profit de EraseCredentialsEvent.
    // On la garde no-op tant qu'aucun credential en clair n'est stocké sur l'objet.
    public function eraseCredentials(): void {}
}

getUserIdentifier() vs ancien getUsername()

Depuis 5.3, l'interface expose getUserIdentifier() (et non getUsername(), retiré en 6.0). C'est la valeur loggée dans le TokenStorage, utilisée par loginUser() en test et affichée dans le profiler. Choisis un identifiant immuable et unique (email, UUID) — surtout pas un champ que l'utilisateur peut changer sans invalider ses sessions / cookies remember-me.

eraseCredentials() en Symfony 7.3+

En 7.3, UserInterface::eraseCredentials() est dépréciée. Le nettoyage des credentials passe désormais par un listener sur EraseCredentialsEvent (déclaré via #[AsEventListener(event: EraseCredentialsEvent::class)]). Migration douce : garder la méthode no-op (elle ne casse rien) et n'ajouter un listener EraseCredentialsEvent que si vous stockez réellement un mot de passe en clair transitoire sur l'entité. La plupart des entités modernes n'ont rien à effacer (le plainPassword vit dans un DTO/form, jamais sur l'entité persistée).

Hash command

php
<?php
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

public function __construct(private UserPasswordHasherInterface $hasher) {}

public function createUser(string $email, string $plain): User
{
    $user = new User($email);
    $user->setPassword($this->hasher->hashPassword($user, $plain));
    return $user;
}

Patterns courants — 3–6 patterns

  1. Multi-firewalls : api stateless + main session. Différents providers, différents authenticators.
  2. Chain provider : combiner LDAP + DB pour bascule progressive.
  3. Two-step auth : login → token de session 2FA → second authenticator vérifie le code TOTP.
  4. Login throttling : login_throttling: max_attempts: 5 (5.3+) — déjà intégré.
  5. UserChecker : implémenter Symfony\Component\Security\Core\User\UserCheckerInterface pour bloquer comptes désactivés (checkPreAuth / checkPostAuth).
  6. switch_user : impersonation contrôlée par ROLE_ALLOWED_TO_SWITCH, super pratique pour debug prod.

Versions — Symfony 5.4 / 6.4 / 7.x

Topic5.46.47.x
Guard authenticatorDeprecatedRetiréRetiré
New authenticatorsStableDefaultDefault
enable_authenticator_manager: trueRequis dans configImpliciteImplicite
password_hashers (anciennement encoders)encoders deprecatedRetiréRetiré
make:user recipeGénère UserInterface + PasswordAuthenticatedUserInterfaceIdemIdem
remember_me cookiePersistent token table OU signed cookieSigned cookie par défaut (token_provider optionnel)Idem
EraseCredentialsEventExistait via méthodeDevient event 7.xEvent-driven
login_throttlingOKOKOK
  • Symfony 6 a complètement retiré security.firewalls.X.guard. Du code ancien doit migrer vers custom_authenticator.
  • Argon2id natif PHP 7.3+ → auto choisit ça si l'extension sodium est dispo.

Pitfalls — 5–8 concrete traps

  1. stateless: true + session cookie : sur firewall api, ne JAMAIS oublier stateless: true, sinon Symfony crée une session et la perf chute.
  2. Mélange form_login + custom_authenticator : ordre dans security.yaml = ordre d'évaluation. Premier qui supports() gagne.
  3. UserBadge callback qui re-hit la DB à chaque requête : sur firewall stateless, c'est attendu (pas de session). Penser cache si user provider lent.
  4. eraseCredentials() vide : impératif sinon le plain password peut rester en mémoire / session.
  5. Argon2id paramètres défaut : OK en 2024, mais sur low-end VPS le CPU peut être saturé. Tester le temps réel d'auth.
  6. remember_me sans signature_properties : si tu changes le mail/password, l'ancien cookie reste valide. Configurer signature_properties: [password, email].
  7. Logout via GET avec CSRF désactivé : route logout sans CSRF = LogOut-CSRF possible (déconnexion forcée). Toujours enable_csrf: true sur logout.
  8. provider non spécifié quand plusieurs : Symfony lève une exception "ambiguous provider".
  9. json_login : valide JSON Content-Type, sinon retourne 400 obscur — bien tester avec curl -H 'Content-Type: application/json'.

Testing — phpunit / KernelTestCase / login simulation

php
<?php
namespace App\Tests\Functional;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class LoginTest extends WebTestCase
{
    private KernelBrowser $client;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $container = static::getContainer();

        $em = $container->get(EntityManagerInterface::class);
        $hasher = $container->get(UserPasswordHasherInterface::class);

        $user = new User('[email protected]');
        $user->setPassword($hasher->hashPassword($user, 'secret'));
        $em->persist($user); $em->flush();
    }

    public function testJsonLoginSuccess(): void
    {
        $this->client->request('POST', '/login_json',
            server: ['CONTENT_TYPE' => 'application/json'],
            content: json_encode(['email' => '[email protected]', 'password' => 'secret'])
        );
        self::assertResponseIsSuccessful();
    }

    public function testLoginUserHelper(): void
    {
        $user = static::getContainer()->get(EntityManagerInterface::class)
            ->getRepository(User::class)->findOneBy(['email' => '[email protected]']);

        $this->client->loginUser($user);              // helper 5.1+
        $this->client->request('GET', '/profile');
        self::assertResponseIsSuccessful();
    }
}

Production — observabilité, sécurité, scale

Observabilité de l'authentification

Tu ne peux pas sécuriser ce que tu ne mesures pas. Le composant Security émet des événements que tu dois brancher sur ton stack de logs/metrics :

ÉvénementQuandMétrique à émettre
LoginSuccessEventAuth réussie (tous authenticators)auth.success (tags: firewall, authenticator)
LoginFailureEventÉchec d'authauth.failure (tag: raison via getException())
CheckPassportEventAvant résolution des badgeshook pour règles custom (geo-block, device)
AuthenticationTokenCreatedEventToken construitenrichir le token (claims)
InteractiveLoginEventLogin interactif (form/json)distinguer du remember-me silencieux
php
#[AsEventListener(event: LoginFailureEvent::class)]
final class AuthFailureMetrics
{
    public function __construct(private readonly StatsdClient $statsd) {}

    public function __invoke(LoginFailureEvent $event): void
    {
        $this->statsd->increment('auth.failure', tags: [
            'firewall'  => $event->getFirewallName(),
            'reason'    => $event->getException()::class,
        ]);
        // Ne jamais logger le mot de passe, ni l'email en clair en zone RGPD :
        // hasher ou tronquer l'identifiant dans les logs.
    }
}

Signal à surveiller : un ratio failure/success qui grimpe sur un firewall = credential stuffing en cours. Couplé à login_throttling, tu veux alerter avant que le rate-limiter ne sature.

Sécurité — la checklist non négociable

  • Hashers : algorithm: autoargon2id (via sodium) ou bcrypt en fallback. Ne jamais figer sha256/md5/plaintext (même en dev : un dev oublie de changer). Sur VPS contraint, calibre memory_cost/time_cost pour viser ~250 ms par hash (cf. exercice).
  • Password upgrade automatique : implémente PasswordUpgraderInterface sur ton user provider ⇒ au prochain login réussi, Symfony re-hash avec les paramètres courants. Migration de coût zéro-downtime, transparente.
  • CSRF sur form_login ET logout : enable_csrf: true des deux côtés. Le logout-CSRF est réel (déconnexion forcée, parfois enchaînée avec login-CSRF pour session fixation).
  • stateless: true sur tout firewall API : pas de cookie de session ⇒ pas de surface CSRF, pas de session fixation.
  • Timing-safe : laisse CheckCredentialsListener faire le compare. Un if ($hash === $input) maison fuit l'info par timing.
  • Énumération de comptes : LoginFailureEvent doit renvoyer un message générique (Invalid credentials) identique que l'email existe ou non, et un temps de réponse constant (le framework hashe un dummy si le user n'existe pas — ne le court-circuite pas).

Scale & perf

  • Stateless API + user provider lent : chaque requête recharge le user (pas de session). Si le UserBadge loader tape une DB lente, mets un cache court (Redis, TTL 30–60 s) DANS le loader — jamais le user complet en session sur un firewall stateless.
  • Session backend : en multi-instance, PHP_SESSION sur fichier local = sticky sessions obligatoires. Préfère Redis (framework.session.handler_id) pour des instances stateless horizontalement scalables.
  • lazy: true : retarde le démarrage de session ⇒ permet le cache HTTP public sur les pages anonymes. Mesure : sans lazy, toute page démarre une session et casse le edge cache.
  • Argon2id sous charge : le hash est CPU/mémoire-bound par design. Sous pic de login (ex. après une campagne marketing), c'est un goulot. Mets un rate-limiter en amont et dimensionne le CPU, ne baisse pas le coût du hash.

🎬 Cas d'usage concrets

Scénario 1 — SSO SAML pour cabinet juridique multi-sites

Le cabinet d'avocats de l'exemple précédent compte sept implantations (Paris, Lyon, Marseille, Bordeaux, Lille, Bruxelles, Genève) et exige un Single Sign-On centralisé via Azure AD. Les 350 utilisateurs (associés, collaborateurs, paralégaux, support) ne saisissent leur mot de passe qu'une fois par jour sur le portail Microsoft, puis accèdent au DMS Symfony sans nouvelle authentification. L'équipe a implémenté un CustomAuthenticator SAML s'appuyant sur la bibliothèque simplesamlphp/simplesamlphp : à la réception de l'assertion SAML signée par Azure, l'authenticator extrait l'email, les attributs department et groups, puis charge l'utilisateur via UserProvider ou le provisionne automatiquement si nouveau (JIT provisioning). Les rôles applicatifs sont dérivés du groupe AD (grp-associesROLE_ASSOCIE, grp-paralegalROLE_PARALEGAL). Les sessions PHP sont configurées en cookie_samesite: lax et cookie_secure: true, durée 8 heures alignée sur la journée de travail. Le logout déconnecte simultanément Azure et l'application via Single Logout SAML.

Scénario 2 — Authentification 2FA pour banque en ligne

Une néobanque destinée aux freelances impose la 2FA TOTP sur tous les accès. Le flux est : form_login classique (email + mot de passe hashé Argon2id), puis si succès l'utilisateur est dirigé vers un état intermédiaire awaiting_2fa stocké en session, où il doit saisir un code TOTP à 6 chiffres. Un CustomAuthenticator TotpAuthenticator valide le code via paragonie/multi-factor avec une fenêtre de tolérance de ±1 step (30 s). Après trois échecs, le compte est verrouillé 15 minutes et un événement CompteVerrouille part vers Messenger pour notification SMS au client. La directive DSP2 impose également la SCA pour les opérations sensibles (virement > 30 €) : un voter intercepte ces actions et redéclenche un challenge 2FA. Les sessions sont en Redis (Cluster) avec TTL 1 heure de inactivité. Le passage en passkey WebAuthn est en cours pour 2027.

Scénario 3 — OAuth social pour marketplace e-commerce

Une marketplace e-commerce de seconde main (vêtements, mobilier, hi-fi) accepte les connexions via Google, Facebook et Apple. L'intégration utilise knpuniversity/oauth2-client-bundle, qui expose un OAuthAuthenticator par provider. À la première connexion, l'utilisateur est invité à compléter un formulaire d'enrichissement (CGU, opt-in newsletter, ville livraison) avant de pouvoir vendre. L'identité primaire est l'email vérifié remonté par le provider ; en cas d'email existant en base, le système propose de lier le compte social existant après revérification par lien magique (anti-account takeover). Les tokens d'accès ne sont pas conservés, seul l'identifiant externe (sub) est stocké pour relier les futurs logins. Pour les utilisateurs Apple qui choisissent le mail relai privé, l'app gère explicitement le changement d'adresse au prochain login. Le taux de conversion d'inscription est passé de 22 % à 41 % depuis l'introduction du login social.

🛠️ Exemple end-to-end

Use case : authentification 2FA TOTP en deux étapes pour la néobanque, avec verrouillage anti-bruteforce et événement de notification.

php
<?php
// src/Security/TotpAuthenticator.php
declare(strict_types=1);

namespace App\Security;

use App\Domain\User\Repository\UserRepository;
use OTPHP\TOTP;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
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\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

final class TotpAuthenticator extends AbstractAuthenticator
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly MessageBusInterface $bus,
    ) {}

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

    public function authenticate(Request $request): Passport
    {
        $userId = $request->getSession()->get('awaiting_2fa_user_id')
            ?? throw new CustomUserMessageAuthenticationException('Session 2FA expirée');
        $code = trim((string) $request->request->get('code', ''));

        return new SelfValidatingPassport(
            new UserBadge($userId, function (string $id) use ($code): \Symfony\Component\Security\Core\User\UserInterface {
                $user = $this->users->find($id) ?? throw new CustomUserMessageAuthenticationException('Utilisateur inconnu');
                $totp = TOTP::createFromSecret($user->getTotpSecret());
                $totp->setLabel($user->getEmail());
                if (!$totp->verify($code, leeway: 30)) {
                    $user->enregistrerEchec2FA();
                    if ($user->doitEtreVerrouille()) {
                        $this->bus->dispatch(new \App\Application\Compte\Event\CompteVerrouille($user->getId()));
                    }
                    throw new CustomUserMessageAuthenticationException('Code 2FA invalide');
                }
                $user->reinitialiserEchecs2FA();
                return $user;
            }),
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        $request->getSession()->remove('awaiting_2fa_user_id');
        return new RedirectResponse('/tableau-de-bord');
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $request->getSession()->getFlashBag()->add('error', $exception->getMessage());
        return new RedirectResponse('/2fa');
    }
}

// config/packages/security.yaml (extrait)
// security:
//     firewalls:
//         main:
//             form_login:
//                 login_path: security_login
//                 check_path: security_login
//                 default_target_path: security_2fa
//             custom_authenticators:
//                 - App\Security\TotpAuthenticator
//             logout:
//                 path: security_logout
//     access_control:
//         - { path: ^/2fa, roles: ROLE_USER_PRE_2FA }
//         - { path: ^/tableau-de-bord, roles: ROLE_USER }

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Monte un projet Symfony 7.x (symfony new sec-lab --webapp) avec make:user et make:auth.

Objectif : un LoginLinkAuthenticator sans mot de passe — l'utilisateur reçoit un lien signé par email, un clic l'authentifie. Indice/Solution : utilise le composant natif symfony/security-httpLoginLinkHandlerInterface (bin/console make:auth ne le fait pas, mais LoginLinkHandler existe). Configure login_link: dans le firewall avec check_route et signature_properties: [id, password] pour invalider le lien si le user change. Le lien expire (lifetime: 600). Vérifie que rejouer un lien expiré renvoie un message générique, pas une stack trace.

2. Login throttling + énumération de comptes (production-grade)

Objectif : empêcher le credential stuffing ET l'énumération d'emails, avec métriques. Indice/Solution : active login_throttling: { max_attempts: 5, interval: '15 minutes' } (nécessite symfony/rate-limiter). Branche un listener sur LoginFailureEvent qui incrémente une métrique Statsd/Prometheus sans logger l'email en clair. Teste avec ab/hey que la réponse est constante en temps et en message, que le compte existe ou non. Vérifie via le profiler qu'un dummy hash est calculé pour un user inexistant.

3. 2FA TOTP en deux étapes (production-grade)

Objectif : reproduire le flux néobanque — form_login puis second authenticator TOTP, avec un rôle intermédiaire ROLE_USER_PRE_2FA. Indice/Solution : après form_login, le token n'a que ROLE_USER_PRE_2FA ; access_control interdit tout sauf /2fa. Un TotpAuthenticator (voir l'exemple end-to-end) valide le code et, via AuthenticationTokenCreatedEvent, promeut le token à ROLE_USER. Piège à résoudre : ne stocke jamais le secret TOTP en clair récupérable — chiffre-le au repos. Ajoute un verrouillage après 3 échecs.

4. Password upgrade zéro-downtime (production-grade)

Objectif : migrer tous les hashes de bcrypt (cost 12) vers argon2id sans forcer de reset, de façon transparente. Indice/Solution : implémente PasswordUpgraderInterface sur ton user provider (l'entity provider le fait déjà). Mets algorithm: auto (⇒ argon2id). Ajoute un PasswordUpgradeBadge($plainPassword) dans le Passport du form_login custom. Au prochain login réussi, le hash est réécrit. Vérifie en base que le préfixe $argon2id$ apparaît progressivement. Question piège : pourquoi ça ne marche que sur form_login/json_login et pas sur un token API ? (Réponse : pas de mot de passe en clair à re-hasher dans un flux SelfValidatingPassport.)

5. Casser puis réparer — firewall mal ordonné (break-then-fix)

Objectif : reproduire le bug « mon API renvoie du HTML de login au lieu d'un 401 JSON ». Indice/Solution : place délibérément le firewall main (catch-all, sans pattern) AVANT api. Observe que /api/... non authentifié redirige vers /login (302 HTML) au lieu de 401. Répare en (a) remettant api en premier, (b) stateless: true, (c) un AuthenticationEntryPointInterface ou entry_point: qui renvoie un JsonResponse 401. Bonus : montre dans le profiler quel firewall a matché.

6. Casser puis réparer — remember-me qui survit au changement de mot de passe (break-then-fix)

Objectif : démontrer une faille de session : après reset de mot de passe, un cookie remember-me volé reste valide. Indice/Solution : configure remember_me SANS signature_properties. Connecte-toi, capture le cookie, change le mot de passe, rejoue le cookie ⇒ toujours connecté (faille). Répare avec signature_properties: [password] : le hash du mot de passe entre dans la signature du cookie, donc changer le mot de passe invalide tous les cookies remember-me existants. Vérifie en rejouant le cookie après reset ⇒ déconnexion.

🎤 En entretien

Q : Différence entre Passport et SelfValidatingPassport ? Quand chacun ? R : Passport porte des credentials que le framework doit vérifier (mot de passe via PasswordCredentials, CSRF) — il faut au moins un badge qui valide, sinon BadCredentialsException. SelfValidatingPassport signifie « la preuve est déjà établie » (token API valide, assertion SAML signée, magic link signé) : aucune vérification de credential, juste le chargement du user. Utiliser le premier pour login/password, le second pour les flux pré-prouvés.

Q : Pourquoi authenticate() ne vérifie-t-il pas le mot de passe lui-même ? R : Inversion de contrôle. authenticate() déclare une intention (un Passport avec badges) ; la vérification est déléguée à des listeners sur CheckPassportEvent (CheckCredentialsListener, CsrfProtectionListener, etc.). Avantage : compare timing-safe, password upgrade automatique, rate-limiting et CSRF sont appliqués uniformément sans que chaque authenticator les réimplémente. Réécrire la vérif à la main = anti-pattern et faille potentielle.

Q : Un firewall api stateless ne « cache » pas l'utilisateur entre requêtes. Problème de perf ? R : Oui, chaque requête recharge le user via le UserBadge loader (pas de session). Si le provider est lent (DB, LDAP), c'est un coût par requête. Solution : cache court (Redis, TTL 30–60 s) dans le loader, jamais le user complet en session — sinon on casse l'invariant stateless (et on rouvre la surface CSRF/session fixation).

Q : Comment migrer 10M de hashes bcrypt vers argon2id sans forcer un reset de mot de passe ? R : algorithm: auto + PasswordUpgraderInterface sur le user provider + PasswordUpgradeBadge dans le Passport. À chaque login réussi, Symfony détecte que le hash stocké n'utilise plus l'algo/les paramètres courants et le réécrit de façon transparente. Migration progressive, zéro-downtime, sans connaître les mots de passe. Le tail des comptes inactifs reste en bcrypt — acceptable, ou forcer un reset après N mois.

Quand utiliser / éviter

  • Form login + session : apps web classiques.
  • JSON login + JWT : SPA / mobile.
  • Custom authenticator : API token, OAuth callback, SSO SAML, magic link.
  • À éviter : ré-écrire un authenticator HTTP basic quand http_basic: ~ suffit.

Liens

Bibliothèque tech perso — Achref