Formation DWWM - Développeur Web et Web Mobile
Comprendre et implémenter la sécurité dans vos applications Symfony
1. Authentification : "Qui êtes-vous ?" (login/mot de passe)
2. Autorisation : "Que pouvez-vous faire ?" (rôles et permissions)
3. Contrôle d'accès : "À quoi pouvez-vous accéder ?" (ACL)
💡 Bon à savoir : Symfony fournit un système de sécurité robuste et flexible qui couvre tous ces aspects !
Symfony utilise un système de sécurité basé sur plusieurs composants qui travaillent ensemble :
| Composant | Rôle | Exemple |
|---|---|---|
| Firewall | Point d'entrée de la sécurité | Configuration dans security.yaml |
| User Provider | Récupère les utilisateurs | Base de données, LDAP, mémoire |
| Encoder | Chiffre les mots de passe | bcrypt, sodium |
| Access Control | Règles d'accès aux routes | ROLE_USER, ROLE_ADMIN |
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
logout:
path: app_logoutACL (Access Control List) = Liste de contrôle d'accès
Une ACL définit qui peut faire quoi sur quelle ressource.
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }#[IsGranted('ROLE_ADMIN')]
public function adminDashboard(): Response
{
// Code du tableau de bord admin
}password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
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
enable_csrf: true
logout:
path: app_logout
target: app_login
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/profile, roles: ROLE_USER }Un Voter est une classe qui décide si un utilisateur a le droit d'effectuer une action sur un objet spécifique.
namespace App\Security;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const EDIT = 'ARTICLE_EDIT';
public const VIEW = 'ARTICLE_VIEW';
public const DELETE = 'ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Article $article */
$article = $subject;
return match($attribute) {
self::VIEW => $this->canView($article, $user),
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
default => false,
};
}// 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
{
// Seul un admin peut supprimer des articles
return in_array('ROLE_ADMIN', $user->getRoles());
}
}namespace App\Controller;
use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ArticleController extends AbstractController
{
#[Route('/article/{id}', name: 'article_show')]
#[IsGranted('ARTICLE_VIEW', subject: 'article')]
public function show(Article $article): Response
{
return $this->render('article/show.html.twig', [
'article' => $article,
]);
}
#[Route('/article/{id}/edit', name: 'article_edit')]
#[IsGranted('ARTICLE_EDIT', subject: 'article')]
public function edit(Article $article): Response
{
// Logique d'édition
return $this->render('article/edit.html.twig', [
'article' => $article,
]);
}
}L'attribut subject: 'article' fait référence au paramètre de la méthode qui contient l'objet à tester.
#[Route('/article/{id}/delete', name: 'article_delete', methods: ['POST'])]
public function delete(Article $article): Response
{
// Vérification des permissions
$this->denyAccessUnlessGranted('ARTICLE_DELETE', $article);
// Logique de suppression
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($article);
$entityManager->flush();
$this->addFlash('success', 'Article supprimé avec succès.');
return $this->redirectToRoute('article_list');
}#[Route('/article/{id}/admin-panel', name: 'article_admin_panel')]
public function adminPanel(Article $article): Response
{
$canEdit = $this->isGranted('ARTICLE_EDIT', $article);
$canDelete = $this->isGranted('ARTICLE_DELETE', $article);
return $this->render('article/admin_panel.html.twig', [
'article' => $article,
'can_edit' => $canEdit,
'can_delete' => $canDelete,
]);
}{% extends 'base.html.twig' %}
{% block title %}{{ article.title }}{% endblock %}
{% block body %}
<div class="article">
<h1>{{ article.title }}</h1>
<p class="author">Par {{ article.author.name }}</p>
<div class="content">
{{ article.content|nl2br }}
</div>
<div class="actions">
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('article_edit', {id: article.id}) }}"
class="btn btn-primary">
✏️ Modifier
</a>
{% endif %}
{% if is_granted('ARTICLE_DELETE', article) %}
<form method="post"
action="{{ path('article_delete', {id: article.id}) }}"
style="display: inline;">
<button type="submit"
class="btn btn-danger"
onclick="return confirm('Êtes-vous sûr ?')">
🗑️ Supprimer
</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}<nav class="navbar">
<ul>
<li><a href="{{ path('home') }}">Accueil</a></li>
{% if is_granted('ROLE_USER') %}
<li><a href="{{ path('article_list') }}">Articles</a></li>
<li><a href="{{ path('profile') }}">Mon profil</a></li>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<li><a href="{{ path('admin_dashboard') }}">Administration</a></li>
{% endif %}
</ul>
</nav>🎉 Bravo ! Vous êtes prêts à sécuriser vos applications Symfony !
<?php
namespace App\Entity;
use App\Entity\User;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
private ?string $content = null;
#[ORM\Column]
private ?bool $published = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
// Getters et setters...
public function isPublished(): bool
{
return $this->published ?? false;
}
}<?php
namespace App\Security;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const VIEW = 'ARTICLE_VIEW';
public const CREATE = 'ARTICLE_CREATE';
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
public const PUBLISH = 'ARTICLE_PUBLISH';
protected function supports(string $attribute, mixed $subject): bool
{
if ($attribute === self::CREATE) {
return true; // CREATE ne nécessite pas d'objet
}
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::PUBLISH])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match($attribute) {
self::CREATE => $this->canCreate($user),
self::VIEW => $this->canView($subject, $user),
self::EDIT => $this->canEdit($subject, $user),
self::DELETE => $this->canDelete($subject, $user),
self::PUBLISH => $this->canPublish($subject, $user),
default => false,
};
}
private function canCreate(User $user): bool
{
// Tout utilisateur connecté peut créer un article
return true;
}
private function canView(Article $article, User $user): bool
{
// Article publié = visible par tous les utilisateurs connectés
if ($article->isPublished()) {
return true;
}
// Article non publié = visible par l'auteur, éditeur ou admin
return $this->isOwner($article, $user)
|| $this->hasRole($user, 'ROLE_EDITOR')
|| $this->hasRole($user, 'ROLE_ADMIN');
}
private function canEdit(Article $article, User $user): bool
{
// L'auteur peut modifier son article
if ($this->isOwner($article, $user)) {
return true;
}
// Un éditeur ou admin peut modifier tous les articles
return $this->hasRole($user, 'ROLE_EDITOR')
|| $this->hasRole($user, 'ROLE_ADMIN');
}
private function canDelete(Article $article, User $user): bool
{
// Seul un admin peut supprimer
return $this->hasRole($user, 'ROLE_ADMIN');
}
private function canPublish(Article $article, User $user): bool
{
// Un éditeur ou admin peut publier
return $this->hasRole($user, 'ROLE_EDITOR')
|| $this->hasRole($user, 'ROLE_ADMIN');
}
private function isOwner(Article $article, User $user): bool
{
return $article->getAuthor() === $user;
}
private function hasRole(User $user, string $role): bool
{
return in_array($role, $user->getRoles());
}
}namespace App\Tests\Security;
use App\Entity\Article;
use App\Entity\User;
use App\Security\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class ArticleVoterTest extends TestCase
{
private ArticleVoter $voter;
protected function setUp(): void
{
$this->voter = new ArticleVoter();
}
public function testUserCanViewPublishedArticle(): void
{
$user = $this->createUser(['ROLE_USER']);
$article = $this->createArticle($this->createUser(), true);
$token = $this->createToken($user);
$result = $this->voter->vote($token, $article, ['ARTICLE_VIEW']);
$this->assertEquals(VoterInterface::ACCESS_GRANTED, $result);
}
public function testUserCannotViewUnpublishedArticleOfOtherUser(): void
{
$user = $this->createUser(['ROLE_USER']);
$otherUser = $this->createUser(['ROLE_USER']);
$article = $this->createArticle($otherUser, false);
$token = $this->createToken($user);
$result = $this->voter->vote($token, $article, ['ARTICLE_VIEW']);
$this->assertEquals(VoterInterface::ACCESS_DENIED, $result);
}
public function testAuthorCanEditOwnArticle(): void
{
$user = $this->createUser(['ROLE_USER']);
$article = $this->createArticle($user, false);
$token = $this->createToken($user);
$result = $this->voter->vote($token, $article, ['ARTICLE_EDIT']);
$this->assertEquals(VoterInterface::ACCESS_GRANTED, $result);
}
public function testEditorCanEditAnyArticle(): void
{
$editor = $this->createUser(['ROLE_EDITOR']);
$author = $this->createUser(['ROLE_USER']);
$article = $this->createArticle($author, false);
$token = $this->createToken($editor);
$result = $this->voter->vote($token, $article, ['ARTICLE_EDIT']);
$this->assertEquals(VoterInterface::ACCESS_GRANTED, $result);
}
public function testOnlyAdminCanDelete(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$user = $this->createUser(['ROLE_USER']);
$article = $this->createArticle($user, true);
// Test admin peut supprimer
$adminToken = $this->createToken($admin);
$result = $this->voter->vote($adminToken, $article, ['ARTICLE_DELETE']);
$this->assertEquals(VoterInterface::ACCESS_GRANTED, $result);
// Test utilisateur normal ne peut pas supprimer
$userToken = $this->createToken($user);
$result = $this->voter->vote($userToken, $article, ['ARTICLE_DELETE']);
$this->assertEquals(VoterInterface::ACCESS_DENIED, $result);
}
private function createUser(array $roles = []): User
{
$user = new User();
$user->setRoles($roles);
return $user;
}
private function createArticle(User $author, bool $published = false): Article
{
$article = new Article();
$article->setAuthor($author);
$article->setPublished($published);
return $article;
}
private function createToken(User $user): TokenInterface
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn($user);
return $token;
}
}Les expressions de sécurité permettent d'écrire des règles complexes directement dans les annotations/attributs.
class ArticleController extends AbstractController
{
// Expression simple : vérifier le propriétaire
#[Route('/article/{id}/edit')]
#[IsGranted("user === object.getAuthor()")]
public function edit(Article $object): Response { }
// Expression avec OR : propriétaire OU admin
#[Route('/article/{id}/moderate')]
#[IsGranted("user === object.getAuthor() or is_granted('ROLE_ADMIN')")]
public function moderate(Article $object): Response { }
// Expression avec méthodes : article publié ET utilisateur connecté
#[Route('/article/{id}/comment')]
#[IsGranted("object.isPublished() and is_granted('ROLE_USER')")]
public function addComment(Article $object): Response { }
// Expression complexe : éditeur dans la même catégorie
#[Route('/article/{id}/advanced-edit')]
#[IsGranted("is_granted('ROLE_EDITOR') and user.getCategory() === object.getCategory()")]
public function advancedEdit(Article $object): Response { }
}La hiérarchie des rôles permet de définir que certains rôles héritent automatiquement des permissions d'autres rôles.
role_hierarchy:
ROLE_AUTHOR: ROLE_USER
ROLE_EDITOR: [ROLE_AUTHOR, ROLE_USER]
ROLE_ADMIN: [ROLE_EDITOR, ROLE_AUTHOR, ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]private function canEdit(Article $article, User $user): bool
{
// Grâce à la hiérarchie, pas besoin de vérifier ROLE_ADMIN
// car ROLE_EDITOR l'inclut automatiquement
if ($this->isOwner($article, $user)) {
return true;
}
// Un éditeur peut modifier tous les articles
return $this->hasRole($user, 'ROLE_EDITOR');
}php bin/console debug:config security # Lister tous les voters php bin/console debug:container --tag=security.voter # Analyser les routes sécurisées php bin/console debug:router --show-controllers # Vérifier les rôles d'un utilisateur php bin/console security:check-roles ROLE_ADMIN
handlers:
security:
type: stream
path: '%kernel.logs_dir%/security.log'
level: debug
channels: ['security']// Dans votre Voter
use Psr\Log\LoggerInterface;
class ArticleVoter extends Voter
{
public function __construct(private LoggerInterface $logger)
{
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
$this->logger->info('ArticleVoter called', [
'attribute' => $attribute,
'user' => $user?->getUserIdentifier(),
'article_id' => $subject->getId(),
]);
// Votre logique...
}class ProjectVoter extends Voter
{
public const VIEW = 'PROJECT_VIEW';
public const EDIT = 'PROJECT_EDIT';
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
/** @var Project $project */
$project = $subject;
$user = $token->getUser();
return match($attribute) {
self::VIEW => $this->canView($project, $user),
self::EDIT => $this->canEdit($project, $user),
default => false,
};
}
private function canView(Project $project, User $user): bool
{
// Le propriétaire peut voir
if ($project->getOwner() === $user) {
return true;
}
// Les membres de l'équipe peuvent voir
if ($project->getTeamMembers()->contains($user)) {
return true;
}
// Les projets publics sont visibles par tous
return $project->isPublic();
}
private function canEdit(Project $project, User $user): bool
{
// Seul le propriétaire et les éditeurs peuvent modifier
return $project->getOwner() === $user
|| $project->getEditors()->contains($user);
}
}private function canEdit(Article $article, User $user): bool
{
// Vérification de propriétaire
if ($article->getAuthor() !== $user) {
return false;
}
// Interdire l'édition après 24h
$deadline = $article->getCreatedAt()->modify('+24 hours');
if (new \DateTimeImmutable() > $deadline) {
return false;
}
return true;
}private function canCreate(User $user): bool
{
// Vérifier le quota d'articles par mois
$thisMonth = new \DateTimeImmutable('first day of this month');
$articleCount = $this->articleRepository->countByAuthorSince($user, $thisMonth);
$maxArticlesPerMonth = match(true) {
$this->hasRole($user, 'ROLE_PREMIUM') => 50,
$this->hasRole($user, 'ROLE_USER') => 10,
default => 0,
};
return $articleCount < $maxArticlesPerMonth;
}N'hésitez pas à poser vos questions !
La sécurité est un domaine vast et en constante évolution.
🎉 Bonne route dans vos projets Symfony ! 🎉