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. [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

25-jwt/
├── app/
│   ├── Auth/
│   │   ├── AuthService.php
│   │   ├── Utilisateur.php
│   │   └── UtilisateursService.php
│   └── Http/
│       └── Controllers/
│           ├── AuthController.php
│           └── ProfilController.php
├── bootstrap/
│   └── app.php
├── config/
│   ├── auth.php
│   └── jwt.php
├── resources/
│   └── views/
│       ├── auth/
│       │   └── login.blade.php
│       ├── layout.blade.php
│       └── profil/
│           └── profil.blade.php
├── routes/
│   └── web.php
└── ...

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 :

1
2
3
# 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] : 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] :

<?php
// config/jwt.php : la configuration des jetons JWT, lue par config('jwt.secret')...
// env(NOM, défaut) : la variable NOM de l'environnement ou du fichier .env
return [
    // la clé qui signe les jetons (au moins 32 caractères) : jamais dans le code
    'secret' => env('JWT_SECRET'),
    // la durée de vie d'un jeton, en minutes
    'duree' => (int) env('JWT_DUREE_MINUTES', 60),
    // le nom du cookie qui transporte le jeton
    'cookie' => 'jeton',
];
  • 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 :

<?php
// config/auth.php : l'authentification. Ce fichier COMPLÈTE la configuration
// par défaut de Laravel (vendor/laravel/framework/config/auth.php)
return [
    // la "garde" (guard) utilisée par défaut : celle qui lit le jeton JWT
    'defaults' => [
        'guard' => 'jwt',
    ],
    'guards' => [
        // une garde de pilote [jwt], défini dans bootstrap/app.php (Auth::viaRequest)
        'jwt' => [
            'driver' => 'jwt',
        ],
    ],
];

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

JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres" php artisan serve

et sous [Windows] (PowerShell) :

$env:JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres"
php artisan serve

8.1.2. Les utilisateurs : app/Auth/Utilisateur.php et UtilisateursService.php

<?php
// app/Auth/Utilisateur.php : un utilisateur. Authenticatable : l'interface que
// Laravel attend des utilisateurs authentifiés

namespace App\Auth;

use Illuminate\Contracts\Auth\Authenticatable;

readonly class Utilisateur implements Authenticatable
{
    public function __construct(
        public string $login,
        public string $nom,
        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 getAuthIdentifierName(): string
    {
        return 'login';
    }

    public function getAuthIdentifier(): string
    {
        return $this->login;
    }

    public function getAuthPasswordName(): string
    {
        return 'hachageMotDePasse';
    }

    public function getAuthPassword(): string
    {
        return $this->hachageMotDePasse;
    }

    // le jeton "se souvenir de moi" des sessions : inutile ici
    public function getRememberToken(): ?string
    {
        return null;
    }

    public function setRememberToken($valeur): void
    {
    }

    public function getRememberTokenName(): string
    {
        return '';
    }
}
  • 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é.
<?php
// app/Auth/UtilisateursService.php : les utilisateurs, en mémoire (l'exemple 28 et
// l'étude de cas les mettront dans une base)

namespace App\Auth;

class UtilisateursService
{
    // 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);
    }
}
  • 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

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

namespace App\Auth;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Support\Facades\Hash;
use Throwable;

class AuthService
{
    public function __construct(private UtilisateursService $utilisateurs)
    {
    }

    // l'utilisateur, ou null si le login est inconnu ou le mot de passe faux.
    // Hash::check 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 || ! Hash::check($motDePasse, $utilisateur->hachageMotDePasse)) {
            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 + config('jwt.duree') * 60,
        ];
        return JWT::encode($claims, config('jwt.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(config('jwt.secret'), 'HS256'));
        } catch (Throwable) {
            return null;
        }
        return new Utilisateur($claims['sub'], $claims['nom'], $claims['role'], claims: $claims);
    }
}
  • 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

<?php
// bootstrap/app.php : l'application de l'exemple 25

use App\Auth\AuthService;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // la route qui sert Bootstrap (cf. exemple 15)
        then: fn () => require __DIR__.'/../../commun/bootstrap.php')
    ->withMiddleware(function (Middleware $middleware): void {
        // le jeton est déjà signé : le cookie qui le transporte n'est pas chiffré
        $middleware->encryptCookies(except: ['jeton']);
        // un visiteur anonyme qui demande une page protégée : direction /login
        $middleware->redirectGuestsTo('/login');
    })
    ->withExceptions()
    ->booted(function () {
        // le pilote de garde [jwt] (cf. config/auth.php) : une fonction qui reçoit la
        // requête, et rend l'utilisateur authentifié, ou null pour un visiteur anonyme
        Auth::viaRequest('jwt', function (Request $request) {
            $jeton = $request->cookie(config('jwt.cookie'));
            if ($jeton === null) {
                return null;
            }
            $utilisateur = app(AuthService::class)->verifier($jeton);
            if ($utilisateur === null) {
                // un cookie présent mais invalide (jeton expiré, falsifié) : on l'efface
                Cookie::queue(Cookie::forget(config('jwt.cookie')));
            }
            return $utilisateur;
        });
    })
    ->create();
  • 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

<?php
// app/Http/Controllers/AuthController.php : connexion et déconnexion

namespace App\Http\Controllers;

use App\Auth\AuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

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

    public function accueil(): RedirectResponse
    {
        return redirect('/profil');
    }

    public function formulaire(): View
    {
        return view('auth.login');
    }

    public function connexion(Request $request): View|RedirectResponse
    {
        $login = $request->input('login');
        $utilisateur = $this->auth->valider($login, $request->input('motDePasse'));
        if ($utilisateur === null) {
            // un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
            return view('auth.login', ['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 (durée en minutes)
        return redirect('/profil')->withCookie(cookie(config('jwt.cookie'),
            $this->auth->creerJeton($utilisateur), config('jwt.duree'),
            httpOnly: true, sameSite: 'lax'));
    }

    // 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
    public function deconnexion(): RedirectResponse
    {
        return redirect('/login')->withoutCookie(config('jwt.cookie'));
    }
}
  • 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.
<?php
// app/Http/Controllers/ProfilController.php : une page réservée aux utilisateurs authentifiés

namespace App\Http\Controllers;

use App\Auth\Utilisateur;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\View\View;

class ProfilController
{
    // #[CurrentUser] : le conteneur fournit l'utilisateur authentifié de la requête,
    // celui qu'a trouvé la garde (cf. bootstrap/app.php)
    public function afficher(#[CurrentUser] Utilisateur $utilisateur): View
    {
        return view('profil.profil', ['utilisateur' => $utilisateur]);
    }
}
  • 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 :

<?php
// routes/web.php : les routes de l'exemple 25

use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProfilController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AuthController::class, 'accueil']);
Route::get('login', [AuthController::class, 'formulaire']);
Route::post('login', [AuthController::class, 'connexion']);
Route::post('logout', [AuthController::class, 'deconnexion']);

// le middleware [auth] : la route n'est accessible qu'à un utilisateur authentifié
Route::get('profil', [ProfilController::class, 'afficher'])->middleware('auth');
  • 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

@extends('layout')

@section('contenu')
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    @csrf
    <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>
  @isset($erreur)
    <div class="alert alert-danger mt-3">{{ $erreur }}</div>
  @endisset
  <p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
@endsection
  • 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.
@extends('layout')

@section('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">
    @foreach ($utilisateur->claims as $cle => $valeur)
      <tr><td><code>{{ $cle }}</code></td><td>{{ $valeur }}</td></tr>
    @endforeach
  </table>
  {{-- la déconnexion : un formulaire POST réduit à son bouton --}}
  <form method="post" action="/logout">
    @csrf
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
@endsection
  • 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é :

curl -i http://localhost:8000/profil
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/login
Content-Type: text/html; charset=utf-8
...

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 18), 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 "_token=...&login=marie&motDePasse=marie" http://localhost:8000/login
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/profil
Set-Cookie: jeton=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtYXJpZSIsIm5vbSI6Ik1hcmllIFBcdTAwZTlsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTA0Mzk2ODIsImV4cCI6MTc5MDQ0MzI4Mn0.IwcQODDFKxG7s01RZ4ezDAJgL4WS_xfq5Hwe7BiJiyI; expires=Sat, 26 Sep 2026 17:21:22 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élissier","role":"USER","iat":1790439682,"exp":1790443282}

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é>.IwcQODDFKxG7s01RZ4ezDAJgL4WS_xfq5Hwe7BiJiyI" http://localhost:8000/profil
1
2
3
4
HTTP/1.1 302 Found
Location: http://localhost:8000/login
Set-Cookie: jeton=deleted; expires=Fri, 26 Sep 2025 16:21:28 GMT; Max-Age=0; path=/; httponly; samesite=lax
...

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

<?php
// bootstrap/app.php : l'application de l'exemple 26

use App\Auth\AuthService;
use App\Auth\Utilisateur;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Gate;

(require __DIR__.'/../../vendor/autoload.php')->addPsr4('App\\', __DIR__.'/../app/');

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        // la route qui sert Bootstrap (cf. exemple 15)
        then: fn () => require __DIR__.'/../../commun/bootstrap.php')
    ->withMiddleware(function (Middleware $middleware): void {
        // le jeton est déjà signé : le cookie qui le transporte n'est pas chiffré
        $middleware->encryptCookies(except: ['jeton']);
        // un visiteur anonyme qui demande une page protégée : direction /login
        $middleware->redirectGuestsTo('/login');
    })
    ->withExceptions()
    ->booted(function () {
        // le pilote de garde [jwt] (cf. config/auth.php) : une fonction qui reçoit la
        // requête, et rend l'utilisateur authentifié, ou null pour un visiteur anonyme
        Auth::viaRequest('jwt', function (Request $request) {
            $jeton = $request->cookie(config('jwt.cookie'));
            if ($jeton === null) {
                return null;
            }
            $utilisateur = app(AuthService::class)->verifier($jeton);
            if ($utilisateur === null) {
                // un cookie présent mais invalide (jeton expiré, falsifié) : on l'efface
                Cookie::queue(Cookie::forget(config('jwt.cookie')));
            }
            return $utilisateur;
        });
        // une AUTORISATION nommée [admin] : la fonction reçoit l'utilisateur
        // authentifié, et dit s'il a le droit demandé
        Gate::define('admin', fn (Utilisateur $utilisateur) => $utilisateur->role === 'ADMIN');
    })
    ->create();
  • 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

<?php
// routes/web.php : les routes de l'exemple 26

use App\Http\Controllers\AdminController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProfilController;
use Illuminate\Support\Facades\Route;

Route::get('/', [AuthController::class, 'accueil']);
Route::get('login', [AuthController::class, 'formulaire']);
Route::post('login', [AuthController::class, 'connexion']);
Route::post('logout', [AuthController::class, 'deconnexion']);

// le middleware [auth] : la route n'est accessible qu'à un utilisateur authentifié
Route::get('profil', [ProfilController::class, 'afficher'])->middleware('auth');

// deux middlewares : [auth] (un utilisateur authentifié), puis [can:admin] (qui a
// l'autorisation [admin], cf. bootstrap/app.php)
Route::get('admin', [AdminController::class, 'afficher'])->middleware(['auth', 'can:admin']);
  • 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à.
<?php
// app/Http/Controllers/AdminController.php : une page réservée au rôle ADMIN

namespace App\Http\Controllers;

use App\Auth\Utilisateur;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\View\View;

class AdminController
{
    public function afficher(#[CurrentUser] Utilisateur $utilisateur): View
    {
        return view('admin.admin', ['nom' => $utilisateur->nom]);
    }
}

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

@extends('layout')
{{-- la page de l'erreur 403, que Laravel affiche de lui-même : une vue
     errors/<code>.blade.php remplace la page d'erreur standard de ce code --}}

@section('contenu')
  <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>
  <p><a href="/profil">Retour au profil</a></p>
@endsection

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 :

@extends('layout')

@section('contenu')
  <h1 class="h3">Bonjour {{ $utilisateur->nom }}</h1>
  <p>Votre rôle : <b>{{ $utilisateur->role === 'ADMIN' ? 'administrateur' : 'utilisateur' }}</b>.</p>
  {{-- @can : le bloc n'est affiché que si l'utilisateur a l'autorisation [admin] --}}
  @can('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>
  @endcan
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    @foreach ($utilisateur->claims as $cle => $valeur)
      <tr><td><code>{{ $cle }}</code></td><td>{{ $valeur }}</td></tr>
    @endforeach
  </table>
  {{-- la déconnexion : un formulaire POST réduit à son bouton --}}
  <form method="post" action="/logout">
    @csrf
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
@endsection
  • 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 :

Image

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

Image

Avec [curl] : un utilisateur de rôle USER (le fichier [jeton.txt] de l'exemple 25), puis un visiteur anonyme :

curl -i -b jeton.txt http://localhost:8000/admin
curl -i http://localhost:8000/admin
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
        <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: http://localhost:8000/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 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

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

namespace App\Auth;

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.

  • 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

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

namespace App\Auth;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

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

    public function generer(): Captcha
    {
        $texte = DessinCaptcha::texteAleatoire(5);
        // un identifiant aléatoire, impossible à deviner
        $id = Str::random(24);
        // la réponse attendue, dans le cache, pour 10 minutes : le cache l'oubliera
        // de lui-même ensuite
        Cache::put("captcha:$id", $texte, 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
    {
        // pull : lit la valeur ET la retire du cache (null si elle n'existe pas ou a expiré)
        $attendu = $id ? Cache::pull("captcha:$id") : null;
        if ($attendu === null || $saisie === null) {
            return false;
        }
        return strcasecmp($attendu, trim($saisie)) === 0;
    }
}
  • 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

<?php
// app/Http/Controllers/AuthController.php : connexion (avec captcha et limitation des tentatives) et déconnexion

namespace App\Http\Controllers;

use App\Auth\AuthService;
use App\Auth\CaptchaService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View;

class AuthController
{
    // 3 échecs au plus, pendant 60 secondes
    private const MAX_ECHECS = 3;

    private const DUREE_BLOCAGE = 60;

    public function __construct(private AuthService $auth, private CaptchaService $captchas)
    {
    }

    public function accueil(): RedirectResponse
    {
        return redirect('/profil');
    }

    public function formulaire(): View
    {
        return $this->pageLogin();
    }

    public function connexion(Request $request): View|RedirectResponse
    {
        $login = $request->input('login');
        // la clé du compteur d'échecs : l'adresse IP du client
        $cle = 'login:'.$request->ip();

        // 1. une adresse bloquée n'est même pas écoutée
        if (RateLimiter::tooManyAttempts($cle, self::MAX_ECHECS)) {
            $secondes = RateLimiter::availableIn($cle);
            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
        if (! $this->captchas->verifier($request->input('captchaId'), $request->input('captcha'))) {
            RateLimiter::hit($cle, self::DUREE_BLOCAGE);
            return $this->pageLogin($login, "Le code de l'image est incorrect.");
        }
        $utilisateur = $this->auth->valider($login, $request->input('motDePasse'));
        if ($utilisateur === null) {
            RateLimiter::hit($cle, self::DUREE_BLOCAGE);
            return $this->pageLogin($login, 'Login ou mot de passe incorrect.');
        }
        RateLimiter::clear($cle);
        return redirect('/profil')->withCookie(cookie(config('jwt.cookie'),
            $this->auth->creerJeton($utilisateur), config('jwt.duree'),
            httpOnly: true, sameSite: 'lax'));
    }

    public function deconnexion(): RedirectResponse
    {
        return redirect('/login')->withoutCookie(config('jwt.cookie'));
    }

    // chaque affichage du formulaire fabrique un NOUVEAU captcha
    private function pageLogin(?string $login = null, ?string $erreur = null): View
    {
        return view('auth.login', ['login' => $login, 'erreur' => $erreur,
            'captcha' => $this->captchas->generer()]);
    }
}
  • 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

@extends('layout')

@section('contenu')
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    @csrf
    <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 --}}
      <div class="mb-1">{!! $captcha->svg !!}</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>
@endsection
  • 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

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 "_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 [Blade] ([&#039;]).