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
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) :
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 :
et sous [Windows] (PowerShell) :
(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 :
La session ne sert ici qu'au jeton anti-CSRF du formulaire de connexion (exemple 21) :
8.1.2. La configuration de la sécurité : config/packages/security.yaml
- 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
- 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].
- 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
- 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
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
- 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.
- 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
- 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).
- 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é :
Un mot de passe erroné :

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 :
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é :
(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 :

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 :
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
- 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
- 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
- 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
- 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 :

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

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 :
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | |
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 :
8.3.2. Le service des captchas : src/Securite/CaptchaService.php
- 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
- 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
- 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
- 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

Un code mal recopié :

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

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) :
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] ([']).