Skip to content

8. Authentification et autorisation

Deux questions distinctes se posent pour chaque requête :

  • qui est l'utilisateur ? C'est l'authentification : il s'est identifié par un login et un mot de passe, et chaque requête suivante doit prouver qu'elle vient de lui ;
  • a-t-il le droit d'accéder à cette page ? C'est l'autorisation : dans l'étude de cas, seul un utilisateur de rôle ADMIN gère les médecins et les clients.

La preuve d'identité doit voyager avec chaque requête. [Symfony] range d'ordinaire l'utilisateur connecté dans sa session (chapitre sur les portées), ce qui est la solution la plus simple pour une application web. L'étude de cas, dans toutes ses versions ([ASP.NET Core MVC], [Spring MVC], [NestJS], [Flask], [Laravel]), utilise plutôt un jeton JWT (JSON Web Token), que nous garderons ici : après la connexion, le serveur fabrique un jeton qui contient l'identité de l'utilisateur et le signe avec une clé secrète (c'est le principe du cookie signé de l'exemple 23). Le jeton est déposé dans un cookie ; le navigateur le renvoie à chaque requête ; le serveur vérifie la signature. L'utilisateur peut lire son jeton, mais pas le modifier : toute modification rend la signature invalide. Le format est normalisé (RFC 7519), compris par tous les langages, et le jeton porte sa date d'expiration. Surtout, le serveur n'a rien à mémoriser : n'importe quel serveur qui connaît la clé peut vérifier le jeton.

[Symfony] possède un composant de sécurité complet, intégré par le SecurityBundle, dont les éléments nous serviront tels quels :

  • les URL de l'application sont réparties entre des pare-feu (firewalls), chacun avec sa façon d'authentifier les utilisateurs ;
  • l'authentification est confiée à des authentificateurs (authenticators) : des objets qui, pour chaque requête, établissent qui est l'utilisateur. [Symfony] en fournit plusieurs (formulaire de connexion, session, jeton d'API...) ; nous écrirons le nôtre, qui lit le jeton JWT. Le reste de l'application ne voit que le résultat : l'utilisateur authentifié, que donnent [#[CurrentUser]], [$this->getUser()] et, dans les vues, [app.user] ;
  • les utilisateurs sont fournis par un fournisseur d'utilisateurs (user provider), et leurs mots de passe hachés par le composant PasswordHasher (algorithme [bcrypt]) ;
  • les autorisations reposent sur des rôles : la protection des pages est décrite par des règles de contrôle d'accès ([access_control]) ou par l'attribut [#[IsGranted]] des actions ; les vues testent un droit par [is_granted] ;
  • les tentatives de connexion sont limitées par le composant RateLimiter (exemple 27).

Tout cela est réalisé par des écouteurs des événements du noyau (chapitre précédent) : le pare-feu écoute [kernel.request] (priorité 8), [#[IsGranted]] est lu sur [kernel.controller_arguments], et les refus d'accès sont des exceptions, traitées sur [kernel.exception].

La fabrication et la vérification des jetons sont confiées à la bibliothèque [firebase/php-jwt], la plus utilisée en [PHP], installée par le fichier [composer.json] des exemples. (Il existe un bundle très utilisé pour les jetons JWT, [LexikJWTAuthenticationBundle], conçu pour les API ; pour une application web, quelques lignes suffisent, et elles montrent mieux le mécanisme.)

8.1. Exemple 25 : connexion, jeton, page protégée

25-jwt/
├── .env
├── config/
│   ├── bundles.php
│   └── packages/
│       ├── framework.yaml
│       └── security.yaml
├── src/
│   ├── Controller/
│   │   ├── AuthController.php
│   │   └── ProfilController.php
│   └── Securite/
│       ├── AuthService.php
│       ├── JetonAuthenticator.php
│       ├── Utilisateur.php
│       └── UtilisateursService.php
├── templates/
│   ├── auth/
│   │   └── login.html.twig
│   ├── layout.html.twig
│   └── profil/
│       └── profil.html.twig
└── ...

8.1.1. La configuration : .env, bundles.php et framework.yaml

Le jeton est signé avec une clé secrète, qui ne doit pas être écrite dans le code. Il est temps de séparer vraiment la configuration du code. Les réglages propres à une installation sont dans le fichier [.env] (exemple 01), qui reçoit trois nouvelles lignes (un commentaire, puis deux variables) :

1
2
3
4
5
6
# .env : la configuration de l'exemple 25 (cf. exemple 01)
APP_ENV=dev
APP_SECRET=6144ddf9f821deebee2291ab7742d7a9
# la clé qui signe les jetons JWT (au moins 32 caractères), et leur durée de vie
JWT_SECRET=une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-25
JWT_DUREE_MINUTES=60

La norme exige, pour l'algorithme utilisé (HMAC-SHA256), une clé d'au moins 256 bits, soit 32 octets ; la bibliothèque refuse une clé plus courte. Le code, lui, ne lit pas le fichier [.env] directement : le composant [Dotenv] (exemple 01) en a fait des variables d'environnement, que le conteneur fournit aux services qui les demandent (nous le verrons avec le service [AuthService]). Une vraie variable d'environnement l'emporte sur le fichier [.env] : sous [Linux] ou [macOS], on lancerait l'application avec une autre clé ainsi :

JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres" php -d variables_order=EGPCS -S localhost:8000 -t public

et sous [Windows] (PowerShell) :

$env:JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres"
php -d variables_order=EGPCS -S localhost:8000 -t public

(L'option [-d variables_order=EGPCS] demande à [PHP] de remplir le tableau [$_ENV] avec les variables d'environnement, ce que sa configuration par défaut ne fait pas toujours.) En production, les secrets sont souvent confiés au coffre de [Symfony] (secrets vault, commande [bin/console secrets:set]), qui les range chiffrés dans le projet.

Le SecurityBundle est ajouté à la liste des bundles :

1
2
3
4
5
6
7
8
<?php
// config/bundles.php : les bundles de l'application ; le SecurityBundle intègre
// le composant de sécurité (authentification, autorisation)
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
];

La session ne sert ici qu'au jeton anti-CSRF du formulaire de connexion (exemple 21) :

1
2
3
4
5
6
7
8
# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # la session : elle ne sert qu'au jeton anti-CSRF du formulaire de connexion
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    csrf_protection: true

8.1.2. La configuration de la sécurité : config/packages/security.yaml

# config/packages/security.yaml : la configuration du SecurityBundle
security:
    # le hachage des mots de passe des utilisateurs de la classe Utilisateur : bcrypt
    password_hashers:
        App\Securite\Utilisateur:
            algorithm: bcrypt

    # le FOURNISSEUR d'utilisateurs : l'objet qui trouve un utilisateur d'après son login
    providers:
        utilisateurs:
            id: App\Securite\UtilisateursService

    # les PARE-FEU : chacun protège les URL qui correspondent à son motif (pattern)
    firewalls:
        # les fichiers de Bootstrap : aucune sécurité
        ressources:
            pattern: ^/bootstrap/
            security: false
        # toutes les autres URL
        principal:
            # sans état : l'utilisateur n'est pas rangé dans la session, il est
            # retrouvé à chaque requête, à partir de son jeton
            stateless: true
            provider: utilisateurs
            # l'authentificateur : il lit le jeton du cookie, à chaque requête
            custom_authenticators:
                - App\Securite\JetonAuthenticator

    # le CONTRÔLE D'ACCÈS : les URL commençant par /profil exigent un utilisateur
    # authentifié ; les autres sont publiques
    access_control:
        - { path: ^/profil, roles: IS_AUTHENTICATED }
  • lignes 4-6 : l'algorithme de hachage des mots de passe des utilisateurs de la classe [Utilisateur] : [bcrypt]. (La valeur [auto] choisirait le meilleur algorithme disponible, et saurait vérifier les hachages des autres) ;
  • lignes 9-11 : le fournisseur d'utilisateurs : le service qui trouve un utilisateur d'après son identifiant (ici, notre classe [UtilisateursService]). [Symfony] en fournit plusieurs : [memory] (une liste écrite dans ce fichier), [entity] (une table de la base, cf. étude de cas)... ;
  • lignes 14-27 : les pare-feu. Chacun s'applique aux URL qui correspondent à son motif ([pattern]), le premier qui convient étant choisi. Le premier, [ressources], couvre les fichiers de [Bootstrap] (exemple 15), sans aucune sécurité ([security: false]) : inutile de chercher un utilisateur pour servir une feuille de style. Le second, [principal], n'a pas de motif : il couvre toutes les autres URL ;
  • ligne 23 : le pare-feu est sans état (stateless) : l'utilisateur authentifié n'est pas rangé dans la session ; il est retrouvé à chaque requête, à partir de son jeton ;
  • lignes 26-27 : l'authentificateur du pare-feu, notre classe [JetonAuthenticator] ;
  • lignes 31-32 : le contrôle d'accès : une liste de règles, examinées dans l'ordre, dont la première qui correspond à l'URL s'applique. Les URL qui commencent par [/profil] exigent un utilisateur authentifié ([IS_AUTHENTICATED]) ; les autres sont publiques.

Dans un projet ordinaire, ce fichier est écrit par [Symfony Flex] quand on installe le bundle ([composer require security]), et la commande [bin/console make:user] du [MakerBundle] écrit la classe des utilisateurs et son fournisseur.

8.1.3. Les utilisateurs : src/Securite/Utilisateur.php et UtilisateursService.php

<?php
// src/Securite/Utilisateur.php : un utilisateur. UserInterface : l'interface que
// Symfony attend des utilisateurs authentifiés ; PasswordAuthenticatedUserInterface :
// un utilisateur qui a un mot de passe

namespace App\Securite;

use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

readonly class Utilisateur implements UserInterface, PasswordAuthenticatedUserInterface
{
    public function __construct(
        public string $login,
        public string $nom,
        // le rôle : USER ou ADMIN
        public string $role,
        // le HACHAGE du mot de passe (vide pour un utilisateur lu dans un jeton)
        public string $hachageMotDePasse = '',
        // le contenu du jeton qui a authentifié l'utilisateur
        public array $claims = [],
    ) {
    }

    // l'identifiant de l'utilisateur : son login
    public function getUserIdentifier(): string
    {
        return $this->login;
    }

    // les rôles, au sens de Symfony : des chaînes qui commencent par ROLE_
    public function getRoles(): array
    {
        return ['ROLE_'.$this->role];
    }

    public function getPassword(): string
    {
        return $this->hachageMotDePasse;
    }
}
  • ligne 11 : un utilisateur. [Symfony] attend des utilisateurs authentifiés qu'ils implémentent l'interface [UserInterface] : ses méthodes lui donnent l'identifiant de l'utilisateur et ses rôles. L'interface [PasswordAuthenticatedUserInterface] ajoute le mot de passe haché. Dans une application ordinaire, c'est l'entité [Doctrine] de la table des utilisateurs (chapitre suivant) qui les implémente ;
  • lignes 13-23 : le login, le nom, le rôle, le hachage du mot de passe, et le contenu du jeton qui a authentifié l'utilisateur (vide pour un utilisateur lu dans la liste des utilisateurs). Un serveur ne conserve jamais un mot de passe, même chiffré : si la base de données était volée, les mots de passe le seraient aussi (et beaucoup de gens utilisent le même partout). Il conserve le résultat d'une fonction de hachage, une fonction à sens unique : on peut calculer le hachage d'un mot de passe, mais pas retrouver un mot de passe à partir de son hachage. Pour vérifier un mot de passe saisi, on calcule son hachage, et on le compare à celui qui est enregistré ;
  • lignes 32-35 : les rôles, au sens de [Symfony] : des chaînes qui commencent par [ROLE_]. Le rôle [USER] de l'étude de cas devient [ROLE_USER].
<?php
// src/Securite/UtilisateursService.php : les utilisateurs, en mémoire (l'exemple 28
// et l'étude de cas les mettront dans une base). UserProviderInterface : un
// FOURNISSEUR d'utilisateurs, au sens de Symfony (cf. config/packages/security.yaml)

namespace App\Securite;

use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class UtilisateursService implements UserProviderInterface
{
    // on ne conserve JAMAIS un mot de passe, seulement son hachage bcrypt, calculé
    // une fois pour toutes par : php -r "echo password_hash('marie', PASSWORD_BCRYPT);"
    private const UTILISATEURS = [
        'marie' => ['Marie Pélissier', 'USER', '$2y$10$2DnOgGBiVcyc07su9rj2E.EvOoyPFzHN/h9PLKDuoW5P7UGtclW6W'],
        'admin' => ['Administrateur', 'ADMIN', '$2y$10$m5o9euCi0EdgoZkLVw.UsO58RxJcqhMBfVrlLMCIOh2LyGSjuN29K'],
    ];

    public function chercher(string $login): ?Utilisateur
    {
        if (! isset(self::UTILISATEURS[$login])) {
            return null;
        }
        [$nom, $role, $hachage] = self::UTILISATEURS[$login];
        return new Utilisateur($login, $nom, $role, $hachage);
    }

    // les trois méthodes de UserProviderInterface
    public function loadUserByIdentifier(string $identifier): UserInterface
    {
        return $this->chercher($identifier) ?? throw new UserNotFoundException();
    }

    // l'utilisateur rangé dans la session, à recharger : jamais appelée ici,
    // puisque le pare-feu est sans état
    public function refreshUser(UserInterface $user): UserInterface
    {
        throw new UnsupportedUserException();
    }

    public function supportsClass(string $class): bool
    {
        return $class === Utilisateur::class;
    }
}
  • lignes 17-20 : deux utilisateurs de test, écrits dans le code (l'exemple 28 et l'étude de cas les mettront dans une base de données) : pour chaque login, le nom, le rôle, et le hachage du mot de passe. Le hachage est celui de l'algorithme bcrypt, conçu pour les mots de passe. Il a deux particularités. Il est volontairement lent, d'une lenteur réglable (le coût, ici 10 : 2^10 tours de calcul, le [$10$] du hachage) : quelques dizaines de millisecondes, imperceptibles pour un utilisateur, mais qui rendent très coûteux l'essai de milliards de mots de passe par un pirate. Il est salé : un sel, tiré au hasard, est mélangé au mot de passe avant hachage, et rangé dans le hachage lui-même (les 22 caractères qui suivent [$10$]) ; deux utilisateurs qui ont le même mot de passe ont ainsi des hachages différents. Les hachages ont été calculés une fois pour toutes, par la fonction [password_hash] de [PHP] (ligne 16), ou par la commande [bin/console security:hash-password] de [Symfony] : les calculer à chaque requête coûterait inutilement du temps, puisque l'application est reconstruite à chaque requête. [bcrypt] ne prend en compte que les 72 premiers octets d'un mot de passe : l'étude de cas en tient compte ;
  • lignes 22-29 : l'utilisateur d'un login, ou [null]. [isset] teste l'existence d'une clé ; l'affectation [[$nom, $role, $hachage] = ...] déstructure un tableau : chaque variable reçoit l'élément correspondant ;
  • lignes 32-47 : les trois méthodes de l'interface [UserProviderInterface], qui font de la classe un fournisseur d'utilisateurs : [loadUserByIdentifier] rend l'utilisateur d'un identifiant, ou lève l'exception [UserNotFoundException] ; [refreshUser] recharge l'utilisateur rangé dans la session (inutile avec un pare-feu sans état) ; [supportsClass] indique la classe des utilisateurs fournis. L'opérateur [??] suivi de [throw] lève l'exception quand l'expression de gauche vaut [null].

8.1.4. Le service d'authentification : src/Securite/AuthService.php

<?php
// src/Securite/AuthService.php : la vérification des mots de passe et les jetons JWT

namespace App\Securite;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Throwable;

class AuthService
{
    // le nom du cookie qui transporte le jeton
    public const COOKIE = 'jeton';

    public function __construct(
        private UtilisateursService $utilisateurs,
        // le service de hachage des mots de passe (cf. config/packages/security.yaml)
        private UserPasswordHasherInterface $hacheur,
        // #[Autowire(env: ...)] : la valeur d'une variable d'environnement (fichier .env) ;
        // le préfixe int: la convertit en entier
        #[Autowire(env: 'JWT_SECRET')] private string $secret,
        #[Autowire(env: 'int:JWT_DUREE_MINUTES')] public readonly int $dureeMinutes,
    ) {
    }

    // l'utilisateur, ou null si le login est inconnu ou le mot de passe faux.
    // isPasswordValid hache le mot de passe saisi avec le sel du hachage enregistré,
    // puis compare les deux résultats
    public function valider(?string $login, ?string $motDePasse): ?Utilisateur
    {
        if (! $login || $motDePasse === null) {
            return null;
        }
        $utilisateur = $this->utilisateurs->chercher($login);
        if ($utilisateur === null || ! $this->hacheur->isPasswordValid($utilisateur, $motDePasse)) {
            return null;
        }
        return $utilisateur;
    }

    // le jeton JWT de l'utilisateur, signé avec la clé secrète (HMAC-SHA256)
    public function creerJeton(Utilisateur $utilisateur): string
    {
        $maintenant = time();
        // le contenu du jeton : les "claims" (affirmations sur l'utilisateur)
        $claims = [
            'sub' => $utilisateur->login,
            'nom' => $utilisateur->nom,
            'role' => $utilisateur->role,
            'iat' => $maintenant,
            'exp' => $maintenant + $this->dureeMinutes * 60,
        ];
        return JWT::encode($claims, $this->secret, 'HS256');
    }

    // l'utilisateur décrit par un jeton dont la signature et la date d'expiration
    // sont valides ; null sinon (jeton falsifié, expiré ou illisible)
    public function verifier(string $jeton): ?Utilisateur
    {
        try {
            // decode rend un objet ; (array) le convertit en tableau associatif
            $claims = (array) JWT::decode($jeton, new Key($this->secret, 'HS256'));
        } catch (Throwable) {
            return null;
        }
        return new Utilisateur($claims['sub'], $claims['nom'], $claims['role'], claims: $claims);
    }
}
  • lignes 17-26 : le service reçoit le fournisseur d'utilisateurs, le service de hachage des mots de passe ([UserPasswordHasherInterface], qui applique l'algorithme choisi dans [security.yaml]), et deux valeurs de configuration. L'attribut [#[Autowire(env: 'NOM')]] fournit la valeur d'une variable d'environnement ; un préfixe la convertit : [int:] en entier (il en existe d'autres : [bool:], [json:], [base64:]...). La durée de vie est une propriété publique en lecture seule : le contrôleur s'en servira ;
  • lignes 31-41 : la vérification d'un login et d'un mot de passe. [isPasswordValid] hache le mot de passe saisi avec le sel du hachage enregistré, et compare les deux hachages. Un login inconnu et un mot de passe faux donnent le même résultat, [null] ;
  • lignes 44-56 : la fabrication du jeton. Son contenu est un tableau d'affirmations (claims) sur l'utilisateur. Certaines ont un nom normalisé : [sub] (subject), l'identifiant de l'utilisateur ; [iat] (issued at), la date de création ; [exp] (expiration), la date d'expiration. Les dates s'écrivent en secondes écoulées depuis le 1er janvier 1970 (la fonction [time()] de [PHP]). Les autres affirmations sont libres : ici le nom et le rôle. [JWT::encode] fabrique le jeton et le signe avec l'algorithme [HS256] (HMAC-SHA256, une signature par clé secrète partagée) ;
  • lignes 60-69 : la vérification d'un jeton. [JWT::decode] vérifie la signature et la date d'expiration, puis rend les affirmations (un objet, converti en tableau par [(array)]). Toute anomalie (jeton falsifié, expiré, mal formé) lève une exception ; [catch (Throwable)] les intercepte toutes ([Throwable] est l'interface de toutes les exceptions et erreurs de [PHP] ; le nom de variable est facultatif quand on ne l'utilise pas). L'objet [Key] associe la clé à un algorithme : un jeton qui déclarerait un autre algorithme (voire aucun, [none]) serait refusé. Le jeton valide devient un [Utilisateur], sans hachage, avec ses affirmations (paramètre nommé [claims]).

8.1.5. L'authentificateur : src/Securite/JetonAuthenticator.php

<?php
// src/Securite/JetonAuthenticator.php : l'AUTHENTIFICATEUR du pare-feu (cf.
// config/packages/security.yaml) : à chaque requête, il établit qui est l'utilisateur,
// d'après le jeton JWT du cookie [jeton]

namespace App\Securite;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class JetonAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    public function __construct(private AuthService $auth)
    {
    }

    // l'authentificateur n'agit que si la requête porte le cookie du jeton ;
    // sinon, le visiteur reste anonyme
    public function supports(Request $request): ?bool
    {
        return $request->cookies->has(AuthService::COOKIE);
    }

    // le PASSEPORT de l'utilisateur : qui il est (le badge), et comment le vérifier.
    // Le jeton a déjà été vérifié : le passeport se valide lui-même (SelfValidatingPassport)
    public function authenticate(Request $request): Passport
    {
        $utilisateur = $this->auth->verifier($request->cookies->get(AuthService::COOKIE));
        if ($utilisateur === null) {
            throw new CustomUserMessageAuthenticationException('jeton invalide');
        }
        // le badge : le login, et une fonction qui rend l'utilisateur (ici, celui du
        // jeton, sans consulter la liste des utilisateurs)
        return new SelfValidatingPassport(new UserBadge($utilisateur->login, fn () => $utilisateur));
    }

    // un jeton valide : null, la requête poursuit son chemin vers l'action
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    // un jeton invalide (expiré, falsifié) : direction /login, et l'on efface le cookie
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $reponse = new RedirectResponse('/login');
        $reponse->headers->clearCookie(AuthService::COOKIE);
        return $reponse;
    }

    // le POINT D'ENTRÉE : la réponse faite à un visiteur anonyme qui demande une
    // page protégée ; ici, une redirection vers la page de connexion
    public function start(Request $request, ?AuthenticationException $authException = null): Response
    {
        return new RedirectResponse('/login');
    }
}

Pour chaque requête qui passe par le pare-feu, [Symfony] appelle les méthodes de l'authentificateur dans cet ordre :

  • lignes 28-31 : [supports] : l'authentificateur est-il concerné par cette requête ? Oui si elle porte le cookie du jeton. Sinon, rien ne se passe : le visiteur reste anonyme ;
  • lignes 35-44 : [authenticate] construit un passeport : l'identité de l'utilisateur (un badge, [UserBadge]), et les preuves à vérifier. Un formulaire de connexion ordinaire mettrait dans le passeport le mot de passe saisi, que [Symfony] vérifierait lui-même. Ici, la preuve est le jeton, déjà vérifié par [AuthService] : le passeport se valide lui-même ([SelfValidatingPassport]). Le badge contient le login, et une fonction qui rend l'utilisateur : ici, celui qu'a décrit le jeton, sans consulter la liste des utilisateurs (sans cette fonction, [Symfony] demanderait l'utilisateur au fournisseur). Un jeton invalide lève une exception d'authentification ;
  • lignes 47-50 : [onAuthenticationSuccess] : l'authentification a réussi. Rendre [null] laisse la requête poursuivre son chemin vers l'action, avec un utilisateur authentifié. (Un formulaire de connexion rendrait ici une redirection vers la page d'accueil) ;
  • lignes 53-58 : [onAuthenticationFailure] : l'authentification a échoué (jeton expiré ou falsifié). La réponse redirige vers la page de connexion, et en profite pour effacer le cookie : [clearCookie] ajoute à la réponse un cookie vide, déjà expiré ;
  • lignes 62-65 : [start] est la méthode de l'interface [AuthenticationEntryPointInterface] : le point d'entrée du pare-feu, c'est-à-dire la réponse faite à un visiteur anonyme qui demande une page protégée. Ici, une redirection vers la page de connexion.

L'authentificateur ne refuse rien : il établit qui est l'utilisateur, ou constate qu'il est anonyme. C'est l'authentification. Les refus sont l'affaire du contrôle d'accès.

8.1.6. Les contrôleurs

<?php
// src/Controller/AuthController.php : connexion et déconnexion

namespace App\Controller;

use App\Securite\AuthService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Attribute\Route;

class AuthController extends AbstractController
{
    public function __construct(private AuthService $auth)
    {
    }

    #[Route('/', methods: ['GET'])]
    public function accueil(): Response
    {
        return $this->redirect('/profil');
    }

    #[Route('/login', methods: ['GET'])]
    public function formulaire(): Response
    {
        return $this->render('auth/login.html.twig');
    }

    #[Route('/login', methods: ['POST'])]
    public function connexion(Request $request): Response
    {
        // le jeton anti-CSRF du formulaire (cf. exemple 21)
        if (! $this->isCsrfTokenValid('login', $request->getPayload()->getString('_csrf_token'))) {
            throw new AccessDeniedHttpException('jeton anti-CSRF invalide');
        }
        $login = $request->getPayload()->getString('login');
        $utilisateur = $this->auth->valider($login, $request->getPayload()->getString('motDePasse'));
        if ($utilisateur === null) {
            // un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
            return $this->render('auth/login.html.twig', ['login' => $login,
                'erreur' => 'Login ou mot de passe incorrect.']);
        }
        // le jeton, dans un cookie : le JavaScript de la page ne peut pas le lire
        // (httpOnly) ; il n'accompagne pas les requêtes POST venues d'autres sites
        // (sameSite) ; il disparaît en même temps que le jeton expire
        $reponse = $this->redirect('/profil');
        $reponse->headers->setCookie(Cookie::create(AuthService::COOKIE, $this->auth->creerJeton($utilisateur))
            ->withExpires(time() + $this->auth->dureeMinutes * 60)
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX));
        return $reponse;
    }

    // la déconnexion : POST seulement (un simple lien, placé sur un autre site, ne
    // peut pas déconnecter l'utilisateur à son insu) ; on efface le cookie
    #[Route('/logout', methods: ['POST'])]
    public function deconnexion(Request $request): Response
    {
        if (! $this->isCsrfTokenValid('logout', $request->getPayload()->getString('_csrf_token'))) {
            throw new AccessDeniedHttpException('jeton anti-CSRF invalide');
        }
        $reponse = $this->redirect('/login');
        $reponse->headers->clearCookie(AuthService::COOKIE);
        return $reponse;
    }
}
  • lignes 36-38 : le formulaire de connexion est protégé contre les requêtes forgées, comme tous les formulaires (exemple 21) ;
  • lignes 40-45 : la connexion. En cas d'échec, le formulaire est réaffiché avec le login saisi et un message volontairement vague : dire « login inconnu » apprendrait à un pirate quels logins existent ;
  • lignes 49-54 : en cas de succès, le jeton est déposé dans un cookie, avec une redirection vers la page protégée (schéma PRG). Le cookie est [httpOnly] : le [JavaScript] de la page ne peut pas le lire, ce qui protège le jeton contre une injection de script (XSS) ; il est [SameSite=Lax] : le navigateur ne l'envoie pas avec une requête POST venue d'un autre site, ce qui protège l'application contre les attaques CSRF ; sa date d'expiration ([withExpires], ici un nombre de secondes depuis 1970) est celle du jeton. En production, sur HTTPS, on ajouterait [withSecure(true)] : le cookie ne serait jamais envoyé sur une connexion non chiffrée ;
  • lignes 59-68 : la déconnexion efface le cookie ([clearCookie]). C'est une action POST, protégée par un jeton anti-CSRF : un simple lien vers [/logout], placé sur un autre site ou dans une image, ne peut pas déconnecter l'utilisateur à son insu. Remarquons une limite des jetons : le jeton lui-même reste valide jusqu'à son expiration. Quelqu'un qui en aurait fait une copie pourrait encore s'en servir. C'est pourquoi on limite leur durée de vie.

[Symfony] sait aussi gérer lui-même la connexion par formulaire (l'authentificateur [form_login]) et la déconnexion (l'option [logout] du pare-feu) ; mais ils rangent l'utilisateur dans la session, là où nous voulons un jeton.

<?php
// src/Controller/ProfilController.php : une page réservée aux utilisateurs authentifiés
// (cf. access_control dans config/packages/security.yaml)

namespace App\Controller;

use App\Securite\Utilisateur;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

class ProfilController extends AbstractController
{
    // #[CurrentUser] : l'utilisateur authentifié de la requête, celui qu'a trouvé
    // l'authentificateur
    #[Route('/profil', methods: ['GET'])]
    public function afficher(#[CurrentUser] Utilisateur $utilisateur): Response
    {
        return $this->render('profil/profil.html.twig', ['utilisateur' => $utilisateur]);
    }
}
  • ligne 18 : l'attribut [#[CurrentUser]] fournit l'utilisateur authentifié, celui qu'a établi l'authentificateur. L'action est protégée par la règle [access_control] de [security.yaml] : elle est certaine de recevoir un utilisateur. On l'obtiendrait aussi par [$this->getUser()].

8.1.7. Les vues

{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    <input type="hidden" name="_csrf_token" value="{{ csrf_token('login') }}">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login ?? '' }}" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="motDePasse">Mot de passe</label>
      <input class="form-control" type="password" id="motDePasse" name="motDePasse" required>
    </div>
    <button class="btn btn-primary" type="submit">Se connecter</button>
  </form>
  {% if erreur is defined %}
    <div class="alert alert-danger mt-3">{{ erreur }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
{% endblock %}
  • ligne 6 : le jeton anti-CSRF du formulaire (exemple 21) ;
  • ligne 9 : le login saisi est réaffiché après un échec ([??] : une chaîne vide quand la variable [login] n'existe pas).
{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Bonjour {{ utilisateur.nom }}</h1>
  <p>Votre rôle : <b>{{ utilisateur.role == 'ADMIN' ? 'administrateur' : 'utilisateur' }}</b>.</p>
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    {% for cle, valeur in utilisateur.claims %}
      <tr><td><code>{{ cle }}</code></td><td>{{ valeur }}</td></tr>
    {% endfor %}
  </table>
  {# la déconnexion : un formulaire POST réduit à son bouton #}
  <form method="post" action="/logout">
    <input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
{% endblock %}
  • lignes 8-10 : le contenu du jeton, affiché pour l'exemple. Une boucle [for] de [Twig] peut parcourir les clés et les valeurs d'un tableau associatif ([for cle, valeur in ...]) ;
  • lignes 13-16 : la déconnexion, par un formulaire réduit à son bouton.

L'utilisateur authentifié est aussi disponible dans toutes les vues, sous le nom [app.user].

8.1.8. Exécution

Demandons la page protégée [/profil] sans être connecté :

curl -i http://localhost:8000/profil
1
2
3
4
HTTP/1.1 302 Found
...
Location: /login
...

Un mot de passe erroné :

Image

La connexion réussie. Avec [curl], il faut d'abord demander le formulaire, pour obtenir le jeton anti-CSRF et le cookie de session (exemple 21), puis envoyer le formulaire en conservant le cookie reçu :

curl -c jeton.txt -b jeton.txt http://localhost:8000/login
curl -i -c jeton.txt -b jeton.txt -d "_csrf_token=...&login=marie&motDePasse=marie" http://localhost:8000/login
1
2
3
4
HTTP/1.1 302 Found
Location: /profil
Set-Cookie: jeton=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtYXJpZSIsIm5vbSI6Ik1hcmllIFBcdTAwZTlsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTA1MDI3MzcsImV4cCI6MTc5MDUwNjMzN30.qcPHkxe01atNrLDX8wAY4ua6rKzznYDwZzGFtWLkVQs; expires=Sun, 27 Sep 2026 10:52:17 GMT; Max-Age=3600; path=/; httponly; samesite=lax
...

Le jeton a trois parties, séparées par des points, chacune encodée en base 64 : l'entête, le contenu, la signature. Les deux premières se décodent sans aucune clé :

{"typ":"JWT","alg":"HS256"}
{"sub":"marie","nom":"Marie P\u00e9lissier","role":"USER","iat":1790502737,"exp":1790506337}

(Le [é] est écrit sous sa forme JSON échappée, [\u00e9].) L'expiration est une heure (3 600 secondes) après la création. Dans le navigateur, la page de profil affiche ce même contenu :

Image

Un utilisateur malhonnête modifie son jeton : il remplace, dans le contenu, le rôle [USER] par [ADMIN], réencode cette partie en base 64, et garde la signature d'origine. Le serveur recalcule la signature du contenu modifié, qui ne correspond plus :

curl -i -b "jeton=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.<contenu modifié>.qcPHkxe01atNrLDX8wAY4ua6rKzznYDwZzGFtWLkVQs" http://localhost:8000/profil
1
2
3
4
HTTP/1.1 302 Found
Location: /login
Set-Cookie: jeton=deleted; expires=Sat, 27 Sep 2025 09:52:20 GMT; Max-Age=0; path=/; httponly
...

Le jeton falsifié est refusé, et effacé (la valeur [deleted] et la date d'expiration passée demandent au navigateur de supprimer le cookie). Pour fabriquer une signature valide, il faudrait connaître la clé secrète : d'où l'importance de la garder secrète, et de la choisir longue et aléatoire.

8.2. Exemple 26 : les rôles

L'exemple 26 ajoute une page d'administration, réservée au rôle ADMIN. Seuls les fichiers nouveaux ou modifiés sont présentés.

8.2.1. La configuration : config/packages/security.yaml

# config/packages/security.yaml : la configuration du SecurityBundle (cf. exemple 25)
security:
    password_hashers:
        App\Securite\Utilisateur:
            algorithm: bcrypt

    providers:
        utilisateurs:
            id: App\Securite\UtilisateursService

    # la HIÉRARCHIE des rôles : un administrateur a aussi les droits d'un utilisateur
    role_hierarchy:
        ROLE_ADMIN: ROLE_USER

    firewalls:
        ressources:
            pattern: ^/bootstrap/
            security: false
        principal:
            stateless: true
            provider: utilisateurs
            custom_authenticators:
                - App\Securite\JetonAuthenticator
            # un utilisateur authentifié, mais qui n'a pas le droit demandé : notre page 403
            access_denied_handler: App\Securite\AccesRefuse

    # /profil : tout utilisateur authentifié ; /admin est protégée par l'attribut
    # #[IsGranted] de son contrôleur ; on aurait pu écrire ici :
    # - { path: ^/admin, roles: ROLE_ADMIN }
    access_control:
        - { path: ^/profil, roles: IS_AUTHENTICATED }
  • lignes 12-13 : la hiérarchie des rôles : le rôle [ROLE_ADMIN] comprend le rôle [ROLE_USER]. Un administrateur a ainsi tous les droits d'un utilisateur, sans qu'il faille lui donner les deux rôles ;
  • ligne 25 : le gestionnaire des refus d'accès du pare-feu ([access_denied_handler]) : la classe qui fabrique la réponse faite à un utilisateur authentifié qui demande une page qui lui est interdite ;
  • lignes 27-31 : la page [/admin] pourrait être protégée ici, par une règle de plus ; nous la protégeons plutôt dans son contrôleur, pour montrer l'autre façon de faire.

La différence entre les deux refus est importante : un visiteur anonyme qui demande une page protégée est envoyé au point d'entrée du pare-feu (la page de connexion, exemple 25) ; un utilisateur authentifié qui n'a pas le rôle demandé reçoit une erreur 403 (Forbidden) : se connecter ne lui servirait à rien, puisqu'il l'est déjà.

8.2.2. Le refus d'accès : src/Securite/AccesRefuse.php

<?php
// src/Securite/AccesRefuse.php : la réponse faite à un utilisateur AUTHENTIFIÉ qui
// demande une page qui lui est interdite (cf. access_denied_handler dans security.yaml)

namespace App\Securite;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
use Twig\Environment;

class AccesRefuse implements AccessDeniedHandlerInterface
{
    public function __construct(private Environment $twig)
    {
    }

    public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response
    {
        return new Response($this->twig->render('erreurs/403.html.twig'), Response::HTTP_FORBIDDEN);
    }
}
  • ligne 13 : la classe implémente l'interface [AccessDeniedHandlerInterface], dont l'unique méthode [handle] rend la réponse du refus ;
  • ligne 21 : la page de l'erreur 403, fabriquée par [Twig] (comme l'écouteur [PageErreur] de l'exemple 24), avec le code 403 ([Response::HTTP_FORBIDDEN], une constante de la classe [Response]).

Sans ce gestionnaire, le refus deviendrait une exception [AccessDeniedHttpException], et l'utilisateur verrait la page d'erreur 403 de [Symfony] (détaillée en mode debug). La page [templates/erreurs/403.html.twig] est une page d'erreur ordinaire, avec un lien de retour vers le profil.

8.2.3. Le contrôleur : src/Controller/AdminController.php

<?php
// src/Controller/AdminController.php : une page réservée au rôle ADMIN

namespace App\Controller;

use App\Securite\Utilisateur;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class AdminController extends AbstractController
{
    // #[IsGranted] : l'action n'est exécutée que si l'utilisateur a le rôle ADMIN
    #[Route('/admin', methods: ['GET'])]
    #[IsGranted('ROLE_ADMIN')]
    public function afficher(#[CurrentUser] Utilisateur $utilisateur): Response
    {
        return $this->render('admin/admin.html.twig', ['nom' => $utilisateur->nom]);
    }
}
  • ligne 17 : l'attribut [#[IsGranted]] n'autorise l'exécution de l'action qu'à un utilisateur qui a le droit demandé, ici le rôle [ROLE_ADMIN]. Un visiteur anonyme est envoyé au point d'entrée ; un utilisateur authentifié sans ce rôle reçoit le refus d'accès. Placé sur la classe, l'attribut protégerait toutes ses actions.

Le contrôleur ne fait aucune vérification lui-même. Dans le code, on vérifierait un droit par [$this->isGranted('ROLE_ADMIN')] (qui rend un booléen) ou [$this->denyAccessUnlessGranted('ROLE_ADMIN')] (qui lève l'exception du refus). Pour des règles plus fines qu'un rôle (« un utilisateur ne peut modifier que ses propres rendez-vous »), [Symfony] propose d'écrire des voteurs (voters) : des classes qui décident d'un droit, que [#[IsGranted]] et [is_granted] interrogent comme un rôle.

8.2.4. La vue du profil

{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Bonjour {{ utilisateur.nom }}</h1>
  <p>Votre rôle : <b>{{ utilisateur.role == 'ADMIN' ? 'administrateur' : 'utilisateur' }}</b>.</p>
  {# is_granted : l'utilisateur a-t-il ce rôle ? Le bloc n'est affiché que dans ce cas #}
  {% if is_granted('ROLE_ADMIN') %}
    <p><a href="/admin">L'espace d'administration</a></p>
  {% else %}
    <p><a href="/admin">L'espace d'administration</a> (qui vous est interdit)</p>
  {% endif %}
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    {% for cle, valeur in utilisateur.claims %}
      <tr><td><code>{{ cle }}</code></td><td>{{ valeur }}</td></tr>
    {% endfor %}
  </table>
  {# la déconnexion : un formulaire POST réduit à son bouton #}
  <form method="post" action="/logout">
    <input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
{% endblock %}
  • lignes 7-11 : la fonction [is_granted] de [Twig] teste un droit de l'utilisateur. On évite ainsi de proposer un lien qui mènerait à une erreur ; mais c'est un confort : la vraie protection est celle de l'action.

8.2.5. Exécution

Marie, de rôle USER, suit le lien vers la page d'administration :

Image

L'administrateur, lui, y a accès :

Image

Avec [curl] : un utilisateur de rôle USER (connecté comme à l'exemple 25, ses cookies étant dans le fichier [jeton.txt]), puis un visiteur anonyme :

curl -i -b jeton.txt http://localhost:8000/admin
curl -i http://localhost:8000/admin
HTTP/1.1 403 Forbidden
...
        <div class="alert alert-danger">
    <h1 class="h4">Erreur 403</h1>
    <p class="mb-0">Accès refusé : cette page est réservée aux administrateurs.</p>
  </div>
...
HTTP/1.1 302 Found
...
Location: /login
...

Protéger les routes une par une a un défaut : un oubli ouvre une page à tous. L'étude de cas renversera la logique : toutes les URL y sont protégées par défaut, par une règle [access_control] générale, et quelques URL seulement (la page de connexion, l'inscription) en sont exemptées.

8.3. Exemple 27 : captcha et limitation des tentatives

Un formulaire de connexion est une cible : un programme peut essayer des milliers de mots de passe par minute (attaque par force brute). Deux protections se complètent :

  • un captcha (Completely Automated Public Turing test to tell Computers and Humans Apart) : une image contenant un code déformé, facile à lire pour un humain, difficile pour un programme ;
  • une limitation des tentatives : après trois échecs, l'adresse IP du client est bloquée une minute.

L'exemple 27 reprend l'exemple 25, et ajoute ces deux protections.

8.3.1. Le dessin du captcha : src/Securite/DessinCaptcha.php

<?php
// src/Securite/DessinCaptcha.php : le dessin d'un captcha, une image SVG (du texte XML)
// fabriquée à la main

namespace App\Securite;

class DessinCaptcha
{
    public const LARGEUR = 150;

    public const HAUTEUR = 50;

    // La "police" : chaque caractère est une liste de traits, chaque trait une
    // suite de points x, y dans une grille de 4 x 6 (y vers le bas).
    // Caractères trop faciles à confondre entre eux : exclus (0 O, 1 I L).
    private const POLICE = [
        'A' => [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
        'B' => [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
        'C' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
        'D' => [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
        'E' => [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
        'F' => [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
        'G' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
        'H' => [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
        'J' => [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
        'K' => [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
        'M' => [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
        'N' => [[0, 6, 0, 0, 4, 6, 4, 0]],
        'P' => [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
        'Q' => [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
        'R' => [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
        'S' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        'T' => [[0, 0, 4, 0], [2, 0, 2, 6]],
        'U' => [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
        'V' => [[0, 0, 2, 6, 4, 0]],
        'W' => [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
        'X' => [[0, 0, 4, 6], [4, 0, 0, 6]],
        'Y' => [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
        'Z' => [[0, 0, 4, 0, 0, 6, 4, 6]],
        '2' => [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
        '3' => [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        '4' => [[3, 6, 3, 0, 0, 4, 4, 4]],
        '5' => [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
        '6' => [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
        '7' => [[0, 0, 4, 0, 1.5, 6]],
        '8' => [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5,
            4, 4, 3, 3]],
        '9' => [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
    ];

    // quelques couleurs foncées, bien lisibles sur le fond clair
    private const COULEURS = ['#1f4e79', '#7b2d26', '#2e6b30', '#5b2c83', '#8a5a00', '#23395d', '#9c2a5b'];

    // un texte aléatoire. random_int est un générateur "cryptographique",
    // imprévisible (contrairement à rand ou mt_rand, dont la suite pourrait être devinée)
    public static function texteAleatoire(int $longueur): string
    {
        $alphabet = array_keys(self::POLICE);
        $texte = '';
        for ($i = 0; $i < $longueur; $i++) {
            $texte .= $alphabet[random_int(0, count($alphabet) - 1)];
        }
        return $texte;
    }

    // un nombre à virgule, au hasard, entre $minimum et $maximum
    private static function hasard(float $minimum, float $maximum): float
    {
        return $minimum + ($maximum - $minimum) * random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
    }

    private static function couleur(): string
    {
        return self::COULEURS[random_int(0, count(self::COULEURS) - 1)];
    }

    public static function svg(string $texte): string
    {
        $l = self::LARGEUR;
        $h = self::HAUTEUR;
        $morceaux = ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"$l\" height=\"$h\" viewBox=\"0 0 $l $h\">",
            '<rect width="100%" height="100%" fill="#f5f7fa"/>'];

        // les caractères : chacun agrandi, penché, décalé, et chaque point
        // légèrement déplacé au hasard
        $pas = ($l - 20) / strlen($texte);
        foreach (str_split($texte) as $i => $caractere) {
            $echelle = self::hasard(4.6, 5.6);    // taille d'une unité de la grille, en pixels
            $angle = self::hasard(-0.35, 0.35);   // rotation, en radians (environ ±20°)
            $cx = 10 + $pas * ($i + 0.5) + self::hasard(-3, 3);    // centre du caractère
            $cy = $h / 2 + self::hasard(-4, 4);
            foreach (self::POLICE[$caractere] as $trait) {
                $d = [];
                for ($k = 0; $k < count($trait); $k += 2) {
                    // point de la grille, centré sur (2, 3), déformé, tourné, placé
                    $x = ($trait[$k] - 2 + self::hasard(-0.2, 0.2)) * $echelle;
                    $y = ($trait[$k + 1] - 3 + self::hasard(-0.2, 0.2)) * $echelle;
                    $px = $cx + $x * cos($angle) - $y * sin($angle);
                    $py = $cy + $x * sin($angle) + $y * cos($angle);
                    $d[] = sprintf('%s%.1f %.1f', $k === 0 ? 'M' : 'L', $px, $py);
                }
                $morceaux[] = sprintf('<path d="%s" fill="none" stroke="%s" stroke-width="%.1f" '
                    .'stroke-linecap="round" stroke-linejoin="round"/>',
                    implode(' ', $d), self::couleur(), self::hasard(2.2, 3));
            }
        }

        // 3 traits de brouillage : des courbes qui traversent l'image
        for ($n = 0; $n < 3; $n++) {
            $morceaux[] = sprintf('<path d="M%.1f %.1f C%.1f %.1f %.1f %.1f %.1f %.1f" fill="none" '
                .'stroke="%s" stroke-width="1.3"/>',
                self::hasard(0, 20), self::hasard(5, 45), self::hasard(30, 60), self::hasard(0, 50),
                self::hasard(90, 120), self::hasard(0, 50), self::hasard(130, 150), self::hasard(5, 45),
                self::couleur());
        }
        $morceaux[] = '</svg>';
        return implode('', $morceaux);
    }
}

L'image est au format SVG (Scalable Vector Graphics) : un dessin vectoriel décrit en XML, c'est-à-dire du texte, que le serveur fabrique sans bibliothèque graphique, et que le navigateur affiche directement dans la page. Cette classe est du [PHP] pur, sans rien de propre à un framework : c'est celle de la version [Laravel] de ce cours.

  • lignes 16-49 : une « police » de caractères minimaliste : chaque caractère est une liste de traits, chaque trait une liste de coordonnées [x, y] dans une grille de 4 × 6. Les caractères faciles à confondre ([0] et [O], [1], [I] et [L]) sont exclus ;
  • lignes 56-64 : un texte aléatoire, tiré parmi les caractères de la police ([array_keys], les clés du tableau). Les fonctions [rand] et [mt_rand] de [PHP] produisent des suites de nombres prévisibles (qui connaît quelques valeurs peut deviner les suivantes) : elles conviennent aux simulations, pas à la sécurité. [random_int] est un générateur cryptographique, imprévisible ;
  • lignes 67-70 : un nombre à virgule au hasard, fabriqué à partir d'un entier aléatoire ([PHP_INT_MAX] est le plus grand entier de [PHP]) ;
  • lignes 77-118 : le dessin. Chaque caractère est agrandi, tourné et déplacé au hasard, et chacun de ses points est légèrement décalé ; chaque trait devient un élément [<path>] du SVG ([M] : déplacement, [L] : ligne). Trois courbes ([C] : courbe de Bézier) traversent l'image pour gêner les programmes de reconnaissance. La fonction [sprintf] met en forme une chaîne : [%s] y insère une chaîne, [%.1f] un nombre avec une décimale. [str_split] découpe une chaîne en caractères.

La classe [Captcha] réunit l'identifiant d'un captcha et son image :

<?php
// src/Securite/Captcha.php : un captcha : son identifiant, et son image (SVG)

namespace App\Securite;

readonly class Captcha
{
    public function __construct(
        public string $id,
        public string $svg,
    ) {
    }
}

8.3.2. Le service des captchas : src/Securite/CaptchaService.php

<?php
// src/Securite/CaptchaService.php : les captchas en attente, gardés par le serveur,
// dans le cache (cf. exemple 20)

namespace App\Securite;

use Psr\Cache\CacheItemPoolInterface;

class CaptchaService
{
    // la durée de vie d'un captcha, en secondes
    private const DUREE_VIE = 600;

    public function __construct(private CacheItemPoolInterface $cache)
    {
    }

    public function generer(): Captcha
    {
        $texte = DessinCaptcha::texteAleatoire(5);
        // un identifiant aléatoire, impossible à deviner : 12 octets tirés par un
        // générateur cryptographique (random_bytes), écrits en hexadécimal (bin2hex)
        $id = bin2hex(random_bytes(12));
        // la réponse attendue, dans le cache, pour 10 minutes (expiresAfter) : le
        // cache l'oubliera de lui-même ensuite
        $element = $this->cache->getItem("captcha.$id");
        $this->cache->save($element->set($texte)->expiresAfter(self::DUREE_VIE));
        return new Captcha($id, DessinCaptcha::svg($texte));
    }

    // le captcha est CONSOMMÉ dans tous les cas : une image ne sert qu'une fois
    public function verifier(?string $id, ?string $saisie): bool
    {
        // un identifiant mal formé ne peut pas être une clé du cache
        if ($id === null || ! ctype_xdigit($id) || $saisie === null) {
            return false;
        }
        $element = $this->cache->getItem("captcha.$id");
        // deleteItem : la réponse est retirée du cache, qu'elle soit bonne ou non
        $this->cache->deleteItem("captcha.$id");
        if (! $element->isHit()) {
            return false;    // identifiant inconnu, ou captcha expiré
        }
        // strcasecmp rend 0 pour deux chaînes égales, majuscules et minuscules confondues
        return strcasecmp($element->get(), trim($saisie)) === 0;
    }
}
  • lignes 18-29 : un nouveau captcha : un texte aléatoire, un identifiant aléatoire et impossible à deviner, et le dessin. Le serveur garde, pour chaque identifiant, la réponse attendue, dans le cache (exemple 20), pour dix minutes : la méthode [expiresAfter] de l'élément de cache fixe sa durée de vie, en secondes. Passé ce délai, le cache l'oublie de lui-même. La page ne reçoit que l'identifiant et l'image : la réponse ne quitte jamais le serveur ;
  • lignes 32-46 : la vérification. L'identifiant vient du client : on vérifie d'abord qu'il est fait de chiffres hexadécimaux ([ctype_xdigit]), car certains caractères ([{}()/\@:]) sont interdits dans les clés du cache, qui lèverait une exception. L'élément est lu, puis retiré du cache ([deleteItem]) : le captcha est consommé dans tous les cas, réussi ou non. Sans cela, un programme pourrait essayer toutes les réponses sur une même image. La comparaison ignore la casse ([strcasecmp] rend 0 pour deux chaînes égales, majuscules et minuscules confondues) et les espaces autour de la saisie ([trim]).

8.3.3. La limitation des tentatives : config/packages/framework.yaml

# config/packages/framework.yaml : la configuration du FrameworkBundle
framework:
    secret: '%env(APP_SECRET)%'
    # la session : elle ne sert qu'au jeton anti-CSRF du formulaire de connexion
    session:
        handler_id: null
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
    csrf_protection: true
    # les verrous (cf. exemple 20) : le limiteur s'en sert pour compter sans erreur
    # les tentatives simultanées
    lock: 'flock://%kernel.project_dir%/var/lock'
    # les LIMITEURS de débit (composant RateLimiter) ; celui-ci, nommé [login],
    # accepte 3 "jetons" par fenêtre de 60 secondes
    rate_limiter:
        login:
            policy: fixed_window
            limit: 3
            interval: '60 seconds'
  • ligne 11 : les verrous (exemple 20) : le limiteur s'en sert pour que deux requêtes simultanées ne faussent pas son compte ;
  • lignes 14-18 : un limiteur de débit (rate limiter), nommé [login]. Un limiteur distribue des « jetons » (sans rapport avec le jeton JWT) : chaque action limitée en consomme un, et, quand il n'y en a plus, l'action est refusée. La politique [fixed_window] (fenêtre fixe) accorde 3 jetons par période de 60 secondes, comptée à partir de la première consommation. Il existe d'autres politiques : [sliding_window] (une fenêtre glissante, plus régulière), [token_bucket] (un seau qui se remplit à un rythme donné). Les compteurs sont rangés dans le cache de l'application.

[Symfony] fabrique, pour chaque limiteur déclaré, un service fabrique de limiteurs ([RateLimiterFactoryInterface]), que l'on obtient par injection, sous un nom de paramètre formé du nom du limiteur suivi de [Limiter] : [$loginLimiter].

8.3.4. Le contrôleur : src/Controller/AuthController.php

<?php
// src/Controller/AuthController.php : connexion (avec captcha et limitation des
// tentatives) et déconnexion

namespace App\Controller;

use App\Securite\AuthService;
use App\Securite\CaptchaService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\Routing\Attribute\Route;

class AuthController extends AbstractController
{
    // $loginLimiter : la fabrique du limiteur [login] (cf. config/packages/framework.yaml) ;
    // le nom du paramètre désigne le limiteur
    public function __construct(
        private AuthService $auth,
        private CaptchaService $captchas,
        private RateLimiterFactoryInterface $loginLimiter,
    ) {
    }

    #[Route('/', methods: ['GET'])]
    public function accueil(): Response
    {
        return $this->redirect('/profil');
    }

    #[Route('/login', methods: ['GET'])]
    public function formulaire(): Response
    {
        return $this->pageLogin();
    }

    #[Route('/login', methods: ['POST'])]
    public function connexion(Request $request): Response
    {
        $saisies = $request->getPayload();
        if (! $this->isCsrfTokenValid('login', $saisies->getString('_csrf_token'))) {
            throw new AccessDeniedHttpException('jeton anti-CSRF invalide');
        }
        $login = $saisies->getString('login');
        // le limiteur de CE client : son compteur d'échecs a pour clé son adresse IP
        $limiteur = $this->loginLimiter->create($request->getClientIp());

        // 1. une adresse bloquée n'est même pas écoutée ; consume(0) consulte le
        // compteur sans rien consommer
        $etat = $limiteur->consume(0);
        if ($etat->getRemainingTokens() === 0) {
            $secondes = $etat->getRetryAfter()->getTimestamp() - time();
            return $this->pageLogin($login, "Trop de tentatives : réessayez dans $secondes s.");
        }
        // 2. le captcha d'abord (peu coûteux), le mot de passe (bcrypt, coûteux) ensuite ;
        // consume() : un échec de plus
        if (! $this->captchas->verifier($saisies->getString('captchaId'), $saisies->getString('captcha'))) {
            $limiteur->consume();
            return $this->pageLogin($login, "Le code de l'image est incorrect.");
        }
        $utilisateur = $this->auth->valider($login, $saisies->getString('motDePasse'));
        if ($utilisateur === null) {
            $limiteur->consume();
            return $this->pageLogin($login, 'Login ou mot de passe incorrect.');
        }
        // une connexion réussie efface les échecs
        $limiteur->reset();
        $reponse = $this->redirect('/profil');
        $reponse->headers->setCookie(Cookie::create(AuthService::COOKIE, $this->auth->creerJeton($utilisateur))
            ->withExpires(time() + $this->auth->dureeMinutes * 60)
            ->withHttpOnly(true)
            ->withSameSite(Cookie::SAMESITE_LAX));
        return $reponse;
    }

    #[Route('/logout', methods: ['POST'])]
    public function deconnexion(Request $request): Response
    {
        if (! $this->isCsrfTokenValid('logout', $request->getPayload()->getString('_csrf_token'))) {
            throw new AccessDeniedHttpException('jeton anti-CSRF invalide');
        }
        $reponse = $this->redirect('/login');
        $reponse->headers->clearCookie(AuthService::COOKIE);
        return $reponse;
    }

    // chaque affichage du formulaire fabrique un NOUVEAU captcha
    private function pageLogin(?string $login = null, ?string $erreur = null): Response
    {
        return $this->render('auth/login.html.twig', ['login' => $login, 'erreur' => $erreur,
            'captcha' => $this->captchas->generer()]);
    }
}
  • lignes 21-26 : le contrôleur reçoit la fabrique du limiteur [login] ;
  • ligne 49 : le limiteur de ce client : [create(clé)] rend un limiteur qui a son propre compteur, sous cette clé, ici l'adresse IP du client ([getClientIp()]) ;
  • lignes 53-57 : une adresse bloquée n'est même pas écoutée. [consume(0)] consulte le limiteur sans rien consommer ; l'objet [RateLimit] qu'il rend donne le nombre de jetons restants ([getRemainingTokens()]) et la date à laquelle l'action sera de nouveau permise ([getRetryAfter()]), dont on déduit le nombre de secondes à attendre ;
  • lignes 60-68 : le captcha est vérifié avant le mot de passe : sa vérification ne coûte presque rien, alors que [bcrypt] est volontairement lent. Un programme qui se tromperait de captcha ne ferait ainsi pas travailler [bcrypt]. Chaque échec, de captcha ou de mot de passe, consomme un jeton ([consume()], qui en consomme un par défaut). Au troisième échec, il n'en reste plus : l'adresse est bloquée jusqu'à la fin de la fenêtre de 60 secondes, comptées depuis le premier échec ;
  • ligne 70 : une connexion réussie efface les échecs ([reset()]) ;
  • lignes 91-95 : chaque affichage du formulaire fabrique un nouveau captcha, y compris après un échec.

Le SecurityBundle offre aussi une limitation intégrée des connexions (l'option [login_throttling] du pare-feu), qui s'applique à ses propres authentificateurs de connexion ([form_login]...), et non à notre action. Pour limiter le nombre de requêtes d'une route, on utiliserait un limiteur de la même façon, dans l'action ou dans un écouteur de [kernel.request]. Nous avons préféré compter les seuls échecs, pour ne pas gêner un utilisateur qui se connecte normalement.

8.3.5. La vue : templates/auth/login.html.twig

{% extends 'layout.html.twig' %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    <input type="hidden" name="_csrf_token" value="{{ csrf_token('login') }}">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login }}" required>
    </div>
    <div class="mb-2">
      <label class="form-label" for="motDePasse">Mot de passe</label>
      <input class="form-control" type="password" id="motDePasse" name="motDePasse" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="captcha">Recopiez le code de l'image</label>
      {# l'image SVG, fabriquée par le serveur : insérée sans échappement (filtre raw) #}
      <div class="mb-1">{{ captcha.svg|raw }}</div>
      {# l'identifiant du captcha voyage dans un champ caché ; la réponse, elle, reste sur le serveur #}
      <input type="hidden" name="captchaId" value="{{ captcha.id }}">
      <input class="form-control" id="captcha" name="captcha" autocomplete="off" required>
    </div>
    <button class="btn btn-primary" type="submit">Se connecter</button>
  </form>
  {% if erreur %}
    <div class="alert alert-danger mt-3">{{ erreur }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
{% endblock %}
  • ligne 18 : l'image SVG est insérée telle quelle dans la page, sans échappement (filtre [raw], exemple 14). C'est légitime : ce HTML a été fabriqué par le serveur, il ne contient aucune saisie de l'utilisateur ;
  • ligne 20 : l'identifiant du captcha, dans un champ caché ;
  • ligne 21 : le navigateur ne propose pas de saisies précédentes pour ce champ.

8.3.6. Exécution

Image

Un code mal recopié :

Image

Après trois échecs, l'adresse est bloquée :

Image

Avec [curl], quatre tentatives avec un identifiant de captcha inventé (après avoir obtenu, comme à l'exemple 25, le cookie de session et le jeton anti-CSRF) :

curl -b cookies.txt -d "_csrf_token=...&login=marie&motDePasse=marie&captchaId=abc&captcha=XXXXX" http://localhost:8000/login
1
2
3
4
      <div class="alert alert-danger mt-3">Le code de l&#039;image est incorrect.</div>
      <div class="alert alert-danger mt-3">Le code de l&#039;image est incorrect.</div>
      <div class="alert alert-danger mt-3">Le code de l&#039;image est incorrect.</div>
      <div class="alert alert-danger mt-3">Trop de tentatives : réessayez dans 60 s.</div>

Même avec le bon mot de passe, la quatrième tentative est refusée sans examen. L'apostrophe du message a été échappée par [Twig] ([&#039;]).