Authenticator sur mesure

Anatomie complète d'un authenticator, badges personnalisés et cycle d'événements de l'authentification.

Créé le 14 juin 2026·Mis à jour le 14 juin 2026
Voir 2 références

Introduction


La leçon Sécurité a présenté l'authenticator et le Passport sous leur forme la plus simple. Mais dès qu'on sort des cas couverts par form_login ou access_token, il faut écrire le sien : authentification par signature de requête, par header propriétaire, par token à usage unique, intégration d'un fournisseur d'identité maison. Cette leçon décortique l'anatomie complète d'un authenticator et le cycle d'événements qui l'entoure.

Le contrat : AbstractAuthenticator


Tout authenticator personnalisé étend AbstractAuthenticator et implémente quatre méthodes. Elles sont appelées dans un ordre précis par le système de sécurité.

final class SignatureAuthenticator extends AbstractAuthenticator
{
    public function __construct(private UserRepository $users) {}

    // 1. Faut-il que cet authenticator traite la requête ?
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-Signature')
            && $request->headers->has('X-Client-Id');
    }

    // 2. Construire la preuve d'identité
    public function authenticate(Request $request): Passport
    {
        $clientId = $request->headers->get('X-Client-Id');

        return new SelfValidatingPassport(
            new UserBadge($clientId, fn (string $id) => $this->users->findOneByClientId($id)),
            [new SignatureBadge($request)], // badge custom, vérifié plus bas
        );
    }

    // 3. Que faire en cas de succès ?
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // null = laisser la requête continuer vers le contrôleur
    }

    // 4. Que faire en cas d'échec ?
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(['error' => $exception->getMessageKey()], 401);
    }
}

Le point clé du retour de supports() :

  • true : cet authenticator traite la requête.
  • false : il est ignoré pour cette requête.
  • null : il pourra être réessayé plus tard dans le cycle (utile pour le mode lazy).

Le Passport et ses badges


authenticate() ne vérifie rien lui-même : il déclare ce qu'il faut vérifier sous forme de badges. C'est de la composition, pas de l'héritage. Le système de sécurité résout ensuite chaque badge.

Type de PassportUsage
PassportCas général, attend au moins un UserBadge et des credentials
SelfValidatingPassportQuand l'identité est déjà prouvée par la requête (token opaque, signature) et qu'il n'y a pas de mot de passe à vérifier

Les badges les plus courants :

BadgeRôle
UserBadgeIdentifie l'utilisateur et fournit le moyen de le charger
PasswordCredentialsDéclenche la vérification du mot de passe haché
CsrfTokenBadgeExige un token CSRF valide
RememberMeBadgeAutorise la pose d'un cookie remember-me
PasswordUpgradeBadgePermet le rehachage transparent du mot de passe

Le UserBadge mérite une attention particulière : son second argument est un user loader. S'il est omis, c'est le user provider du firewall qui charge l'utilisateur. Le fournir explicitement permet de contourner le provider, par exemple pour charger par un identifiant non standard.

Un badge sur mesure


Pour vérifier la signature de notre exemple, on crée un badge et son resolver. Le badge porte la donnée, le resolver porte la logique de vérification.

final class SignatureBadge implements BadgeInterface
{
    private bool $resolved = false;

    public function __construct(public readonly Request $request) {}

    public function markResolved(): void { $this->resolved = true; }
    public function isResolved(): bool { return $this->resolved; }
}
// Un listener vérifie le badge pendant l'authentification
#[AsEventListener(event: CheckPassportEvent::class)]
final class SignatureBadgeListener
{
    public function __construct(private ClientSecretProvider $secrets) {}

    public function __invoke(CheckPassportEvent $event): void
    {
        $passport = $event->getPassport();
        if (!$passport->hasBadge(SignatureBadge::class)) {
            return;
        }

        /** @var SignatureBadge $badge */
        $badge = $passport->getBadge(SignatureBadge::class);
        $request = $badge->request;

        $expected = hash_hmac('sha256', $request->getContent(), $this->secrets->forUser($passport->getUser()));
        if (!hash_equals($expected, (string) $request->headers->get('X-Signature'))) {
            throw new BadCredentialsException('Signature invalide.');
        }

        $badge->markResolved(); // un badge non résolu fait échouer l'authentification
    }
}

Tout badge présent dans le Passport doit être marqué résolu avant la fin du CheckPassportEvent, sinon l'authentification échoue. C'est ce qui garantit qu'aucune vérification déclarée n'est silencieusement ignorée.

Le cycle d'événements de l'authentification


Au-delà de l'authenticator, Symfony émet une série d'événements qui sont les bons points d'accroche pour la logique transverse (audit, MAJ de date de connexion, blocage conditionnel).

ÉvénementMomentUsage typique
CheckPassportEventAprès authenticate()Résoudre les badges, vérifications supplémentaires
AuthenticationTokenCreatedEventLe token est crééEnrichir le token (attributs, rôles dynamiques)
LoginSuccessEventAuthentification réussieMettre à jour lastLogin, journaliser
LoginFailureEventAuthentification échouéeCompter les échecs, alerter
LogoutEventDéconnexionInvalider un token, nettoyer
#[AsEventListener]
final class UpdateLastLoginListener
{
    public function __construct(private EntityManagerInterface $em) {}

    public function __invoke(LoginSuccessEvent $event): void
    {
        $user = $event->getUser();
        if ($user instanceof User) {
            $user->setLastLoginAt(new \DateTimeImmutable());
            $this->em->flush();
        }
    }
}

Placer cette logique dans des listeners plutôt que dans onAuthenticationSuccess() la garde réutilisable quel que soit l'authenticator employé : login par formulaire, JWT ou signature passent tous par LoginSuccessEvent.

Enregistrer l'authenticator


Il suffit de le référencer dans le firewall via la clé custom_authenticators :

security:
  firewalls:
    api:
      pattern: ^/api
      stateless: true
      custom_authenticators:
        - App\Security\SignatureAuthenticator

L'autowiring fait le reste : pas de déclaration de service à écrire.

Résumé


NotionÀ retenir
supports()Décide si l'authenticator traite la requête (true/false/null)
authenticate()Déclare les vérifications via un Passport et ses badges, ne vérifie rien lui-même
SelfValidatingPassportPour les preuves sans mot de passe (token, signature)
Badge non résoluFait échouer l'authentification : aucune vérification n'est oubliée
Événements d'authLoginSuccessEvent et consorts : la place de la logique transverse
custom_authenticatorsLa seule ligne de config pour brancher son authenticator

Un authenticator bien écrit ne contient que de la plomberie : extraire la preuve et déclarer les badges. Toute la logique de vérification vit dans des badges et des listeners, testables isolément et réutilisables d'un firewall à l'autre.