← Tous les cours / Symfony Security avancé Cours
00 — ARCHITECTURE SECURITY

Architecture Security — vue d'ensemble

Security Symfony est une pile en couches — firewall, authenticator, token, voter — chaque couche a un rôle précis.

Quand une requête HTTP entre dans Symfony, elle traverse une pile de 4 couches avant que ton code métier soit exécuté. Chaque couche a une responsabilité précise — aucune ne fait le travail d'une autre.

Requête HTTP
Firewall
Authenticator
Token
Voter
Contrôleur

Les 4 couches

1. Firewall
Première couche. Il détermine si la requête entre dans une zone protégée et délègue à un authenticator. Configuré dans security.yaml sous firewalls:. S'il n'y a pas de firewall pour l'URL courante, Security est inactif.
2. Authenticator
Reçoit la requête, extrait les credentials (formulaire, token JWT, header HTTP…), et construit un Passport. Si l'authentification réussit, il crée un Token et l'enregistre dans le contexte de sécurité.
3. Token
Preuve d'identité stockée en mémoire (et en session pour les firewalls stateful). Il contient l'utilisateur, ses rôles et des attributs supplémentaires. Un NullToken représente les anonymes.
4. Voter
Consulté à chaque isGranted() ou #[IsGranted]. Il reçoit le token, le sujet et les attributs demandés. Il vote GRANTED, DENIED ou ABSTAIN. L'AccessDecisionManager agrège les votes.

AuthN vs AuthZ

Authentification (AuthN)

Qui es-tu ? — Vérifier l'identité.
Couches : Firewall + Authenticator + Token.
Résultat : un TokenInterface dans le contexte.

Autorisation (AuthZ)

Peux-tu faire ça ? — Vérifier les permissions.
Couche : Voters + AccessDecisionManager.
Résultat : GRANTED ou DENIED.

security.yaml — point d'entrée

Tout passe par ce fichier. La structure minimale :

# config/packages/security.yaml
security:
    providers:
        app_user_provider:
            entity: { class: App\Entity\User, property: email }

    firewalls:
        main:
            lazy: true
            provider: app_user_provider
            custom_authenticators:
                - App\Security\LoginFormAuthenticator

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

Lance la simulation pour voir les 4 couches s'activer sur une requête :

Clique sur Envoyer une requête pour visualiser le flux.

01 — FIREWALLS

Firewalls — la frontière d'authentification

Un firewall délimite une zone de l'application avec ses propres règles d'authentification.

Un firewall Symfony est une zone de l'application avec ses propres règles d'authentification. Quand une requête entre, Symfony cherche le premier firewall dont le pattern: correspond à l'URL — et lui délègue la gestion de la sécurité.

Firewall = zone + stratégie
Un firewall définit qui peut entrer (access_control), comment s'identifier (authenticators) et depuis quelle source charger l'utilisateur (provider). Un firewall nommé dev couvre généralement ^/(_(profiler|wdt)|css|images|js)/ sans authentification.

stateless vs stateful

stateless: false (défaut)

Le token est sérialisé en session après authentification. Les requêtes suivantes le désérialisent. Adapté aux apps web traditionnelles avec formulaire de login.

stateless: true

Chaque requête est authentifiée de zéro (JWT, API key, OAuth token). Aucune session. Requis pour les APIs RESTful.

Authenticators intégrés

Clé YAMLUsageFormat des credentials
form_login Formulaire HTML traditionnel POST _username + _password
json_login Login API JSON Body JSON {"username":"…","password":"…"}
http_basic Header HTTP Basic Auth Authorization: Basic base64(user:pass)
custom_authenticators Ton propre authenticator N'importe quelle stratégie

Délimiter le périmètre

firewalls:
    api:
        pattern: ^/api/         # regex sur l'URL
        stateless: true
        custom_authenticators:
            - App\Security\JwtAuthenticator

    main:
        lazy: true               # token chargé en session uniquement si besoin
        provider: app_user_provider
        form_login:
            login_path: app_login
            check_path: app_login
💡 lazy: true — le token n'est chargé depuis la session que si la requête appelle réellement isGranted() ou accède à l'utilisateur. Pour les routes publiques, aucune désérialisation n'a lieu. Active-le par défaut sur le firewall main.

access_denied_handler

Par défaut, une requête non autorisée reçoit une 403 générique. Pour personnaliser :

firewalls:
    main:
        access_denied_handler: App\Security\AccessDeniedHandler

// La classe implémente AccessDeniedHandlerInterface :
public function handle(Request $request, AccessDeniedException $exception): Response
{
    // Rediriger, retourner du JSON, logger…
}
🔑 À retenir : les firewalls sont testés dans l'ordre de déclaration dans security.yaml. Le premier qui matche est utilisé — les suivants sont ignorés. Déclare toujours le firewall le plus spécifique (api, admin) avant le firewall générique (main).
02 — AUTHENTICATORS

Authenticators — Passport et badges

Un authenticator implémente une stratégie d'authentification — il crée un Passport qui contient l'identité et les credentials.

Un authenticator custom implémente AuthenticatorInterface — 5 méthodes qui couvrent l'ensemble du cycle d'authentification : détecter les credentials, les valider, créer le token, et réagir au succès ou à l'échec.

AuthenticatorInterface — les 5 méthodes

MéthodeSignatureRôle
supports() supports(Request): ?bool La requête contient-elle des credentials pour cet authenticator ? Retourner null = pas de support (l'authenticator est ignoré silencieusement)
authenticate() authenticate(Request): Passport Extraire les credentials et construire le Passport
createToken() createToken(Passport, string $firewallName): TokenInterface Créer le token à partir du Passport validé
onAuthenticationSuccess() onAuthenticationSuccess(Request, TokenInterface, string $firewallName): ?Response Que faire après succès — redirection, null pour continuer la requête
onAuthenticationFailure() onAuthenticationFailure(Request, AuthenticationException): ?Response Que faire après échec — redirection vers le login, réponse 401…
AbstractAuthenticator
Implémente createToken() par défaut avec un PostAuthenticationToken. Étends-le quand tu n'as pas besoin d'un token custom — c'est le cas dans 95% des situations.

Passport — le contrat d'authentification

Le Passport est l'objet qui circule entre les listeners pour valider les credentials. Il est construit dans authenticate() et doit avoir tous ses badges résolus après validation.

use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
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\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;

public function authenticate(Request $request): Passport
{
    $email    = $request->request->getString('email');
    $password = $request->request->getString('password');

    return new Passport(
        new UserBadge($email), // UserProvider::loadUserByIdentifier($email)
        new PasswordCredentials($password),
        [
            new CsrfTokenBadge('authenticate', $request->request->getString('_csrf_token')),
            new RememberMeBadge(),
        ]
    );
}

SelfValidatingPassport — pour les credentials externes

Quand la validation se fait en dehors de Symfony (OAuth, JWT, clé API) et que tu n'as pas de mot de passe à vérifier :

use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

return new SelfValidatingPassport(
    new UserBadge($jwtPayload['sub'], fn (string $id) => $this->userRepository->find($id))
);

Badges — les extensions du Passport

BadgeRôleRésolu par
UserBadge Charger l'utilisateur par son identifiant UserProvider (ou le callable passé en 2e argument)
PasswordCredentials Vérifier le mot de passe CheckCredentialsListener via PasswordHasher
CsrfTokenBadge Valider le token CSRF CsrfProtectionListener
RememberMeBadge Créer un cookie remember-me RememberMeListener
⚠️ Badge::isResolved() — après l'authentification, Symfony vérifie que tous les badges du Passport ont isResolved() === true. Un badge non résolu lève une AuthenticationException. Si tu crées un badge custom, assure-toi qu'un listener le marque comme résolu ($badge->markResolved()).

Exemple complet — authenticator JSON

#[AsAuthenticator]
final class ApiTokenAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('X-API-TOKEN');
    }

    public function authenticate(Request $request): Passport
    {
        $token = $request->headers->get('X-API-TOKEN');

        return new SelfValidatingPassport(
            new UserBadge($token, fn (string $t) => $this->apiTokenRepository->findUserByToken($t))
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // continuer la requête
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED);
    }
}
03 — USERINTERFACE & PROVIDER

UserInterface et UserProvider

UserInterface définit le contrat minimal d'un utilisateur — getUserIdentifier(), getRoles(). Le UserProvider charge l'utilisateur depuis une source de données.

UserInterface est le contrat que tout utilisateur Symfony doit respecter. C'est la définition minimale — deux méthodes obligatoires, une optionnelle. Le UserProvider est responsable de charger l'utilisateur depuis ta source de données (base, LDAP, API…).

UserInterface — le contrat minimal

use Symfony\Component\Security\Core\User\UserInterface;

final class User implements UserInterface
{
    public function getUserIdentifier(): string
    {
        return $this->email; // l'identifiant unique — email, username, UUID…
    }

    public function getRoles(): array
    {
        return array_unique([...$this->roles, 'ROLE_USER']);
        // ROLE_USER doit toujours être présent pour les utilisateurs authentifiés
    }

    public function eraseCredentials(): void
    {
        // Optionnel en Symfony 8.1 — mais bonne pratique d'effacer le mot de passe en clair
        $this->plainPassword = null;
    }
}
getUserIdentifier()
Retourne l'identifiant unique de l'utilisateur — utilisé par le UserProvider pour le recharger depuis la source de données entre les requêtes. Doit être stable (ne pas changer en cours de vie de la session).

UserProvider — charger l'utilisateur

Le provider reçoit un identifiant et retourne un UserInterface ou lève une UserNotFoundException.

EntityUserProvider — intégration Doctrine

# config/packages/security.yaml
providers:
    app_user_provider:
        entity:
            class: App\Entity\User
            property: email   # champ Doctrine utilisé pour loadUserByIdentifier()

UserProvider custom

use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;

final class UserProvider implements UserProviderInterface
{
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        $user = $this->repository->findByEmail($identifier);

        if ($user === null) {
            throw new UserNotFoundException(sprintf('Utilisateur "%s" introuvable.', $identifier));
        }

        return $user;
    }

    public function refreshUser(UserInterface $user): UserInterface
    {
        return $this->loadUserByIdentifier($user->getUserIdentifier());
    }

    public function supportsClass(string $class): bool
    {
        return $class === User::class || is_subclass_of($class, User::class);
    }
}

UserCheckerInterface — vérifier l'état du compte

Le checker est appelé avant (checkPreAuth) et après (checkPostAuth) l'authentification pour vérifier l'état du compte (banni, email non confirmé, abonnement expiré…).

use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;

final class UserChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user): void
    {
        if (!$user instanceof User) { return; }

        if ($user->isBanned()) {
            throw new CustomUserMessageAccountStatusException('Compte suspendu.');
        }
    }

    public function checkPostAuth(UserInterface $user): void {}
}
# security.yaml — associer le checker au firewall
firewalls:
    main:
        user_checker: App\Security\UserChecker
🔑 eraseCredentials() optionnel en 8.1 — la méthode existe toujours dans UserInterface (signée eraseCredentials(): void), mais Symfony 8.1 ne la force plus avec une déclaration explicite dans l'interface (elle a une implémentation vide par défaut). Il est tout de même recommandé de l'implémenter pour effacer le mot de passe en clair si tu le stockes temporairement dans la propriété de l'entité.
04 — VOTERS

Voters — le système d'autorisation

Un voter répond à la question "peut-il faire ça ?" — il reçoit le token, le sujet et les attributs, et vote GRANTED, DENIED ou ABSTAIN.

Un voter est l'unité de base du système d'autorisation. Il répond à une seule question : "pour cet attribut et ce sujet, l'utilisateur est-il autorisé ?" Sa réponse est un vote parmi trois valeurs. L'AccessDecisionManager agrège tous les votes.

VoterInterface — les 3 votes

use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

// Constantes de VoterInterface :
VoterInterface::ACCESS_GRANTED  // = 1  — accès accordé
VoterInterface::ACCESS_ABSTAIN  // = 0  — ce voter ne se prononce pas
VoterInterface::ACCESS_DENIED   // = -1 — accès refusé
Quand voter ABSTAIN ?
Quand ton voter ne supporte pas cet attribut ou ce type de sujet. Il ne doit pas influencer la décision — d'autres voters prendront le relais. Ce n'est pas un refus, c'est une abstention.

La classe abstraite Voter

Dans 99% des cas, tu étends Voter plutôt qu'implémenter VoterInterface directement. Elle gère le routage vers tes deux méthodes.

use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

final class PostVoter extends Voter
{
    public const string EDIT   = 'POST_EDIT';
    public const string DELETE = 'POST_DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        // Ce voter ne gère que POST_EDIT et POST_DELETE sur des Post
        return in_array($attribute, [self::EDIT, self::DELETE], true)
            && $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        $user = $token->getUser();

        if (!$user instanceof User) {
            return false; // non authentifié → DENIED
        }

        /** @var Post $subject — garanti par supports() */
        return match ($attribute) {
            self::EDIT   => $subject->getAuthor() === $user || in_array('ROLE_MODERATOR', $user->getRoles(), true),
            self::DELETE => in_array('ROLE_ADMIN', $user->getRoles(), true),
            default       => throw new \LogicException("Attribut non géré : {$attribute}"),
        };
    }
}
💡 Lancer une exception dans le default — si supports() est correct, le default de ton match ne devrait jamais être atteint. Une LogicException ici signale un bug de programmation, pas une erreur métier.

CacheableVoterInterface — optimisation

Si ton voter supporte des attributs fixes (pas de logique runtime pour décider), implémente CacheableVoterInterface. Symfony peut alors éviter d'appeler supports() pour chaque vote.

use Symfony\Component\Security\Core\Authorization\Voter\CacheableVoterInterface;

final class PostVoter extends Voter implements CacheableVoterInterface
{
    public function supportsAttribute(string $attribute): bool
    {
        return in_array($attribute, [self::EDIT, self::DELETE], true);
    }

    public function supportsType(string $subjectType): bool
    {
        return is_a($subjectType, Post::class, true);
    }
}

Expérimente les 3 stratégies ci-dessous. Règle les votes de chaque voter et choisis une stratégie :

Définis les votes des 3 voters, puis clique sur une stratégie.

Voter 1
Voter 2
Voter 3
05 — #[ISGRANTED]

#[IsGranted] et #[Security]

Ces attributs PHP 8 remplacent les appels manuels à denyAccessUnlessGranted() — ils déclarent les permissions directement sur la méthode ou la classe.

#[IsGranted] et #[Security] sont des attributs PHP 8 qui permettent de déclarer les permissions directement sur la méthode ou la classe. Fini les appels $this->denyAccessUnlessGranted() au début de chaque action.

#[IsGranted]

use Symfony\Component\Security\Http\Attribute\IsGranted;

// Sur une méthode — accès à cette action uniquement si ROLE_ADMIN
#[IsGranted('ROLE_ADMIN')]
public function adminDashboard(): Response { ... }

// Sur la classe — toutes les actions nécessitent ROLE_USER
#[IsGranted('ROLE_USER')]
final class AccountController extends AbstractController { ... }

Passer un sujet au voter

// $post est résolu depuis les paramètres de la route (MapEntity / EntityValueResolver)
#[IsGranted('POST_EDIT', subject: 'post')]
public function edit(#[MapEntity] Post $post): Response { ... }
subject: 'post'
La valeur de subject: est le nom du paramètre de la méthode (sans $). Symfony résout la variable au moment du vote. Tu peux aussi passer une expression : subject: 'post.getAuthor()'.

Contrôler le code HTTP avec statusCode

// Retourner 404 plutôt que 403 — éviter d'informer qu'une ressource existe
#[IsGranted('POST_VIEW', subject: 'post', statusCode: 404)]
public function show(Post $post): Response { ... }

IS_REPEATABLE — empiler les conditions

Plusieurs #[IsGranted] sur la même méthode forment un ET logique — toutes les conditions doivent être vraies.

#[IsGranted('ROLE_USER')]
#[IsGranted('POST_PUBLISH', subject: 'post')]
public function publish(Post $post): Response { ... }

Limiter aux verbes HTTP avec methods

// Ce check s'applique uniquement aux POST et PUT — pas aux GET
#[IsGranted('POST_EDIT', subject: 'post', methods: ['POST', 'PUT'])]
public function editForm(Post $post): Response { ... }

#[Security] — expressions complexes

Pour les conditions qui combinent plusieurs vérifications avec une logique booléenne :

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
// ou (Symfony natif depuis 6.3) :
use Symfony\Component\Security\Http\Attribute\Security;

#[Security('is_granted("ROLE_ADMIN") or (is_granted("ROLE_EDITOR") and user.getOrg() == object.getOrg())')]
public function edit(Post $object): Response { ... }
#[IsGranted]
  • Syntaxe simple, lisible
  • Supporte subject:, statusCode:, methods:
  • IS_REPEATABLE pour les ET logiques
  • Préférable dans 90% des cas
#[Security]
  • Expression complexe avec opérateurs
  • Accès à user et aux objets nommés
  • Requiert le composant expression-language
  • Pour les cas que #[IsGranted] ne couvre pas
💡 vs denyAccessUnlessGranted() — l'approche attribut est préférable pour la lisibilité et la testabilité. Mais denyAccessUnlessGranted() reste utile dans les services (non-controllers) ou quand la condition dépend d'une logique calculée à l'exécution impossible à exprimer statiquement.
06 — ACCESSDECISIONMANAGER

AccessDecisionManager — les 3 stratégies

L'AccessDecisionManager agrège les votes de tous les voters — la stratégie choisie détermine comment les votes sont combinés.

Quand tu appelles isGranted(), Symfony consulte tous les voters enregistrés. Chacun vote GRANTED, DENIED ou ABSTAIN. L'AccessDecisionManager agrège ces votes selon une stratégie configurable.

Les 3 stratégies

AffirmativeStrategy (défaut)
Accès accordé dès qu'au moins un voter vote GRANTED. Si tous s'abstiennent, comportement contrôlé par allowIfAllAbstainDecisions (défaut : false → DENIED). Adaptée à la plupart des applications.
UnanimousStrategy
Accès accordé uniquement si aucun voter ne vote DENIED. Un seul DENIED suffit à bloquer l'accès. Les ABSTAIN sont ignorés. Utilisée pour les ressources sensibles où tout doute doit être résolu en faveur du refus.
ConsensusStrategy
Compare le nombre de GRANTED vs DENIED. Accès accordé si GRANTED > DENIED. Les ABSTAIN ne comptent pas. En cas d'égalité, allowIfEqualGrantedDeniedDecisions (défaut : true) détermine l'issue. Rarement utilisée en pratique.

Configuration

# config/packages/security.yaml
security:
    access_decision_manager:
        strategy: affirmative         # affirmative | unanimous | consensus | priority
        allow_if_all_abstain: false   # défaut : false

Guide de choix

StratégieQuand l'utiliser
affirmative La plupart des apps — un voter positif suffit (ROLE_ADMIN ou auteur du post)
unanimous Ressources très sensibles — paiement, données médicales, documents confidentiels
consensus Systèmes de modération où plusieurs votes indépendants ont le même poids

Compare les 3 stratégies sur 4 scénarios de votes :

Résultat selon la stratégie pour différents scénarios de votes :

07 — SECURITY SERVICE

Security service — l'API programmatique

Le service Security est la façade programmatique pour l'authentification et l'autorisation — isGranted(), login(), logout().

Le service Symfony\Bundle\SecurityBundle\Security est la porte d'entrée programmatique pour tout ce qui touche à la sécurité dans tes services et controllers. Authentification, autorisation, connexion, déconnexion — tout passe par là.

Injection et méthodes principales

use Symfony\Bundle\SecurityBundle\Security;

final class MyService
{
    public function __construct(private readonly Security $security) {}

    public function doSomething(): void
    {
        // Récupérer l'utilisateur courant (null si anonyme)
        $user = $this->security->getUser();

        // Vérifier une permission
        if (!$this->security->isGranted('POST_EDIT', $post)) {
            throw new AccessDeniedException();
        }

        // Lire le token brut
        $token = $this->security->getToken(); // TokenInterface|null
    }
}

isGrantedForUser — vérifier pour un autre utilisateur

// Vérifier si $otherUser peut éditer $post, sans changer le contexte courant
$canEdit = $this->security->isGrantedForUser($otherUser, 'POST_EDIT', $post);

login() — authentification programmatique

Authentifier un utilisateur sans passer par le formulaire — utile après une inscription ou un changement de mot de passe.

use Symfony\Bundle\SecurityBundle\Security;

// Dans un controller :
public function register(Request $request): Response
{
    // ... créer l'utilisateur ...

    // Connecter automatiquement après inscription
    $response = $this->security->login(
        $user,
        LoginFormAuthenticator::class, // authenticator à utiliser
        'main',                          // nom du firewall
    );

    return $response ?? $this->redirectToRoute('app_home');
}

logout() — déconnexion programmatique

$response = $this->security->logout(validateCsrfToken: true);
return $response ?? $this->redirectToRoute('app_login');

Security vs AuthorizationCheckerInterface

Security

Façade complète : isGranted, getUser, getToken, login, logout, getFirewallConfig. Injecter dans les controllers et les services qui ont besoin de plusieurs fonctionnalités.

AuthorizationCheckerInterface

Interface étroite : seulement isGranted(). Préférable dans les services métier qui n'ont besoin que de vérifier des permissions — principe d'interface minimale.

use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

final class PostService
{
    public function __construct(
        private readonly AuthorizationCheckerInterface $authz,
    ) {}

    public function canEdit(Post $post): bool
    {
        return $this->authz->isGranted('POST_EDIT', $post);
    }
}
🔑 getFirewallConfig()$this->security->getFirewallConfig($request) retourne un FirewallConfig avec le nom du firewall, les authenticators enregistrés, si le firewall est stateless, etc. Utile dans les listeners ou les exceptions handlers qui doivent adapter leur comportement selon la zone de l'application.
08 — TOKENS

Tokens — la preuve d'identité

Un token est la preuve qu'un utilisateur a été authentifié — il porte l'identité, les rôles et des attributs pendant la durée de la requête.

Un token est la preuve qu'une authentification a réussi. Il vit en mémoire (et en session pour les apps stateful) pendant toute la durée de la requête. Il porte l'utilisateur, ses rôles et des attributs supplémentaires que tu peux lire ou écrire.

TokenInterface — les méthodes clés

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

// Dans un voter, tu reçois le token en paramètre :
protected function voteOnAttribute(string $attr, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
    $identifier = $token->getUserIdentifier(); // email, username…
    $roles      = $token->getRoleNames();       // ['ROLE_USER', 'ROLE_ADMIN']
    $user       = $token->getUser();            // UserInterface|null

    // Attributs personnalisés sur le token :
    $token->setAttribute('impersonating', true);
    $val = $token->getAttribute('impersonating'); // true
    return true;
}

PostAuthenticationToken

C'est le token créé par défaut par AbstractAuthenticator::createToken() après une authentification réussie. Tu n'as généralement pas besoin de l'instancier manuellement.

use Symfony\Component\Security\Core\Authentication\Token\PostAuthenticationToken;

// Créé automatiquement par AbstractAuthenticator :
new PostAuthenticationToken(
    $user,                    // UserInterface
    'main',                   // nom du firewall
    $user->getRoles(),        // rôles résolus avec la hiérarchie
);

NullToken — l'anonyme

Quand aucun utilisateur n'est authentifié, Symfony utilise un NullToken. Il expose les mêmes méthodes que TokenInterface, mais avec des valeurs vides.

use Symfony\Component\Security\Core\Authentication\Token\NullToken;

// NullToken — comportement :
$nullToken->getUserIdentifier(); // retourne ''
$nullToken->getRoleNames();      // retourne []
$nullToken->getUser();           // retourne null
$nullToken->setAttribute('x', true); // lève BadMethodCallException
⚠️ Tester le type du token dans un voter — si ton voter accepte des requêtes anonymes, vérifie $token instanceof NullToken avant d'appeler getUser(). Sinon, récupère l'utilisateur et vérifie qu'il est bien une instance de ta classe User.

Stockage en session et role_hierarchy

Pour les firewalls stateful, le token est sérialisé en session après chaque requête et désérialisé au début de la suivante. L'utilisateur est rechargé via UserProvider::refreshUser() à chaque requête pour refléter les changements en DB.

# security.yaml — role_hierarchy
security:
    role_hierarchy:
        ROLE_ADMIN:  [ROLE_USER, ROLE_MODERATOR]
        ROLE_SUPER_ADMIN: [ROLE_ADMIN]

La hiérarchie est appliquée par RoleHierarchyVoter. Quand tu appelles isGranted('ROLE_USER') pour un ROLE_ADMIN, RoleHierarchyVoter étend les rôles du token avant de voter.

getRoleNames() vs getRoles()
TokenInterface::getRoleNames() retourne les rôles tels qu'ils sont dans le token — sans appliquer la hiérarchie. La hiérarchie est appliquée à l'heure du vote par RoleHierarchyVoter, pas au moment de la création du token.
09 — SYNTHÈSE

Synthèse — patterns et anti-patterns

Les patterns éprouvés et les pièges à éviter avec Security Symfony.

Security Symfony est une pile cohérente et extensible. La difficulté n'est pas d'utiliser chaque outil — c'est de choisir le bon outil face au bon besoin. Voici le guide de choix et les 4 pièges à éviter.

Guide de choix

BesoinOutil
Protéger une route entière (par URL) access_control: dans security.yaml
Protéger une action de controller #[IsGranted]
Condition complexe avec opérateurs booléens #[Security('expression')]
Logique d'autorisation réutilisable sur un objet métier Voter (extends Voter)
Vérification programmatique dans un service AuthorizationCheckerInterface::isGranted()
Authentification custom (JWT, clé API, OAuth) AbstractAuthenticator
Authentifier après inscription ou SSO Security::login()
Ressource ultra-sensible (0 tolérance sur le DENIED) Passer en strategy: unanimous

Anti-patterns à éviter

🚫 Mettre la logique d'autorisation dans une entité.
$post->canBeEditedBy($user) dans l'entité crée une dépendance entre ton domaine et les règles métier d'autorisation. Quand les règles changent (nouveau rôle, nouvelle condition), tu touches l'entité. Les voters séparent cette responsabilité — utilise-les.
🚫 Injecter Security dans un voter.
$this->security->getUser() dans un voter crée un risque de dépendance circulaire. Le voter reçoit déjà le TokenInterface en paramètre — utilise $token->getUser(). C'est la voie officielle.
🚫 Retourner ACCESS_ABSTAIN quand on devrait voter DENIED.
Si ton voter connaît la ressource (elle passe supports()) et que l'utilisateur n'a manifestement pas le droit, vote DENIED. Voter ABSTAIN laisse la décision à d'autres voters — qui peuvent accorder l'accès à tort si la stratégie est affirmative.
🚫 Mauvaise signature de voteOnAttribute().
En Symfony 8.1, la signature correcte est :
voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
Oublier le paramètre ?Vote $vote = null ne cause pas d'erreur immédiate (le paramètre est optionnel), mais ta classe ne correspondra pas exactement au contrat de la classe parente — comportement imprévisible si Symfony passe un objet Vote.

Récapitulatif des classes et interfaces clés

Classe / InterfaceNamespaceRôle
Security Symfony\Bundle\SecurityBundle Façade principale — isGranted, login, logout, getUser
AuthorizationCheckerInterface Symfony\Component\Security\Core\Authorization Interface étroite — seulement isGranted()
Voter Symfony\Component\Security\Core\Authorization\Voter Classe abstraite à étendre pour les voters custom
CacheableVoterInterface Symfony\Component\Security\Core\Authorization\Voter Optimisation voter — supportsAttribute() + supportsType()
AuthenticatorInterface Symfony\Component\Security\Http\Authenticator Contrat d'un authenticator custom — 5 méthodes
AbstractAuthenticator Symfony\Component\Security\Http\Authenticator Implémentation partielle — createToken() fourni
Passport Symfony\Component\Security\Http\Authenticator\Passport Contrat d'authentification avec badges
SelfValidatingPassport Symfony\Component\Security\Http\Authenticator\Passport Passport sans credentials Symfony (JWT, OAuth)
PostAuthenticationToken Symfony\Component\Security\Core\Authentication\Token Token créé après auth réussie
NullToken Symfony\Component\Security\Core\Authentication\Token Token anonyme — setters lancent BadMethodCallException
UserInterface Symfony\Component\Security\Core\User Contrat minimal de l'utilisateur
UserCheckerInterface Symfony\Component\Security\Core\User Vérifier l'état du compte avant/après auth
#[IsGranted] Symfony\Component\Security\Http\Attribute Attribut PHP 8 pour protéger une méthode ou classe