Sécurité avancée : voters et protection CSRF
Comment autoriser finement les actions des utilisateurs et protéger les formulaires contre les attaques CSRF
Notions théoriques
Authentification vs autorisation
La séance sur la gestion des utilisateurs a mis en place l'authentification : Symfony sait désormais qui est connecté grâce à UserInterface et à la propriété roles.
Mais savoir qui est connecté ne suffit pas toujours. Il faut aussi répondre à une seconde question, plus précise : cet utilisateur a-t-il le droit d'effectuer cette action, sur cette ressource précise ? C'est le rôle de l'autorisation.
- Authentification : « Qui êtes-vous ? »
- Autorisation : « Avez-vous le droit de faire cela ? »
Par exemple, tous les joueurs connectés ont le rôle ROLE_USER, mais seul le joueur qui a créé un personnage devrait pouvoir le modifier ou le supprimer. Un simple #[IsGranted('ROLE_USER')] sur le contrôleur ne suffit pas à vérifier cela : il faudrait comparer le personnage au joueur connecté.
La hiérarchie de rôles dans security.yaml
Avant d'aller plus loin, Symfony permet de définir une hiérarchie de rôles dans config/packages/security.yaml, pour éviter de répéter plusieurs rôles sur chaque utilisateur :
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: ROLE_ADMIN
Avec cette configuration, un utilisateur qui possède uniquement ROLE_ADMIN dans sa propriété roles est automatiquement considéré comme ayant aussi ROLE_USER. Il n'est donc pas nécessaire de stocker les deux rôles en base de données.
Les voters personnalisés
Pour des règles d'autorisation plus fines qu'un simple rôle (« êtes-vous l'auteur de cette ressource ? »), Symfony fournit le mécanisme des voters. Un voter est une classe qui étend Voter et qui répond, pour une action et une ressource données, si l'utilisateur courant est autorisé ou non.
Un voter implémente deux méthodes :
supports(string $attribute, mixed $subject): bool: indique si ce voter sait juger cette combinaison d'action ($attribute, par exemple'EDIT') et de ressource ($subject, par exemple un objetCharacter). Sifalse, le voter est ignoré pour cette vérification.voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool: contient la véritable logique de décision. Retournetruesi l'action est autorisée,falsesinon.
Un cas d'usage typique : « seul l'auteur d'une ressource peut la modifier ». Dans notre jeu, cela se traduit par : « seul le joueur propriétaire d'un personnage peut le modifier ou le supprimer ».
Utiliser un voter dans un contrôleur
Une fois le voter enregistré (Symfony le détecte automatiquement grâce à l'autoconfiguration), on l'utilise dans un contrôleur avec la méthode denyAccessUnlessGranted() :
$this->denyAccessUnlessGranted('EDIT', $character);
Si le voter refuse l'accès, Symfony lève automatiquement une exception AccessDeniedException (traduite en page d'erreur 403 pour l'utilisateur).
La protection CSRF
Une attaque CSRF (Cross-Site Request Forgery, falsification de requête intersite) consiste, pour un site malveillant, à faire exécuter une action à l'insu de l'utilisateur sur un autre site où il est déjà connecté (par exemple, soumettre discrètement un formulaire de suppression de compte pendant que la victime visite une page piégée).
Pour se protéger, chaque formulaire sensible embarque un jeton CSRF : une valeur unique et imprévisible, générée par le serveur, vérifiée à la soumission. Un site tiers ne peut pas deviner ce jeton, donc une requête forgée depuis l'extérieur est automatiquement rejetée.
Tous les formulaires créés avec le composant Form de Symfony (AbstractType / FormBuilder, comme vu dans la séance sur les formulaires) intègrent automatiquement un jeton CSRF caché. Vous n'avez rien à faire de particulier : $form->isValid() vérifie ce jeton pour vous.
En revanche, pour une action simple qui n'utilise pas le composant Form — typiquement un lien de suppression (<a href="...">) — il faut ajouter et vérifier le jeton CSRF manuellement.
Génération du jeton dans le template Twig :
<a href="{{ path('delete_character', {id: character.id}) }}?token={{ csrf_token('delete-character-' ~ character.id) }}">
Supprimer
</a>
Vérification dans le contrôleur :
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/game/character/{id}/delete', name: 'delete_character')]
public function delete(Request $request, Character $character, CsrfTokenManagerInterface $csrfTokenManager): Response
{
$submittedToken = $request->query->get('token');
if (!$csrfTokenManager->isTokenValid(new CsrfToken('delete-character-' . $character->getId(), $submittedToken))) {
throw $this->createAccessDeniedException('Jeton CSRF invalide.');
}
// ... suppression du personnage ...
}
Ne jamais réaliser une action qui modifie des données (suppression, modification) via une simple requête GET sans vérification CSRF. Un lien GET peut être déclenché involontairement, y compris par un simple <img src="..."> piégé sur un autre site.
Exemple de mise en application
Créons un voter qui autorise uniquement le joueur propriétaire d'un personnage à le modifier ou le supprimer.
// src/Security/Voter/CharacterVoter.php
namespace App\Security\Voter;
use App\Entity\Character;
use App\Entity\Player;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class CharacterVoter extends Voter
{
const EDIT = 'EDIT';
const DELETE = 'DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE])
&& $subject instanceof Character;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof Player) {
return false;
}
/** @var Character $character */
$character = $subject;
// Seul le joueur propriétaire du personnage peut le modifier ou le supprimer
return $character->getPlayer() === $user;
}
}
Utilisation dans le GameController :
#[Route('/game/character/{id}/edit', name: 'edit_character')]
public function editCharacter(Character $character, Request $request): Response
{
$this->denyAccessUnlessGranted('EDIT', $character);
$form = $this->formFactory->create(CharacterType::class, $character);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->characterRepository->save($character, true);
return $this->redirectToRoute('game');
}
return $this->render('game/edit_character.html.twig', ['form' => $form->createView()]);
}
denyAccessUnlessGranted() appelle automatiquement tous les voters qui supportent l'attribut 'EDIT' et le sujet $character. Si plusieurs voters existent, Symfony combine leurs décisions selon une stratégie configurable (par défaut : au moins un voter doit accepter, et aucun ne doit refuser).
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre défi pour aujourd'hui consiste à protéger la suppression d'un personnage : seul son propriétaire doit pouvoir le supprimer, et l'action doit être protégée contre les attaques CSRF.
Étape 1 — Compléter le supports() du voter
Le voter CharacterVoter doit reconnaître l'attribut 'DELETE' en plus de 'EDIT', pour un sujet de type Character.
Regrouper EDIT et DELETE dans le même voter évite de dupliquer la logique de vérification de propriété ($character->getPlayer() === $user), qui est identique pour les deux actions.
Étape 2 — Vérifier l'autorisation dans le contrôleur
Étape 3 — Générer et vérifier le jeton CSRF
Le lien de suppression n'utilise pas le composant Form : il faut donc gérer le jeton CSRF manuellement dans le contrôleur.
En incluant l'identifiant du personnage dans le nom du jeton ('delete-character-' . $character->getId()), un jeton généré pour supprimer un personnage ne peut pas être réutilisé pour en supprimer un autre.