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. [Laravel] range d'ordinaire l'identifiant de 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]), 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. 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.
[Laravel] possède un système d'authentification complet, dont les éléments nous serviront tels quels :
- l'authentification est confiée à une garde (*guard*) : un objet qui, pour chaque requête, trouve l'utilisateur authentifié. [Laravel] fournit une garde qui lit la session ; nous écrirons la nôtre, qui lit le jeton, en quelques lignes. Le reste de l'application ne voit que la garde : [auth()->user()], [#[CurrentUser]], [@auth] dans les vues ;
- la protection des pages est l'affaire de middlewares de route (chapitre précédent) : [auth] exige un utilisateur authentifié, [can:admin] une autorisation ;
- les autorisations sont décrites par des Gates (des « portes », exemple 26) ;
- les mots de passe sont hachés par la façade [Hash] (algorithme *bcrypt*), et les tentatives limitées par la façade [RateLimiter] (exemple 27).
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.
8.1. Exemple 25 : connexion, jeton, page protégée
8.1.1. La configuration : .env, config/jwt.php et config/auth.php
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 deux nouvelles lignes :
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] : il lit la configuration de l'application, un ensemble de fichiers [PHP] rangés dans le dossier [config], qui rendent chacun un tableau. Nous créons le fichier [config/jwt.php] :
- ligne 6 : la fonction [env] lit une variable de l'environnement du processus, ou, à défaut, du fichier [.env] ; son second paramètre est une valeur par défaut. Une variable d'environnement l'emporte sur le fichier : en production, la clé secrète est fournie par l'environnement, et le fichier [.env] n'en contient pas ;
- ligne 8 : les variables d'environnement sont des chaînes : [(int)] convertit la durée en entier.
Le code lit ces valeurs par la fonction [config] : [config('jwt.secret')] désigne la clé [secret] du fichier [jwt.php]. On n'appelle [env] que dans les fichiers de configuration : en production, la commande [php artisan config:cache] réunit toute la configuration dans un seul fichier, pour aller plus vite, et [.env] n'est alors plus lu. Le fichier [config/auth.php] configure l'authentification :
[Laravel] a sa propre configuration de l'authentification (le fichier [vendor/laravel/framework/config/auth.php], cf. exemple 01) ; celle de l'application la complète : les clés qu'elle fournit remplacent celles du framework, les autres gardent leur valeur. Nous déclarons une garde nommée [jwt], de pilote [jwt] (ligne 12), et en faisons la garde par défaut (ligne 7). Le pilote [jwt] n'existe pas dans [Laravel] : nous le définissons dans [bootstrap/app.php].
Sous [Linux] ou [macOS], on lancerait l'application avec une autre clé ainsi :
et sous [Windows] (PowerShell) :
8.1.2. Les utilisateurs : app/Auth/Utilisateur.php et UtilisateursService.php
- ligne 9 : un utilisateur. [Laravel] attend des utilisateurs authentifiés qu'ils implémentent l'interface [Authenticatable] : ses méthodes lui donnent l'identifiant de l'utilisateur, son mot de passe haché, et le jeton « se souvenir de moi » des sessions, inutile ici. Dans une application ordinaire, c'est le modèle [Eloquent] de la table des utilisateurs (chapitre suivant) qui l'implémente ;
- lignes 12-18 : 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 11-14 : 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 10) : 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 16-23 : 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.
8.1.3. Le service d'authentification : app/Auth/AuthService.php
- lignes 20-30 : la vérification d'un login et d'un mot de passe. [Hash::check] 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 33-45 : 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 49-58 : 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.4. La garde : bootstrap/app.php
- ligne 20 : le jeton est déjà signé ; le cookie qui le transporte n'est pas chiffré (exemple 22), ce qui permet de l'examiner ;
- ligne 22 : le middleware [auth] renverra les visiteurs anonymes vers la page de connexion ;
- lignes 28-39 : [Auth::viaRequest] définit un pilote de garde par une simple fonction : elle reçoit la requête, et rend l'utilisateur authentifié, ou [null] pour un visiteur anonyme. Elle n'est appelée que si l'application a besoin de l'utilisateur, et, dès qu'elle en a trouvé un, [Laravel] le garde pour toute la requête (pour un visiteur anonyme, elle est appelée à chaque demande) (le middleware [auth], [auth()->user()]...). Elle ne refuse rien : elle établit qui est l'utilisateur. C'est l'authentification. [app(AuthService::class)] demande au conteneur un objet (cf. exemple 06) : la fonction [app] donne accès au conteneur partout ;
- lignes 34-39 : si le navigateur a envoyé un cookie, mais que son jeton est invalide (expiré ou falsifié), on en profite pour l'effacer. [Cookie::forget] fabrique un cookie vide, déjà expiré ; [Cookie::queue] le met en attente : le middleware [AddQueuedCookiesToResponse] du groupe [web] l'ajoutera à la réponse, quelle qu'elle soit.
8.1.5. Les contrôleurs
- lignes 30-34 : 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 39-41 : 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 (exemple 18) ; sa durée de vie (en minutes) est celle du jeton. En production, sur HTTPS, on ajouterait [secure: true] : le cookie ne serait jamais envoyé sur une connexion non chiffrée ;
- lignes 46-49 : la déconnexion efface le cookie ([withoutCookie]). C'est une action POST : 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.
- ligne 14 : l'attribut [#[CurrentUser]] demande au conteneur l'utilisateur authentifié, celui qu'a trouvé la garde (cf. exemple 12 pour les attributs des paramètres). L'action est protégée par le middleware [auth] (voir la table de routage) : elle est certaine de recevoir un utilisateur. On l'obtiendrait aussi par [auth()->user()] ou [$request->user()].
La table de routage :
- ligne 14 : le middleware [auth] protège la page du profil. Pour un visiteur anonyme, il lève une exception [AuthenticationException], que [Laravel] transforme en redirection vers la page de connexion ([redirectGuestsTo]). [auth] est un alias : un nom court, que [Laravel] associe au middleware [Illuminate\Auth\Middleware\Authenticate]. Un groupe de routes peut être protégé d'un coup : [Route::middleware('auth')->group(...)].
8.1.6. Les vues
- ligne 6 : le formulaire de connexion est protégé contre les requêtes forgées, comme tous les formulaires (exemple 18) ;
- ligne 9 : le login saisi est réaffiché après un échec.
- lignes 8-10 : le contenu du jeton, affiché pour l'exemple ;
- lignes 13-16 : la déconnexion, par un formulaire réduit à son bouton.
8.1.7. 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 18), 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é :
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. Une autorisation : bootstrap/app.php
- ligne 44 : la façade [Gate] (une « porte ») définit une autorisation nommée : [admin]. La fonction reçoit l'utilisateur authentifié (et, éventuellement, d'autres paramètres : l'objet auquel on veut accéder, par exemple), et rend [true] s'il a le droit demandé. Une autorisation se vérifie de plusieurs façons : [Gate::allows('admin')] ou [Gate::authorize('admin')] (qui lève une exception, 403) dans le code, [@can('admin')] dans les vues, et le middleware [can:admin] sur les routes. Pour des règles plus nombreuses, [Laravel] propose de les regrouper par classe de modèle, dans des polices (*policies*).
8.2.2. La table de routage et le contrôleur
- ligne 19 : deux middlewares, exécutés dans l'ordre : [auth] vérifie qu'un utilisateur est authentifié (sinon, direction la page de connexion), puis [can:admin] qu'il a l'autorisation [admin] (sinon, une erreur 403, *Forbidden*). Le texte qui suit le [:] est le paramètre du middleware, que [Laravel] lui passe après [$next]. La différence entre les deux refus est importante : pour un utilisateur authentifié, mais qui n'a pas le rôle demandé, se connecter ne servirait à rien, puisqu'il l'est déjà.
Le contrôleur ne fait aucune vérification : c'est l'affaire des middlewares. [Laravel] permet aussi d'écrire les middlewares sur l'action elle-même, par des attributs : [#[Middleware('auth')]], [#[Authorize('admin')]].
8.2.3. Les vues
La page de l'erreur 403 : [Laravel] cherche, pour une erreur HTTP, une vue [errors/<code>.blade.php], et l'affiche à la place de sa page standard (chapitre précédent). Il n'y a rien à déclarer. La page de profil propose un lien vers la page d'administration :
- lignes 7-11 : la directive [@can] n'affiche son bloc que si l'utilisateur a l'autorisation ; [@else] traite le cas contraire. On évite ainsi de proposer un lien qui mènerait à une erreur ; mais c'est un confort : la vraie protection est celle de la route.
8.2.4. 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 (le fichier [jeton.txt] de l'exemple 25), 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 routes y sont protégées par défaut, par un groupe, et quelques routes seulement (la page de connexion, l'inscription) en sont sorties.
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 : app/Auth/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.
- 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.
8.3.2. Le service des captchas : app/Auth/CaptchaService.php
- lignes 15-24 : un nouveau captcha : un texte aléatoire, un identifiant aléatoire et impossible à deviner ([Str::random] utilise lui aussi un générateur cryptographique), et le dessin. Le serveur garde, pour chaque identifiant, la réponse attendue, dans le cache (exemple 20), pour dix minutes : [Cache::put(clé, valeur, 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. La classe [Captcha] réunit l'identifiant et l'image ;
- lignes 27-35 : la vérification. [Cache::pull] lit la valeur et la retire du cache : 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. Le contrôleur : app/Http/Controllers/AuthController.php
- ligne 38 : la limitation est confiée à la façade [RateLimiter] de [Laravel], qui compte des « tentatives » sous une clé, dans le cache. La clé est ici l'adresse IP du client ;
- lignes 41-44 : une adresse bloquée n'est même pas écoutée. [tooManyAttempts(clé, 3)] est vrai si la clé a atteint 3 tentatives ; [availableIn] rend le nombre de secondes avant la fin du blocage ;
- lignes 46-54 : 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, est compté : [RateLimiter::hit(clé, 60)] ajoute une tentative, et fixe à 60 secondes la durée de vie du compteur. Au troisième échec, l'adresse est donc bloquée jusqu'à la fin de ces 60 secondes, comptées depuis le premier échec ;
- ligne 55 : une connexion réussie efface les échecs ;
- lignes 67-71 : chaque affichage du formulaire fabrique un nouveau captcha, y compris après un échec.
[Laravel] fournit aussi un middleware, [throttle], qui limite le nombre de requêtes d'une route ([->middleware('throttle:5,1')] : 5 requêtes par minute) ; nous avons préféré compter les seuls échecs, pour ne pas gêner un utilisateur qui se connecte normalement.
8.3.4. La vue : resources/views/auth/login.blade.php
- ligne 18 : l'image SVG est insérée telle quelle dans la page, sans échappement (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.5. 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 [Blade] ([']).