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.
Les 4 couches
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.
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é.
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.
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
Qui es-tu ? — Vérifier l'identité.
Couches : Firewall + Authenticator + Token.
Résultat : un TokenInterface dans le contexte.
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.
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é.
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
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.
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é YAML | Usage | Format 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
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…
}
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).
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éthode | Signature | Rô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… |
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
| Badge | Rôle | Ré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 |
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);
}
}
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;
}
}
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
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é.
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 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}"),
};
}
}
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.
#[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 { ... }
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 { ... }
- Syntaxe simple, lisible
- Supporte
subject:,statusCode:,methods: - IS_REPEATABLE pour les ET logiques
- Préférable dans 90% des cas
- Expression complexe avec opérateurs
- Accès à
useret aux objets nommés - Requiert le composant
expression-language - Pour les cas que #[IsGranted] ne couvre pas
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.
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
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.
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.
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égie | Quand 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 :
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
Façade complète : isGranted, getUser, getToken, login, logout, getFirewallConfig. Injecter dans les controllers et les services qui ont besoin de plusieurs fonctionnalités.
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);
}
}
$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.
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
$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.
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.
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
| Besoin | Outil |
|---|---|
| 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
$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.
$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.
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.
En Symfony 8.1, la signature correcte est :
voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): boolOublier 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 / Interface | Namespace | Rô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 |