Protéger votre application Symfony
Users • Login • Firewall • Roles • Voters
Formation DWWM - 2025
Authentification vs Autorisation
Authentification = Prouver qui tu es
Autorisation = Vérifier ce que tu peux faire
Question : Qui es-tu ?
Réponse : Login + mot de passe
Résultat : Tu es connecté
Question : Que peux-tu faire ?
Réponse : Vérification des rôles
Résultat : Accès autorisé ou refusé
| Composant | Rôle | Fichier de config |
|---|---|---|
| User | Entité utilisateur | src/Entity/User.php |
| UserProvider | Charge les utilisateurs depuis la BDD | Automatique avec Doctrine |
| PasswordHasher | Encode/vérifie les mots de passe | security.yaml |
| Firewall | Protège des URLs | security.yaml |
| Access Control | Règles d'accès par URL | security.yaml |
| Authenticator | Gère le processus de login | src/Security/ |
| Voters | Logique d'autorisation personnalisée | src/Security/Voter/ |
Créer la classe utilisateur
php bin/console make:user
$ php bin/console make:user
The name of the security user class (e.g. User) [User]:
> User
Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
> yes
Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
> email
Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).
Does this app need to hash/check user passwords? (yes/no) [yes]:
> yes
created: src/Entity/User.php
created: src/Repository/UserRepository.php
updated: config/packages/security.yaml
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
/**
* @var list The user roles
*/
#[ORM\Column]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @return list
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
/**
* @param list $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
}
interface UserInterface
{
public function getUserIdentifier(): string; // Email ou username
public function getRoles(): array; // Rôles de l'user
public function eraseCredentials(): void; // Nettoyer données sensibles
}
Interface obligatoire pour tous les utilisateurs Symfony.
interface PasswordAuthenticatedUserInterface
{
public function getPassword(): ?string; // Mot de passe hashé
}
Interface pour les utilisateurs avec mot de passe.
💡 getUserIdentifier() :
Retourne l'identifiant unique de l'utilisateur (email, username...). Utilisé par Symfony pour charger l'utilisateur.
php bin/console make:entity User
$ php bin/console make:entity User
New property name (press <return> to stop adding fields):
> firstName
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 100
Can this field be null in the database (nullable) (yes/no) [no]:
> no
New property name (press <return> to stop adding fields):
> lastName
Field type [string]:
> string
Field length [255]:
> 100
Can this field be null in the database (nullable) (yes/no) [no]:
> no
#[ORM\Column(length: 100)]
private ?string $firstName = null;
#[ORM\Column(length: 100)]
private ?string $lastName = null;
// + getters et setters générés automatiquement
php bin/console make:migration
php bin/console doctrine:migrations:migrate
✅ Résultat : Table user créée avec les colonnes :
Hasher et vérifier les passwords
// ❌ DANGEREUX !
INSERT INTO user (email, password) VALUES ('user@example.com', 'motdepasse123');
// Si la BDD est piratée, tous les mots de passe sont exposés !
// ✅ SÉCURISÉ
INSERT INTO user (email, password) VALUES
('user@example.com', '$2y$13$abcd1234...xyz7890');
// Hash irréversible, impossible de retrouver le mot de passe original
Un hash = passer un document dans une broyeuse :
On ne peut PAS retrouver le document original, mais on peut broyer un nouveau document et comparer les confettis !
Symfony utilise par défaut l'algorithme bcrypt (ou argon2).
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# 'auto' = Symfony choisit le meilleur algorithme (bcrypt ou argon2)
Mot de passe : "monmotdepasse123"
Hash bcrypt : "$2y$13$DfJ8k3.tL/rL5Pq0xYz.8eN7K2mP9qR4sT6uV8wX0yZ2aB4cD6eF8"
// Caractéristiques :
// - Commence par $2y$ (bcrypt)
// - $13$ = coût (plus c'est élevé, plus c'est sécurisé mais lent)
// - Reste = sel + hash
💡 Sel (salt) :
Chaîne aléatoire ajoutée au mot de passe avant le hash. Même mot de passe = hash différent à chaque fois !
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $em
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Récupérer le mot de passe en clair depuis le formulaire
$plainPassword = $form->get('plainPassword')->getData();
// Hasher le mot de passe
$hashedPassword = $passwordHasher->hashPassword(
$user,
$plainPassword
);
// Stocker le hash
$user->setPassword($hashedPassword);
$em->persist($user);
$em->flush();
return $this->redirectToRoute('app_login');
}
return $this->render('registration/register.html.twig', [
'form' => $form
]);
}
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
public function checkPassword(
User $user,
string $plainPassword,
UserPasswordHasherInterface $passwordHasher
): bool {
return $passwordHasher->isPasswordValid($user, $plainPassword);
}
// Utilisation
if ($passwordHasher->isPasswordValid($user, $submittedPassword)) {
// Mot de passe correct !
} else {
// Mot de passe incorrect
}
💡 Comment ça marche ?
Connexion et déconnexion
php bin/console make:auth
$ php bin/console make:auth
What style of authentication do you want? [Empty authenticator]:
[0] Empty authenticator
[1] Login form authenticator
> 1
The class name of the authenticator to create (e.g. AppCustomAuthenticator):
> LoginFormAuthenticator
Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
> SecurityController
Do you want to generate a '/logout' URL? (yes/no) [yes]:
> yes
created: src/Security/LoginFormAuthenticator.php
updated: config/packages/security.yaml
created: src/Controller/SecurityController.php
created: templates/security/login.html.twig
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// Récupérer l'erreur de login s'il y en a une
$error = $authenticationUtils->getLastAuthenticationError();
// Dernier email saisi par l'utilisateur
$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
// Symfony intercepte cette route et déconnecte l'utilisateur
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}
{% extends 'base.html.twig' %}
{% block title %}Connexion{% endblock %}
{% block body %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<h1 class="text-center mb-4">Connexion</h1>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Email</label>
<input type="email"
class="form-control"
id="username"
name="_username"
value="{{ last_username }}"
required
autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<input type="password"
class="form-control"
id="password"
name="_password"
required>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember_me" name="_remember_me">
<label class="form-check-label" for="remember_me">
Se souvenir de moi
</label>
</div>
<button type="submit" class="btn btn-primary w-100">
Se connecter
</button>
</form>
</div>
</div>
</div>
{% endblock %}
security:
# Hashage des mots de passe
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# Provider : comment charger les utilisateurs
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
# Firewalls : zones de sécurité
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
# Authentification par formulaire
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
# Déconnexion
logout:
path: app_logout
target: app_login
# Contrôle d'accès
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
Protéger les URLs
Un firewall = un checkpoint qui vérifie chaque requête HTTP :
firewalls:
main:
lazy: true # Chargement paresseux
provider: app_user_provider # Provider d'utilisateurs
form_login:
login_path: app_login # Route du formulaire de login
check_path: app_login # Route de vérification (même route)
enable_csrf: true # Protection CSRF
default_target_path: / # Redirection après login
logout:
path: app_logout # Route de déconnexion
target: app_login # Redirection après logout
remember_me:
secret: '%kernel.secret%' # Remember me (cookie)
lifetime: 604800 # 1 semaine en secondes
Access Control définit qui peut accéder à quelles URLs.
access_control:
# Tout le monde peut accéder
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
# Seulement les utilisateurs connectés
- { path: ^/profile, roles: ROLE_USER }
# Seulement les admins
- { path: ^/admin, roles: ROLE_ADMIN }
# Seulement les super admins
- { path: ^/admin/users, roles: ROLE_SUPER_ADMIN }
⚠️ Attention : Les règles sont évaluées dans l'ordre. La première qui matche est appliquée !
# ❌ MAUVAIS ORDRE
- { path: ^/admin, roles: ROLE_USER } # Matche /admin/users
- { path: ^/admin/users, roles: ROLE_ADMIN } # Jamais atteint !
# ✅ BON ORDRE
- { path: ^/admin/users, roles: ROLE_ADMIN } # Plus spécifique en premier
- { path: ^/admin, roles: ROLE_USER } # Plus général ensuite
Gérer les permissions
Les rôles = différents types de badges :
Un utilisateur peut avoir plusieurs badges !
| Rôle | Description | Accès typique |
|---|---|---|
| ROLE_USER | Utilisateur basique | /profile, /settings |
| ROLE_MODERATOR | Modérateur | /moderate, /reports |
| ROLE_ADMIN | Administrateur | /admin, /users, /stats |
| ROLE_SUPER_ADMIN | Super administrateur | Tout |
💡 Règles de nommage :
ROLE_ROLE_USER
ROLE_ADMIN
ROLE_SUPER_ADMIN
ROLE_EDITOR
ROLE_MODERATOR
ROLE_CUSTOMER
ROLE_PREMIUM_USER
USER // ❌ Manque ROLE_
role_admin // ❌ Pas en majuscules
ROLE-ADMIN // ❌ Tiret au lieu de underscore
ADMIN_ROLE // ❌ ROLE_ doit être au début
// Créer un admin
$user = new User();
$user->setEmail('admin@example.com');
$user->setRoles(['ROLE_ADMIN']); // Tableau de rôles
$entityManager->persist($user);
$entityManager->flush();
// Ajouter un rôle à un utilisateur existant
$user = $userRepository->find(1);
$currentRoles = $user->getRoles();
$currentRoles[] = 'ROLE_MODERATOR';
$user->setRoles($currentRoles);
$entityManager->flush();
// Colonne 'roles' de type JSON en BDD
{
"id": 1,
"email": "admin@example.com",
"roles": ["ROLE_ADMIN", "ROLE_MODERATOR"]
}
💡 ROLE_USER automatique :
La méthode getRoles() de User ajoute automatiquement ROLE_USER à tous les utilisateurs.
On peut définir qu'un rôle hérite automatiquement d'autres rôles.
security:
role_hierarchy:
ROLE_MODERATOR: ROLE_USER
ROLE_ADMIN: ROLE_MODERATOR
ROLE_SUPER_ADMIN: ROLE_ADMIN
✅ Conséquences :
ROLE_ADMIN a aussi ROLE_MODERATOR et ROLE_USERROLE_SUPER_ADMIN a tous les rôlesROLE_USER, tous les rôles supérieurs peuvent accéderisGranted et denyAccessUnlessGranted
public function admin(): Response
{
// Vérifier si l'utilisateur a le rôle
if (!$this->isGranted('ROLE_ADMIN')) {
throw $this->createAccessDeniedException('Accès refusé !');
}
// Code accessible seulement aux admins
return $this->render('admin/dashboard.html.twig');
}
public function admin(): Response
{
// Lève automatiquement une exception si pas le rôle
$this->denyAccessUnlessGranted('ROLE_ADMIN');
// Code accessible seulement aux admins
return $this->render('admin/dashboard.html.twig');
}
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin', name: 'admin_dashboard')]
#[IsGranted('ROLE_ADMIN')]
public function dashboard(): Response
{
// Accès automatiquement protégé !
return $this->render('admin/dashboard.html.twig');
}
{% if is_granted('ROLE_ADMIN') %}
<a href="{{ path('admin_dashboard') }}">
Administration
</a>
{% endif %}
{% if is_granted('ROLE_MODERATOR') %}
<a href="{{ path('moderate_content') }}">
Modération
</a>
{% endif %}
{% if app.user %}
<p>Bonjour {{ app.user.email }} !</p>
<a href="{{ path('app_logout') }}">Déconnexion</a>
{% else %}
<a href="{{ path('app_login') }}">Connexion</a>
{% endif %}
💡 Variable globale app.user :
app.user = Utilisateur connecté (ou null)app.user.email = Email de l'utilisateurapp.user.roles = Tableau des rôlespublic function profile(): Response
{
// Récupérer l'utilisateur connecté
$user = $this->getUser();
// Vérifier s'il est connecté
if (!$user) {
return $this->redirectToRoute('app_login');
}
// Utiliser l'objet User
$email = $user->getEmail();
$roles = $user->getRoles();
return $this->render('profile/show.html.twig', [
'user' => $user
]);
}
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
public function profile(Security $security): Response
{
/** @var User|null $user */
$user = $security->getUser();
if (!$user) {
return $this->redirectToRoute('app_login');
}
// $user est typé comme User
return $this->render('profile/show.html.twig', [
'user' => $user
]);
}
Logique d'autorisation complexe
Un Voter = un juge qui décide si une action est autorisée :
Les Voters permettent une logique d'autorisation plus fine que les simples rôles !
php bin/console make:voter ArticleVoter
<?php
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;
class ArticleVoter extends Voter
{
const EDIT = 'ARTICLE_EDIT';
const DELETE = 'ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
// Ce voter gère seulement les attributs ARTICLE_EDIT et ARTICLE_DELETE
if (!in_array($attribute, [self::EDIT, self::DELETE])) {
return false;
}
// Ce voter gère seulement les objets Article
if (!$subject instanceof Article) {
return false;
}
return true;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// L'utilisateur doit être connecté
if (!$user instanceof User) {
return false;
}
/** @var Article $article */
$article = $subject;
return match($attribute) {
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
default => false
};
}
private function canEdit(Article $article, User $user): bool
{
// Admins peuvent tout éditer
if (in_array('ROLE_ADMIN', $user->getRoles())) {
return true;
}
// Auteur peut éditer son propre article
return $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// Seuls les admins peuvent supprimer
return in_array('ROLE_ADMIN', $user->getRoles());
}
}
#[Route('/article/{id}/edit', name: 'article_edit')]
public function edit(Article $article): Response
{
// Vérifier si l'utilisateur peut éditer cet article
$this->denyAccessUnlessGranted('ARTICLE_EDIT', $article);
// Si on arrive ici, l'utilisateur peut éditer
// ...
}
#[Route('/article/{id}/delete', name: 'article_delete')]
public function delete(Article $article, EntityManagerInterface $em): Response
{
// Vérifier si l'utilisateur peut supprimer
if (!$this->isGranted('ARTICLE_DELETE', $article)) {
throw $this->createAccessDeniedException('Vous ne pouvez pas supprimer cet article');
}
$em->remove($article);
$em->flush();
return $this->redirectToRoute('article_list');
}
{% 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) %}
<a href="{{ path('article_delete', {id: article.id}) }}" class="btn btn-danger">
Supprimer
</a>
{% endif %}
Se souvenir de moi
firewalls:
main:
# ... autres configs
remember_me:
secret: '%kernel.secret%' # Clé secrète
lifetime: 604800 # 7 jours (en secondes)
path: / # Portée du cookie
always_remember_me: false # Toujours se souvenir (sans checkbox)
token_provider:
doctrine: true # Stocker tokens en BDD (optionnel)
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="remember_me"
name="_remember_me">
<label class="form-check-label" for="remember_me">
Se souvenir de moi
</label>
</div>
💡 Comment ça marche ?
Mot de passe oublié
composer require symfonycasts/reset-password-bundle
php bin/console make:reset-password
✅ Fichiers créés :
src/Entity/ResetPasswordRequest.php - Entité pour stocker les tokenssrc/Controller/ResetPasswordController.php - Contrôleurtemplates/reset_password/ - Templates💡 Ce bundle gère :
| Composant | Rôle | Fichier |
|---|---|---|
| User | Entité utilisateur (email, password, roles) | src/Entity/User.php |
| PasswordHasher | Hash et vérifie les mots de passe | Service auto |
| Firewall | Zone de sécurité (login/logout) | security.yaml |
| Access Control | Règles d'accès par URL | security.yaml |
| Rôles | Permissions (ROLE_USER, ROLE_ADMIN...) | BDD + security.yaml |
| Voters | Logique d'autorisation personnalisée | src/Security/Voter/ |
| Remember Me | Cookie de persistance | security.yaml |
| Commande | Description |
|---|---|
make:user |
Créer l'entité User |
make:auth |
Créer le système d'authentification |
make:registration-form |
Créer le formulaire d'inscription |
make:voter |
Créer un Voter personnalisé |
make:reset-password |
Créer système de réinit mot de passe |
// ❌ DANGEREUX
$user->setPassword('motdepasse123');
// ✅ BON
$hashedPassword = $passwordHasher->hashPassword($user, 'motdepasse123');
$user->setPassword($hashedPassword);
// ❌ MAUVAIS
$user->setRoles(['ADMIN', 'USER']);
// ✅ BON
$user->setRoles(['ROLE_ADMIN', 'ROLE_USER']);
# ❌ MAUVAIS - Trop général en premier
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/admin/users, roles: ROLE_ADMIN } # Jamais atteint !
# ✅ BON - Plus spécifique en premier
- { path: ^/admin/users, roles: ROLE_ADMIN }
- { path: ^/admin, roles: ROLE_USER }
🌐 https://symfony.com/doc/current/security.html
Guide complet de la sécurité
🌐 https://symfony.com/doc/current/security/authentication.html
Tout sur l'authentification
🌐 https://symfony.com/doc/current/security/voters.html
Créer des autorisations personnalisées
🌐 https://github.com/SymfonyCasts/reset-password-bundle
Réinitialisation de mot de passe
Vous savez maintenant :
✅ Protéger votre application
✅ Gérer l'authentification
✅ Contrôler les autorisations
✅ Sécuriser les mots de passe
💡 Conseil final :
La sécurité n'est jamais "terminée". Restez à jour sur les bonnes pratiques, utilisez HTTPS en production, et n'oubliez jamais : JAMAIS de mots de passe en clair !
Vous maîtrisez Symfony ! 🚀
Formation DWWM - 2025