Maîtriser la sécurité dans Symfony : de l'authentification simple aux permissions granulaires avec les Voters.
Avoir une entité User fonctionnelle (vue dans la session précédente) avec les interfaces UserInterface et PasswordAuthenticatedUserInterface.
security:
# Configuration du hashage des mots de passe
password_hashers:
App\Entity\User:
algorithm: auto
# Fournisseurs d'utilisateurs
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
# Configuration des firewalls
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: /dashboard
logout:
path: app_logout
target: /
# Contrôle d'accès
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }Utilise algorithm: auto pour que Symfony choisisse automatiquement le meilleur algorithme de hashage disponible.
# Générer le système d'authentification complet php bin/console make:auth # Générer le formulaire d'inscription php bin/console make:registration-form
// src/Controller/SecurityController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// Rediriger si déjà connecté
if ($this->getUser()) {
return $this->redirectToRoute('dashboard');
}
// Récupérer les erreurs de connexion
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
// Cette méthode peut rester vide
// La déconnexion est gérée par la clé logout dans security.yaml
throw new \LogicException('Cette méthode peut rester vide');
}
}{% extends 'base.html.twig' %}
{% block title %}Connexion{% endblock %}
{% block body %}
{% endblock %}# config/packages/security.yaml
security:
role_hierarchy:
ROLE_MODERATOR: ROLE_USER
ROLE_ADMIN: [ROLE_MODERATOR, ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]// Un ROLE_ADMIN aura automatiquement
// ROLE_USER et ROLE_MODERATOR
$this->denyAccessUnlessGranted('ROLE_ADMIN');use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController
{
#[IsGranted('ROLE_USER')]
#[Route('/profile')]
public function profile(): Response
{
// Accessible aux utilisateurs connectés
}
#[IsGranted('ROLE_ADMIN')]
#[Route('/admin/users')]
public function adminUsers(): Response
{
// Accessible aux admins uniquement
}
}public function editArticle(Article $article): Response
{
// Vérification simple
$this->denyAccessUnlessGranted('ROLE_USER');
// Vérification avec objet
$this->denyAccessUnlessGranted('EDIT', $article);
// Vérification conditionnelle
if (!$this->isGranted('ROLE_ADMIN')) {
throw $this->createAccessDeniedException();
}
// Récupérer l'utilisateur
$user = $this->getUser();
}{# templates/article/show.html.twig #}
{% if is_granted('ROLE_USER') %}
Modifier
{% endif %}
{% if is_granted('EDIT', article) %}
{% endif %}
{% if app.user and app.user == article.author %}
Vous êtes l'auteur de cet article
{% endif %}Ne jamais se fier uniquement aux vérifications côté template ! Toujours protéger les contrôleurs et les méthodes sensibles.
Un Voter est une classe qui décide si un utilisateur a le droit d'effectuer une action spécifique sur un objet donné.
Question : "Est-ce que cet utilisateur peut modifier cet article ?"
Voter : Vérifie si l'utilisateur est l'auteur OU a le rôle ADMIN
$this->denyAccessUnlessGranted('EDIT', $article)
# Générer un voter pour les articles php bin/console make:voter ArticleVoter
// src/Security/Voter/ArticleVoter.php
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class ArticleVoter extends Voter
{
public const EDIT = 'EDIT';
public const VIEW = 'VIEW';
public const DELETE = 'DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
// Remplace par ta propre logique
return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE])
&& $subject instanceof \App\Entity\Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// Si l'utilisateur est anonyme, refuse l'accès
if (!$user instanceof UserInterface) {
return false;
}
// Tu connais maintenant $attribute, $subject, $user
switch ($attribute) {
case self::EDIT:
return $this->canEdit($subject, $user);
case self::VIEW:
return $this->canView($subject, $user);
case self::DELETE:
return $this->canDelete($subject, $user);
}
return false;
}
}
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class ArticleVoter extends Voter
{
public const EDIT = 'EDIT';
public const VIEW = 'VIEW';
public const DELETE = 'DELETE';
public const PUBLISH = 'PUBLISH';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE, self::PUBLISH])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
/** @var Article $article */
$article = $subject;
switch ($attribute) {
case self::VIEW:
return $this->canView($article, $user);
case self::EDIT:
return $this->canEdit($article, $user);
case self::DELETE:
return $this->canDelete($article, $user);
case self::PUBLISH:
return $this->canPublish($article, $user);
}
return false;
}
private function canView(Article $article, User $user): bool
{
// Un article publié peut être vu par tous les utilisateurs connectés
if ($article->isPublished()) {
return true;
}
// Un article non publié ne peut être vu que par son auteur ou un admin
return $this->canEdit($article, $user);
}
private function canEdit(Article $article, User $user): bool
{
// L'auteur peut toujours modifier son article
if ($article->getAuthor() === $user) {
return true;
}
// Un admin peut modifier tous les articles
return in_array('ROLE_ADMIN', $user->getRoles());
}
private function canDelete(Article $article, User $user): bool
{
// Seuls les admins peuvent supprimer
return in_array('ROLE_ADMIN', $user->getRoles());
}
private function canPublish(Article $article, User $user): bool
{
// L'auteur peut publier son propre article
if ($article->getAuthor() === $user) {
return true;
}
// Un modérateur ou admin peut publier n'importe quel article
return in_array('ROLE_MODERATOR', $user->getRoles()) ||
in_array('ROLE_ADMIN', $user->getRoles());
}
}
// src/Controller/ArticleController.php
class ArticleController extends AbstractController
{
#[Route('/article/{id}', name: 'article_show')]
public function show(Article $article): Response
{
// Vérifier si l'utilisateur peut voir cet article
$this->denyAccessUnlessGranted('VIEW', $article);
return $this->render('article/show.html.twig', [
'article' => $article,
]);
}
#[Route('/article/{id}/edit', name: 'article_edit')]
public function edit(Article $article, Request $request): Response
{
// Vérifier si l'utilisateur peut modifier cet article
$this->denyAccessUnlessGranted('EDIT', $article);
// Logique d'édition...
return $this->render('article/edit.html.twig', [
'article' => $article,
]);
}
#[Route('/article/{id}/delete', name: 'article_delete', methods: ['POST'])]
public function delete(Article $article): Response
{
// Vérifier si l'utilisateur peut supprimer cet article
$this->denyAccessUnlessGranted('DELETE', $article);
// Logique de suppression...
return $this->redirectToRoute('article_list');
}
#[Route('/article/{id}/publish', name: 'article_publish', methods: ['POST'])]
public function publish(Article $article): Response
{
// Vérifier si l'utilisateur peut publier cet article
$this->denyAccessUnlessGranted('PUBLISH', $article);
$article->setIsPublished(true);
// Sauvegarder...
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
}{# templates/article/show.html.twig #}
{{ article.content }}
{% if is_granted('EDIT', article) %}
Modifier
{% endif %}
{% if is_granted('DELETE', article) %}
{% endif %}
{% if is_granted('PUBLISH', article) and not article.isPublished %}
{% endif %}
Le même Voter est utilisé partout : contrôleurs, templates, services. La logique est centralisée et cohérente !
namespace App\Security\Voter;
use App\Entity\User;
use App\Service\SubscriptionService;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class PremiumContentVoter extends Voter
{
public const VIEW_PREMIUM = 'VIEW_PREMIUM';
public function __construct(
private SubscriptionService $subscriptionService
) {
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW_PREMIUM;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match($attribute) {
self::VIEW_PREMIUM => $this->canViewPremium($user),
default => false,
};
}
private function canViewPremium(User $user): bool
{
// Vérifier via un service externe
return $this->subscriptionService->hasActivePremiumSubscription($user) ||
in_array('ROLE_ADMIN', $user->getRoles());
}
}private function canEditProject(Project $project, User $user): bool
{
// L'utilisateur est-il membre de ce projet ?
if ($project->getMembers()->contains($user)) {
return true;
}
// L'utilisateur est-il propriétaire de l'organisation ?
if ($project->getOrganization()->getOwner() === $user) {
return true;
}
// L'utilisateur a-t-il un rôle admin dans l'organisation ?
$membership = $project->getOrganization()
->getMemberships()
->filter(fn($m) => $m->getUser() === $user)
->first();
return $membership && $membership->getRole() === 'admin';
}Access Control Lists - Système de permissions très granulaires au niveau de chaque objet en base de données.
Les ACL ajoutent de la complexité et des requêtes SQL supplémentaires. Utilise-les seulement si les Voters ne suffisent pas !
"L'utilisateur Alice peut LIRE l'article #123, mais Bob peut LIRE et MODIFIER ce même article, et Carol peut tout faire dessus."
# Installer le bundle ACL composer require symfony/acl-bundle # Créer les tables ACL php bin/console acl:init
# config/services.yaml
services:
# ACL Provider
security.acl.provider:
class: Symfony\Component\Security\Acl\Dbal\MutableAclProvider
arguments:
- '@doctrine.dbal.default_connection'
- '@security.acl.permission_strategy'
- '@security.acl.cache'
# Permission Strategy
security.acl.permission_strategy:
class: Symfony\Component\Security\Acl\Domain\PermissionGrantingStrategy
# ACL Cache
security.acl.cache:
class: Symfony\Component\Security\Acl\Domain\DoctrineAclCache
arguments:
- '@doctrine.orm.default_entity_manager'
// src/Controller/ArticleController.php
use Symfony\Component\Security\Acl\Domain\ObjectIdentity;
use Symfony\Component\Security\Acl\Domain\UserSecurityIdentity;
use Symfony\Component\Security\Acl\Permission\MaskBuilder;
class ArticleController extends AbstractController
{
public function createArticle(Request $request): Response
{
$article = new Article();
// ... création de l'article ...
// Créer l'ACL pour cet article
$aclProvider = $this->container->get('security.acl.provider');
$objectIdentity = ObjectIdentity::fromDomainObject($article);
$acl = $aclProvider->createAcl($objectIdentity);
// Donner tous les droits à l'auteur
$securityIdentity = UserSecurityIdentity::fromAccount($this->getUser());
$acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
// Donner le droit de lecture aux utilisateurs connectés
$roleIdentity = new RoleSecurityIdentity('ROLE_USER');
$acl->insertObjectAce($roleIdentity, MaskBuilder::MASK_VIEW);
$aclProvider->updateAcl($acl);
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
}public function editArticle(Article $article): Response
{
// Vérifier avec les ACL
$this->denyAccessUnlessGranted('EDIT', $article);
// Ou manuellement
$authorizationChecker = $this->container->get('security.authorization_checker');
if (!$authorizationChecker->isGranted('EDIT', $article)) {
throw $this->createAccessDeniedException();
}
// ... logique d'édition ...
}public function shareArticle(Article $article, User $user, string $permission): Response
{
$this->denyAccessUnlessGranted('OWNER', $article);
$aclProvider = $this->container->get('security.acl.provider');
$objectIdentity = ObjectIdentity::fromDomainObject($article);
$acl = $aclProvider->findAcl($objectIdentity);
$securityIdentity = UserSecurityIdentity::fromAccount($user);
switch ($permission) {
case 'read':
$mask = MaskBuilder::MASK_VIEW;
break;
case 'write':
$mask = MaskBuilder::MASK_EDIT;
break;
case 'admin':
$mask = MaskBuilder::MASK_OPERATOR;
break;
}
$acl->insertObjectAce($securityIdentity, $mask);
$aclProvider->updateAcl($acl);
return $this->json(['status' => 'success']);
}Tu maîtrises maintenant l'authentification et l'autorisation dans Symfony ! Tu peux créer des applications sécurisées avec des permissions granulaires.