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.

Le serveur étant sans état (chapitre 6), la preuve d'identité doit voyager avec chaque requête. On utilise un jeton JWT ([JSON Web Token]) : 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.

[ASP.NET Core] sépare nettement les deux questions :

  • l'authentification est l'affaire d'un middleware ([UseAuthentication]) et d'un schéma d'authentification : ici le schéma [JwtBearer], fourni par le paquet [NuGet] [Microsoft.AspNetCore.Authentication.JwtBearer]. Pour chaque requête, il cherche un jeton, le vérifie et en tire l'utilisateur, [HttpContext.User] ;
  • l'autorisation est l'affaire de l'attribut [[Authorize]], vérifié par le middleware [UseAuthorization] : il compare ce qu'exige l'action à ce qu'on sait de l'utilisateur.

Les exemples de ce chapitre utilisent deux paquets [NuGet] : [Microsoft.AspNetCore.Authentication.JwtBearer] et [BCrypt.Net-Next] (le hachage des mots de passe). Ils sont déclarés dans le fichier projet ; la commande [dotnet build] (ou [dotnet run]) les télécharge depuis nuget.org lors de la première compilation : il faut donc une connexion Internet.

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

25-jwt/
├── Auth/
│   ├── AuthService.cs
│   └── UtilisateursService.cs
├── Controllers/
│   ├── AuthController.cs
│   └── ProfilController.cs
├── Views/
│   ├── Auth/
│   │   └── Login.cshtml
│   ├── Profil/
│   │   └── Profil.cshtml
│   ├── Shared/
│   │   └── _Layout.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
├── appsettings.json
├── Exemple25.csproj
└── Program.cs

Le fichier [Exemple25.csproj] :

<Project Sdk="Microsoft.NET.Sdk.Web">

  <ItemGroup>
    <!-- l'authentification par jeton JWT (vérification des jetons) -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
    <!-- le hachage des mots de passe (bcrypt) -->
    <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
  </ItemGroup>

</Project>
  • lignes 5-7 : les paquets [NuGet] utilisés, et leur version. Les éléments communs à tous les exemples (version de [.NET]...) sont dans le fichier [Directory.Build.props] du dossier [exemples].

Le fichier [appsettings.json] :

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Secret": "une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-25",
    "DureeMinutes": 60
  }
}
  • lignes 9-11 : une section de configuration propre à l'application : la clé qui signe les jetons et leur durée de vie. Qui connaît la clé peut fabriquer des jetons valides : en production, elle ne figure pas dans un fichier du projet, elle est fournie par une variable d'environnement ([Jwt__Secret]) ou un coffre à secrets. L'algorithme utilisé (HMAC-SHA256) exige une clé d'au moins 32 octets.

Le fichier [Auth/UtilisateursService.cs] :

namespace Exemple25.Auth;

public record Utilisateur(string Login, string Nom, string Role, string HachageMotDePasse);

// les utilisateurs, en mémoire (le chapitre suivant les mettra dans une base)
public class UtilisateursService
{
    private readonly List<Utilisateur> utilisateurs =
    [
        // on ne conserve JAMAIS un mot de passe, seulement son hachage
        Creer("marie", "Marie Pélissier", "USER", "marie"),
        Creer("admin", "Administrateur", "ADMIN", "admin"),
    ];

    public Utilisateur? Chercher(string login) =>
        utilisateurs.Find(u => u.Login == login);

    // bcrypt, avec un coût de 10 : 2^10 tours de calcul, volontairement lents
    private static Utilisateur Creer(string login, string nom, string role, string motDePasse) =>
        new(login, nom, role, BCrypt.Net.BCrypt.HashPassword(motDePasse, 10));
}
  • ligne 3 : un utilisateur, avec son rôle ;
  • lignes 10-12 : on ne conserve jamais un mot de passe, seulement son hachage : une empreinte calculée par une fonction à sens unique. On peut vérifier qu'un mot de passe correspond à une empreinte, mais pas retrouver le mot de passe à partir de l'empreinte ;
  • lignes 19-20 : [BCrypt.Net.BCrypt.HashPassword(motDePasse, 10)] calcule le hachage avec l'algorithme [bcrypt]. Le 10 est le « coût » : [bcrypt] est volontairement lent (2¹⁰ tours de calcul), pour décourager qui voudrait essayer des millions de mots de passe. Deux hachages du même mot de passe sont différents (un « sel » aléatoire est ajouté) : c'est [BCrypt.Verify] qui sait les vérifier.

Le fichier [Auth/AuthService.cs] :

using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace Exemple25.Auth;

public class AuthService(UtilisateursService utilisateurs, IConfiguration configuration)
{
    // le nom du cookie qui transporte le jeton
    public const string Cookie = "jeton";

    // la clé qui signe les jetons, lue dans appsettings.json (section Jwt)
    public static SymmetricSecurityKey Cle(IConfiguration configuration) =>
        new(Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]!));

    // null si le login est inconnu ou le mot de passe faux
    public Utilisateur? Valider(string? login, string? motDePasse)
    {
        var utilisateur = utilisateurs.Chercher(login ?? "");
        if (utilisateur is null || motDePasse is null)
        {
            return null;
        }
        return BCrypt.Net.BCrypt.Verify(motDePasse, utilisateur.HachageMotDePasse) ? utilisateur : null;
    }

    // le jeton JWT de l'utilisateur, signé avec la clé secrète
    public string CreerJeton(Utilisateur utilisateur)
    {
        var descripteur = new SecurityTokenDescriptor
        {
            // le contenu du jeton : les "claims" (affirmations sur l'utilisateur)
            Claims = new Dictionary<string, object>
            {
                ["sub"] = utilisateur.Login,
                ["nom"] = utilisateur.Nom,
                ["role"] = utilisateur.Role,
            },
            Expires = DateTime.UtcNow.AddMinutes(configuration.GetValue<int>("Jwt:DureeMinutes")),
            SigningCredentials = new SigningCredentials(Cle(configuration), SecurityAlgorithms.HmacSha256),
        };
        return new JsonWebTokenHandler().CreateToken(descripteur);
    }
}

Commentons ce code :

  • ligne 10 : le nom du cookie qui transporte le jeton ;
  • lignes 13-14 : la clé de signature, fabriquée à partir du texte de [appsettings.json]. [IConfiguration] donne accès à la configuration ; ["Jwt:Secret"] désigne la propriété [Secret] de la section [Jwt]. La même clé sert à signer et à vérifier les jetons (algorithme symétrique) ;
  • lignes 17-24 : la vérification du login et du mot de passe ;
  • lignes 33-37 : le contenu du jeton, les claims (affirmations sur l'utilisateur). [sub] ([subject]) est le nom conventionnel de l'identifiant de l'utilisateur. On y met ce dont les pages ont besoin (nom, rôle), pour ne pas relire la base de données à chaque requête ;
  • ligne 39 : la date d'expiration du jeton ;
  • ligne 40 : la clé et l'algorithme de signature ;
  • ligne 42 : la classe [JsonWebTokenHandler] (paquet [Microsoft.IdentityModel.JsonWebTokens], apporté par [JwtBearer]) fabrique le jeton signé.

Le fichier [Program.cs] :

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Exemple25.Auth;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.AddSingleton<UtilisateursService>();
builder.Services.AddSingleton<AuthService>();

// l'authentification : un jeton JWT, vérifié à chaque requête
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false; // garder les noms des claims tels quels ("sub", "role"...)
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = AuthService.Cle(builder.Configuration), // la signature doit être la nôtre
            ValidateLifetime = true,                                   // le jeton ne doit pas être expiré
            ValidateIssuer = false,
            ValidateAudience = false,
            NameClaimType = "nom",  // User.Identity.Name
            RoleClaimType = "role", // [Authorize(Roles = ...)], User.IsInRole(...)
        };
        options.Events = new JwtBearerEvents
        {
            // 1. le jeton est lu dans le cookie (et non dans l'entête Authorization)
            OnMessageReceived = contexte =>
            {
                contexte.Token = contexte.Request.Cookies[AuthService.Cookie];
                return Task.CompletedTask;
            },
            // 2. page protégée sans jeton valide : direction la page de connexion
            OnChallenge = contexte =>
            {
                contexte.HandleResponse(); // on fabrique nous-mêmes la réponse (pas de 401)
                contexte.Response.Redirect("/login");
                return Task.CompletedTask;
            },
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});
app.UseAuthentication(); // qui est-ce ? -> HttpContext.User
app.UseAuthorization();  // a-t-il le droit ? -> [Authorize]

app.MapControllers();
app.Run();

Commentons ce code :

  • lignes 16-17 : on déclare le schéma d'authentification [JwtBearer], et on en fait le schéma par défaut ;
  • ligne 19 : par défaut, [JwtBearer] renomme certains claims avec des noms longs hérités des technologies Microsoft ([role] deviendrait [http://schemas.microsoft.com/ws/2008/06/identity/claims/role]). On garde les noms du jeton ;
  • lignes 20-26 : les vérifications faites sur chaque jeton : la signature (avec notre clé) et la date d'expiration. Nos jetons n'indiquent ni émetteur ([issuer]) ni destinataire ([audience]) : on ne les vérifie pas ;
  • lignes 27-28 : quel claim contient le nom de l'utilisateur ([User.Identity.Name]), et lequel contient son rôle ([[Authorize(Roles = ...)]], [User.IsInRole(...)]) ;
  • lignes 33-36 : les événements de [JwtBearer] permettent d'adapter son comportement. Par défaut, il cherche le jeton dans l'entête [Authorization: Bearer ...] de la requête : c'est ainsi que procède un client [JavaScript] ou une application mobile. Un navigateur n'ajoute pas cet entête de lui-même : on va chercher le jeton dans le cookie ;
  • lignes 39-42 : le challenge a lieu quand une page protégée est demandée sans jeton valide. Par défaut, la réponse est une erreur 401, sans contenu : utile à un client [JavaScript], déroutante pour un utilisateur. On redirige vers la page de connexion. [HandleResponse()] indique à [JwtBearer] que la réponse est prise en charge ;
  • lignes 56-57 : les deux middlewares, dans cet ordre : d'abord établir qui est l'utilisateur, ensuite vérifier ses droits.

Le fichier [Controllers/AuthController.cs] :

using Exemple25.Auth;
using Microsoft.AspNetCore.Mvc;

namespace Exemple25.Controllers;

public class AuthController(AuthService auth, IConfiguration configuration) : Controller
{
    [HttpGet("/")]
    public IActionResult Accueil() => Redirect("/profil");

    [HttpGet("login")]
    public IActionResult Formulaire() => View("Login");

    [HttpPost("login")]
    public IActionResult Connexion(string? login, string? motDePasse)
    {
        var utilisateur = auth.Valider(login, motDePasse);
        if (utilisateur is null)
        {
            // un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
            ViewData["Erreur"] = "Login ou mot de passe incorrect.";
            ViewData["Login"] = login;
            return View("Login");
        }
        // le jeton, dans un cookie que le JavaScript de la page ne peut pas lire
        Response.Cookies.Append(AuthService.Cookie, auth.CreerJeton(utilisateur), new CookieOptions
        {
            HttpOnly = true,
            SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromMinutes(configuration.GetValue<int>("Jwt:DureeMinutes")),
        });
        return Redirect("/profil");
    }

    // la déconnexion modifie l'état de l'utilisateur : c'est un POST
    [HttpPost("logout")]
    public IActionResult Deconnexion()
    {
        Response.Cookies.Delete(AuthService.Cookie);
        return Redirect("/login");
    }
}

Commentons ce code :

  • lignes 14-23 : la connexion. En cas d'échec, le formulaire est réaffiché avec un message volontairement vague : dire « ce login n'existe pas » aiderait un attaquant à trouver des logins valides ;
  • lignes 26-32 : en cas de succès, le jeton est déposé dans un cookie [HttpOnly] : le [JavaScript] de la page ne peut pas le lire, ce qui protège le jeton si un script malveillant parvenait à s'exécuter dans la page. [SameSite = Lax] : le cookie n'accompagne pas les requêtes POST émises depuis un autre site, ce qui protège contre la falsification de requêtes ([CSRF]). La durée de vie du cookie est celle du jeton ;
  • lignes 36-40 : la déconnexion efface le cookie ([Cookies.Delete]). C'est un POST : elle modifie l'état de l'utilisateur (cf. exemple 21).

Le fichier [Controllers/ProfilController.cs] :

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Exemple25.Controllers;

// [Authorize] : réservé aux utilisateurs authentifiés
[Authorize]
public class ProfilController : Controller
{
    [HttpGet("profil")]
    public IActionResult Profil()
    {
        // User : l'utilisateur authentifié par le jeton (HttpContext.User)
        return View(User.Claims.Select(c => (c.Type, c.Value)).ToList());
    }
}
  • ligne 7 : l'attribut [[Authorize]], posé sur le contrôleur, réserve toutes ses actions aux utilisateurs authentifiés. C'est un filtre d'autorisation (cf. chapitre 7). Sans jeton valide, il déclenche le challenge, donc la redirection vers [/login] ;
  • ligne 14 : [User] (le [HttpContext.User] de la requête) est l'utilisateur établi par [JwtBearer] à partir du jeton. [User.Claims] est la liste de ses claims ; on en fait une liste de couples (type, valeur) pour la vue.

Le fichier [Views/Profil/Profil.cshtml] :

@model List<(string Type, string Valeur)>
<h1 class="h3">Bonjour @User.Identity!.Name</h1>
<p>Votre rôle : <b>@(User.IsInRole("ADMIN") ? "administrateur" : "utilisateur")</b>.</p>
<p>Le contenu de votre jeton :</p>
<table class="table table-sm w-auto">
  @foreach (var (type, valeur) in Model)
  {
    <tr><td><code>@type</code></td><td>@valeur</td></tr>
  }
</table>
@* la déconnexion : un formulaire POST réduit à son bouton *@
<form method="post" action="/logout">
  <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
</form>
  • ligne 2 : la propriété [User] est aussi accessible dans une vue. Le nom est le claim [nom] (cf. [NameClaimType]) ;
  • ligne 3 : [IsInRole] teste le claim [role] (cf. [RoleClaimType]) ;
  • ligne 6 : la boucle déconstruit chaque couple en deux variables ;
  • ligne 12 : la déconnexion, par un formulaire POST.

Le fichier [Views/Auth/Login.cshtml] :

<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
  <div class="mb-2">
    <label class="form-label" for="login">Login</label>
    <input class="form-control" id="login" name="login" value="@ViewData["Login"]" required>
  </div>
  <div class="mb-3">
    <label class="form-label" for="motDePasse">Mot de passe</label>
    <input class="form-control" type="password" id="motDePasse" name="motDePasse" required>
  </div>
  <button class="btn btn-primary" type="submit">Se connecter</button>
</form>
@if (ViewData["Erreur"] is string erreur)
{
  <div class="alert alert-danger mt-3">@erreur</div>
}
<p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>

8.1.1. Exécution

Sans cookie, la page protégée redirige vers la page de connexion :

curl -i http://localhost:5000/profil
1
2
3
HTTP/1.1 302 Found
Content-Length: 0
Location: /login

Image

Un mot de passe erroné :

Image

La connexion réussie, vue par [curl] :

curl -i -c cookies.txt -d "login=marie&motDePasse=marie" http://localhost:5000/login
1
2
3
4
HTTP/1.1 302 Found
Content-Length: 0
Location: /profil
Set-Cookie: jeton=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpZSIsIm5vbSI6Ik1hcmllIFDDqWxpc3NpZXIiLCJyb2xlIjoiVVNFUiIsImV4cCI6MTc5MDMzNTUzOCwiaWF0IjoxNzkwMzMxOTM4LCJuYmYiOjE3OTAzMzE5Mzh9._fRdn27Hn-tNCxqEU4AMi_qXika16AMrSjPOkiBIIPk; max-age=3600; path=/; samesite=lax; httponly

Le jeton est formé de trois parties séparées par des points : un entête, le contenu, la signature. Les deux premières sont simplement encodées en [base64url]. L'entête décodé :

{"alg":"HS256","typ":"JWT"}

(l'algorithme de signature et le type du jeton). Le contenu décodé :

1
2
3
4
5
6
7
8
{
  "sub": "marie",
  "nom": "Marie Pélissier",
  "role": "USER",
  "exp": 1790335538,
  "iat": 1790331938,
  "nbf": 1790331938
}

On retrouve les claims fabriqués par [CreerJeton], plus trois champs ajoutés par [JsonWebTokenHandler] : [exp] (la date d'expiration), [iat] ([issued at], la date de création) et [nbf] ([not before], la date à partir de laquelle le jeton est valable), en secondes depuis le 1er janvier 1970. Le jeton n'est pas chiffré : on n'y met rien de secret. Dans le navigateur, à l'URL http://localhost:5000/profil :

Image

Tentons maintenant de modifier le jeton pour nous attribuer le rôle ADMIN : on décode le contenu, on remplace [USER] par [ADMIN], on le réencode... mais on ne peut pas recalculer la signature sans la clé secrète :

curl -i -b "jeton=eyJhbGciOi...(contenu modifié)...(ancienne signature)" http://localhost:5000/profil
1
2
3
HTTP/1.1 302 Found
Content-Length: 0
Location: /login

La signature ne correspond plus au contenu : le jeton est refusé, l'utilisateur n'est pas authentifié, et [[Authorize]] renvoie vers la page de connexion. La déconnexion :

curl -i -b cookies.txt -X POST http://localhost:5000/logout
1
2
3
4
HTTP/1.1 302 Found
Content-Length: 0
Location: /login
Set-Cookie: jeton=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/

Le serveur efface le cookie en le redéposant, vide, avec une date d'expiration passée.

8.2. Exemple 26 : les rôles

L'exemple 26 reprend l'exemple 25 et y ajoute une page réservée au rôle ADMIN. Avec [ASP.NET Core], il n'y a presque rien à écrire : le rôle est un claim du jeton, et [[Authorize]] sait le vérifier. Le fichier [Controllers/AdminController.cs] :

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Exemple26.Controllers;

// tout le contrôleur est réservé au rôle ADMIN : il faut être authentifié
// ET avoir le claim "role" égal à "ADMIN" (cf. RoleClaimType dans Program.cs)
[Authorize(Roles = "ADMIN")]
public class AdminController : Controller
{
    [HttpGet("admin")]
    public IActionResult Admin() => View();
}
  • ligne 8 : tout le contrôleur est réservé au rôle ADMIN. Le filtre vérifie d'abord que l'utilisateur est authentifié (sinon : challenge, donc la page de connexion), puis qu'il a le rôle demandé (sinon : réponse 403, « accès interdit »). [Roles = "ADMIN,DOCTOR"] accepterait l'un ou l'autre de ces rôles.

Le fichier [Program.cs] n'a qu'une ligne de plus :

var app = builder.Build();

// 403 (rôle insuffisant), 404... : une page d'erreur
app.UseStatusCodePagesWithReExecute("/erreur/{0}");
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "commun", "bootstrap"))),
    RequestPath = "/bootstrap",
});
app.UseAuthentication(); // qui est-ce ? -> HttpContext.User
app.UseAuthorization();  // a-t-il le droit ? -> [Authorize]

app.MapControllers();
app.Run();
  • ligne 4 : la réponse 403 n'a pas de contenu : elle est transformée en page par le contrôleur [ErreurController] (cf. exemple 24).

L'utilisatrice [marie] (rôle USER) tente d'ouvrir la page d'administration depuis son profil :

Image

Image

La même tentative par [admin] :

Image

Avec [curl], on voit le code 403 :

curl -i -c cookies.txt -d "login=marie&motDePasse=marie" http://localhost:5000/login
curl -i -b cookies.txt http://localhost:5000/admin
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Authentification</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3" style="max-width: 34rem">
    <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>

  </div>
</body>
</html>

L'étude de cas a trois rôles (ADMIN, DOCTOR, USER) et utilise exactement ces attributs : [[Authorize]] sur tous les contrôleurs des pages, [[Authorize(Roles = "ADMIN")]] sur la gestion des médecins et des clients, [[Authorize(Roles = "USER")]] sur l'espace des patients. La page 403 y est traduite.

8.3. Exemple 27 : captcha et limitation des tentatives

Une page de connexion attire les robots : des programmes qui essaient des milliers de couples login / mot de passe. Deux protections complémentaires :

  • un captcha : une image de caractères déformés, qu'un humain lit sans peine mais qu'un robot simple ne sait pas déchiffrer ;
  • une limitation des tentatives : après quelques échecs, l'adresse IP est bloquée un moment.

L'exemple 27 ajoute ces deux protections à l'exemple 25. Le fichier [Auth/CaptchaService.cs] :

using System.Collections.Concurrent;

namespace Exemple27.Auth;

// un captcha : son identifiant, et son image (SVG)
public record Captcha(string Id, string Svg);

// singleton : les captchas en attente sont gardés en mémoire par le serveur
public class CaptchaService
{
    private static readonly TimeSpan DureeVie = TimeSpan.FromMinutes(10);

    // identifiant -> (réponse attendue, date d'expiration).
    // ConcurrentDictionary : un dictionnaire utilisable par plusieurs requêtes simultanées
    private readonly ConcurrentDictionary<string, (string Texte, DateTime Expire)> enAttente = new();

    public Captcha Generer()
    {
        var texte = DessinCaptcha.TexteAleatoire(5);
        var id = Guid.NewGuid().ToString(); // un identifiant aléatoire, impossible à deviner
        enAttente[id] = (texte, DateTime.UtcNow + DureeVie);
        return new Captcha(id, DessinCaptcha.Svg(texte));
    }

    // le captcha est CONSOMMÉ dans tous les cas : une image ne sert qu'une fois
    public bool Verifier(string? id, string? saisie)
    {
        if (id is null || saisie is null || !enAttente.TryRemove(id, out var attendu))
        {
            return false;
        }
        return attendu.Expire > DateTime.UtcNow
            && string.Equals(attendu.Texte, saisie.Trim(), StringComparison.OrdinalIgnoreCase);
    }
}

Commentons ce code :

  • ligne 15 : les réponses attendues sont gardées par le serveur, sous un identifiant aléatoire. La page ne contient que l'image et l'identifiant : la réponse ne quitte jamais le serveur. Le service est un singleton, utilisé par des requêtes simultanées : [ConcurrentDictionary] est un dictionnaire prévu pour cela ;
  • lignes 17-22 : un texte de cinq caractères tiré au hasard, mémorisé, et dessiné dans une image. Le dessin est confié à la classe [DessinCaptcha] (fichier [Auth/DessinCaptcha.cs], repris de l'étude de cas et commenté ligne à ligne) : chaque caractère est un ensemble de traits, agrandi, penché, déformé au hasard, et l'image est brouillée par trois courbes. Les caractères faciles à confondre (0 et O, 1, I et L) sont exclus ;
  • lignes 26-33 : la vérification consomme le captcha ([TryRemove] le retire du dictionnaire) : une image ne sert qu'une fois, un robot ne peut pas rejouer une réponse trouvée une fois. Les majuscules ne comptent pas.

Le fichier [Auth/LimiteurService.cs] :

namespace Exemple27.Auth;

// singleton : compte les échecs de connexion de chaque adresse IP
public class LimiteurService
{
    private const int MaxEchecs = 3;
    private static readonly TimeSpan DureeBlocage = TimeSpan.FromMinutes(1);

    private class Compteur
    {
        public int Echecs;
        public DateTime BloqueJusqua;
    }

    private readonly Dictionary<string, Compteur> compteurs = [];
    private readonly Lock verrou = new(); // plusieurs requêtes simultanées : un seul accès à la fois

    // le nombre de secondes de blocage restantes (0 : pas bloquée)
    public int SecondesBlocage(string ip)
    {
        lock (verrou)
        {
            if (!compteurs.TryGetValue(ip, out var c)) return 0;
            var reste = c.BloqueJusqua - DateTime.UtcNow;
            return reste > TimeSpan.Zero ? (int)Math.Ceiling(reste.TotalSeconds) : 0;
        }
    }

    public void Echec(string ip)
    {
        lock (verrou)
        {
            if (!compteurs.TryGetValue(ip, out var c))
            {
                c = new Compteur();
                compteurs[ip] = c;
            }
            c.Echecs++;
            if (c.Echecs >= MaxEchecs)
            {
                c.BloqueJusqua = DateTime.UtcNow + DureeBlocage;
                c.Echecs = 0; // après le blocage, on repart de zéro
            }
        }
    }

    public void Reussite(string ip)
    {
        lock (verrou)
        {
            compteurs.Remove(ip);
        }
    }
}
  • lignes 6-7 : trois échecs bloquent l'adresse IP une minute ;
  • ligne 16 : le dictionnaire des compteurs est partagé par toutes les requêtes. L'instruction [lock (verrou) { ... }] garantit qu'une seule requête à la fois exécute le bloc : deux échecs simultanés ne peuvent pas se perdre (cf. [Interlocked], chapitre 6). [Lock] est le type de verrou de [.NET] 9 et suivants ;
  • lignes 19-25 : le temps de blocage restant ;
  • lignes 29-42 : un échec est compté ; au troisième, l'adresse est bloquée ;
  • lignes 47-51 : une réussite remet le compteur à zéro.

Le fichier [Controllers/AuthController.cs] :

using Exemple27.Auth;
using Microsoft.AspNetCore.Mvc;

namespace Exemple27.Controllers;

public class AuthController(AuthService auth, CaptchaService captchas, LimiteurService limiteur,
    IConfiguration configuration) : Controller
{
    [HttpGet("/")]
    public IActionResult Accueil() => Redirect("/profil");

    [HttpGet("login")]
    public IActionResult Formulaire() => Login(null, null);

    [HttpPost("login")]
    public IActionResult Connexion(string? login, string? motDePasse, string? captchaId, string? captcha)
    {
        // l'adresse IP du client
        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "?";

        // 1. une adresse bloquée n'est même pas écoutée
        var secondes = limiteur.SecondesBlocage(ip);
        if (secondes > 0)
        {
            return Login(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 (!captchas.Verifier(captchaId, captcha))
        {
            limiteur.Echec(ip);
            return Login(login, "Le code de l'image est incorrect.");
        }
        var utilisateur = auth.Valider(login, motDePasse);
        if (utilisateur is null)
        {
            limiteur.Echec(ip);
            return Login(login, "Login ou mot de passe incorrect.");
        }
        limiteur.Reussite(ip);
        Response.Cookies.Append(AuthService.Cookie, auth.CreerJeton(utilisateur), new CookieOptions
        {
            HttpOnly = true,
            SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromMinutes(configuration.GetValue<int>("Jwt:DureeMinutes")),
        });
        return Redirect("/profil");
    }

    [HttpPost("logout")]
    public IActionResult Deconnexion()
    {
        Response.Cookies.Delete(AuthService.Cookie);
        return Redirect("/login");
    }

    // chaque affichage du formulaire fabrique un NOUVEAU captcha
    private ViewResult Login(string? login, string? erreur)
    {
        ViewData["Login"] = login;
        ViewData["Erreur"] = erreur;
        return View("Login", captchas.Generer());
    }
}

Commentons ce code :

  • ligne 19 : l'adresse IP du client, donnée par la connexion réseau ;
  • lignes 22-25 : une adresse bloquée n'est même pas écoutée ;
  • lignes 28-31 : le captcha est vérifié d'abord, puis le mot de passe (un calcul [bcrypt] coûteux) seulement si le captcha est bon. Un mauvais captcha compte comme un échec ;
  • lignes 57-61 : chaque affichage du formulaire fabrique un nouveau captcha, qui devient le modèle de la vue.

Le fichier [Views/Auth/Login.cshtml] :

@model Captcha
<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
  <div class="mb-2">
    <label class="form-label" for="login">Login</label>
    <input class="form-control" id="login" name="login" value="@ViewData["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">@Html.Raw(Model.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="@Model.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 (ViewData["Erreur"] is string erreur)
{
  <div class="alert alert-danger mt-3">@erreur</div>
}
<p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
  • ligne 15 : l'image SVG est du texte (du XML) : elle est insérée telle quelle dans la page. [Html.Raw] est ici légitime : c'est du HTML fabriqué par le serveur, pas une saisie de l'utilisateur ;
  • ligne 17 : l'identifiant du captcha voyage dans un champ caché ; la réponse, elle, reste sur le serveur.

La page de connexion, à l'URL http://localhost:5000/login :

Image

Un captcha mal recopié :

Image

Après trois échecs, même le bon mot de passe est refusé pendant une minute :

Image

L'étude de cas applique ces deux protections à sa page de connexion et à la création de compte, avec des seuils réglables dans le fichier [appsettings.json] (section [Protection]).