Aller au contenu principal

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.

info
  • 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 objet Character). Si false, le voter est ignoré pour cette vérification.
  • voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool : contient la véritable logique de décision. Retourne true si l'action est autorisée, false sinon.
info

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.

info

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 ...
}
attention

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()]);
}
astuce

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


Quelle question l'autorisation permet-elle de répondre, contrairement à l'authentification ?


Dans role_hierarchy, que signifie 'ROLE_ADMIN: ROLE_USER' ?


Quelle classe doit étendre un voter personnalisé dans Symfony ?


Quelle méthode d'un voter indique s'il sait juger une combinaison action/ressource donnée ?


Quelle méthode contient la véritable logique de décision d'un voter ?


Quelle méthode d'un contrôleur permet de vérifier une autorisation via les voters ?


Que signifie l'acronyme CSRF ?


Les formulaires créés avec le composant Form de Symfony intègrent-ils une protection CSRF ?


Pourquoi faut-il ajouter un jeton CSRF manuel sur un lien de suppression qui n'utilise pas le composant Form ?


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.


Bonne pratique - Un seul voter peut gérer plusieurs attributs liés

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.


Bonne pratique - Utiliser un identifiant de jeton unique par ressource

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.

📌 Une solution