Accueil › Construire le web › Chapitre 17

Sécurité : identité et accès

Deux questions distinctes qu'on mélange sans arrêt : qui es-tu ? (authentification) et qu'as-tu le droit de faire ? (autorisation). Puis les protections que le framework offre déjà — et celles qu'il ne peut pas mettre à ta place.

Authentification ≠ autorisation

L'image

À l'entrée de l'immeuble, tu montres ta carte d'identité : c'est l'authentification (statut HTTP 401 si tu n'as rien à montrer).

Devant la salle des coffres, on lit ton badge : il t'autorise le 2ᵉ étage, pas le sous-sol. C'est l'autorisation (statut 403 : on sait qui tu es, et c'est non).

// L'ORDRE est structurant (chapitre 13)
app.UseAuthentication();     // remplit HttpContext.User
app.UseAuthorization();      // évalue les règles sur ce User

// Ce que tu manipules ensuite
app.MapGet("/moi", (ClaimsPrincipal moi) => new
{
    Identifiant = moi.FindFirst(ClaimTypes.NameIdentifier)?.Value,
    Nom         = moi.Identity?.Name,
    Authentifie = moi.Identity?.IsAuthenticated ?? false,
    Roles       = moi.FindAll(ClaimTypes.Role).Select(c => c.Value)
}).RequireAuthorization();
Cookie de sessionJeton JWT
EnvoiAutomatique par le navigateurÀ la main, en-tête Authorization
Révocation immédiatePossible (côté serveur)Difficile : valide jusqu'à expiration
Sensible au CSRFOui → antiforgerie nécessaireNon (envoi non automatique)
Sensible au XSSNon si HttpOnlyOui s'il est stocké dans localStorage
Multi-domaines / mobileCompliquéNaturel
Idéal pourApplication web servie par le même serveur (Blazor, MVC, Razor Pages)API consommée par un mobile, un front séparé, un autre service
Le mauvais réflexe le plus courant

« API = JWT, forcément. » Non : si ton front et ton API sont servis par la même application (Blazor Web App, MVC), le cookie est plus simple et plus sûr — révocable, invisible à JavaScript. Les jetons s'imposent pour les clients tiers, les mobiles et les communications entre services. Et surtout : ne stocke jamais un JWT dans localStorage si tu peux l'éviter.

Cookie
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(o =>
    {
        o.Cookie.HttpOnly = true;                             // invisible pour JavaScript
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;    // HTTPS uniquement
        o.Cookie.SameSite = SameSiteMode.Lax;                 // limite le CSRF
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.SlidingExpiration = true;
        o.LoginPath = "/connexion";
    });
JWT (validation de jetons émis par un fournisseur d'identité)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://identite.exemple.fr";   // récupère les clés publiques tout seul
        o.Audience = "api-boutique";
        o.TokenValidationParameters = new()
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromSeconds(30)       // par défaut 5 min : trop permissif
        };
    });
Un JWT n'est pas chiffré

Il est signé, donc infalsifiable — mais son contenu est lisible par quiconque le possède (base64, pas de chiffrement). Copie n'importe quel jeton dans un décodeur en ligne pour t'en convaincre. Ne mets donc jamais d'information sensible dans ses claims, et n'écris jamais ton propre code de validation : utilise AddJwtBearer.

ASP.NET Core Identity et les passkeys

Identity fournit tout le nécessaire pour gérer des comptes : inscription, connexion, hachage des mots de passe, verrouillage après échecs, double authentification, confirmation d'email, réinitialisation.

builder.Services.AddIdentityCore<Utilisateur>(o =>
{
    o.Password.RequiredLength = 12;          // la longueur compte plus que la complexité
    o.Password.RequireNonAlphanumeric = false;
    o.Lockout.MaxFailedAccessAttempts = 5;   // contre la force brute
    o.User.RequireUniqueEmail = true;
    o.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<AppDb>()
.AddDefaultTokenProviders();
Passkeys .NET 10

ASP.NET Core Identity intègre désormais WebAuthn/FIDO2 : connexion par empreinte, visage ou code de l'appareil, sans mot de passe. C'est la seule méthode réellement résistante au hameçonnage — la clé privée ne quitte jamais l'appareil et est liée au domaine, donc un site imitateur ne peut rien en faire. Le modèle Blazor Web App inclut la gestion des passkeys prête à l'emploi. Si tu démarres un projet avec des comptes utilisateurs aujourd'hui, propose-les.

Ne code jamais ton propre hachage de mot de passe

Ni MD5, ni SHA-256 « avec un sel maison ». Identity utilise PBKDF2 avec un grand nombre d'itérations, un sel aléatoire et une comparaison à temps constant. Hors Identity, utilise PasswordHasher<T> ou une bibliothèque reconnue (bcrypt, Argon2). Et n'implémente jamais toi-même un protocole d'authentification : délègue à un fournisseur d'identité (Entra ID, Auth0, Keycloak) dès que tu peux.

Autoriser : rôles, claims, politiques

builder.Services.AddAuthorization(o =>
{
    // Par rôle : simple, grossier
    o.AddPolicy("admin", p => p.RequireRole("Administrateur"));

    // Par claim : plus fin
    o.AddPolicy("gestion-stock", p => p.RequireClaim("permission", "stock:ecrire"));

    // Règle personnalisée
    o.AddPolicy("majeur", p => p.RequireAssertion(ctx =>
    {
        var naissance = ctx.User.FindFirst("date_naissance")?.Value;
        return DateOnly.TryParse(naissance, out var d)
            && d.AddYears(18) <= DateOnly.FromDateTime(DateTime.UtcNow);
    }));

    o.FallbackPolicy = new AuthorizationPolicyBuilder()   // tout est protégé par défaut…
        .RequireAuthenticatedUser().Build();
});

// …et on ouvre explicitement ce qui est public
app.MapGet("/sante", () => "ok").AllowAnonymous();
app.MapDelete("/produits/{id:int}", Supprimer).RequireAuthorization("admin");
Fermé par défaut, ouvert explicitement

Avec une FallbackPolicy, oublier un attribut sur un nouvel endpoint le rend inaccessible — pas public. C'est la différence entre un bug visible en développement et une fuite de données en production.

L'autorisation qu'on oublie toujours : « est-ce bien À TOI ? »

Faille classique (IDOR)
app.MapGet("/factures/{id:int}", async (int id, AppDb db) =>
    await db.Factures.FindAsync(id))
   .RequireAuthorization();

// L'utilisateur est authentifié… et lit la
// facture de n'importe qui en changeant l'id.
// /factures/1, /factures/2, /factures/3…
Corrigé
app.MapGet("/factures/{id:int}",
  async (int id, ClaimsPrincipal moi, AppDb db) =>
{
    var monId = moi.FindFirst(ClaimTypes.NameIdentifier)!.Value;
    var f = await db.Factures
        .FirstOrDefaultAsync(x => x.Id == id
                               && x.ClientId == monId);   // ← filtre de propriété
    return f is null ? Results.NotFound() : Results.Ok(f);
}).RequireAuthorization();
// 404 plutôt que 403 : on ne révèle même pas
// que la facture existe.

Un filtre de requête global EF Core sur l'identifiant du locataire ou du client automatise cette protection pour toute l'application — la meilleure défense contre l'oubli ponctuel.

Les attaques classiques, et ce que .NET fait déjà

AttaqueProtection intégréeCe qui reste à ta charge
Injection SQLEF Core paramètre tout : LINQ, FromSql interpoléNe jamais concaténer dans FromSqlRaw/ExecuteSqlRaw (un analyseur t'avertit depuis EF 10)
XSSRazor et Blazor échappent tout par défautÉviter MarkupString / @Html.Raw sur du contenu utilisateur ; assainir le HTML si tu dois l'afficher
CSRFAntiforgerie automatique sur les formulaires ; .NET 11 protection CSRF activée par défautCookies en SameSite, ne pas désactiver la protection « parce que ça bloque »
Sur-affectation (overposting)Utiliser des DTO : lier directement une entité laisse un client envoyer {"estAdmin": true}
Force bruteVerrouillage IdentityAjouter une limitation de débit sur les endpoints de connexion
Redirection ouverteLocalRedirectNe jamais rediriger vers une URL fournie par l'utilisateur sans vérification
Envoi de fichiersLimiter la taille, vérifier le type réel, renommer, stocker hors du dossier web, ne jamais faire confiance au nom d'origine
Dépendances vulnérablesdotnet list package --vulnerableL'exécuter dans la CI, régulièrement
Écoute réseauHTTPS par défaut, HSTSRediriger en HTTPS, ne pas désactiver la validation de certificat « pour tester »
Le socle minimal en production
app.UseHttpsRedirection();
app.UseHsts();                        // production uniquement

app.Use(async (ctx, suivant) =>       // en-têtes de sécurité
{
    var h = ctx.Response.Headers;
    h["X-Content-Type-Options"] = "nosniff";
    h["Referrer-Policy"] = "strict-origin-when-cross-origin";
    h["X-Frame-Options"] = "DENY";     // ou une CSP frame-ancestors
    h["Content-Security-Policy"] =
        "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'";
    await suivant();
});

// Limiter les tentatives de connexion
builder.Services.AddRateLimiter(o => o.AddFixedWindowLimiter("connexion", l =>
{
    l.Window = TimeSpan.FromMinutes(5);
    l.PermitLimit = 10;
}));
app.MapPost("/connexion", Connexion).RequireRateLimiting("connexion");

// CORS : jamais AllowAnyOrigin avec des identifiants
builder.Services.AddCors(o => o.AddPolicy("front", p => p
    .WithOrigins("https://boutique.fr")
    .AllowCredentials()
    .AllowAnyHeader()
    .AllowAnyMethod()));
Data Protection : la clé de voûte silencieuse

ASP.NET Core chiffre les cookies d'authentification, les jetons antiforgerie et les jetons de réinitialisation avec le système Data Protection. Par défaut, les clés sont stockées localement. Conséquences en production :

builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo("/var/keys"))   // volume partagé
    .SetApplicationName("Boutique")                            // identique sur toutes les instances
    .ProtectKeysWithAzureKeyVault(uri, credential);            // en cloud

Journaliser les événements de sécurité (connexion réussie et échouée, changement de mot de passe, élévation de privilèges, accès refusé) avec l'identifiant utilisateur et l'adresse IP — mais jamais le mot de passe, le jeton ou le cookie, même tronqués.

Cas particulier : Blazor

ModeOù vérifier les droits
Static SSR / InteractiveServerLe code tourne sur le serveur : [Authorize] et AuthorizeView sont de vraies barrières
InteractiveWebAssembly / AutoAuthorizeView n'est qu'un confort d'affichage : il masque un bouton, il ne protège rien. Toute décision doit être revérifiée par l'API côté serveur
Le piège WebAssembly

Un utilisateur peut ouvrir les outils de développement, modifier l'état du composant, et faire apparaître le bouton « Supprimer » masqué par AuthorizeView. Si ton endpoint DELETE /produits/{id} n'a pas de RequireAuthorization("admin"), il vient de supprimer le produit. La sécurité vit sur le serveur, jamais dans l'interface.

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Application Blazor Web App servie par le même serveur que son API. Cookie ou JWT ?

Les jetons servent aux clients tiers (mobile, front séparé, service à service). Même origine = cookie.

2. Ton endpoint a RequireAuthorization() et renvoie db.Factures.FindAsync(id). Est-ce sécurisé ?

C'est la faille IDOR. Filtre toujours sur le propriétaire de la ressource — un GUID ne fait que la rendre moins facile à deviner, pas impossible à obtenir.

3. Un JWT contient-il des informations confidentielles en sécurité ?

La signature garantit l'intégrité, pas la confidentialité. Un jeton perdu révèle tous ses claims.

4. En Blazor WebAssembly, <AuthorizeView Roles="admin"> protège-t-il une action ?

Tout ce qui s'exécute dans le navigateur est modifiable par l'utilisateur. Le serveur est la seule autorité.

5. Après un déploiement, tous tes utilisateurs sont déconnectés. Cause probable ?

Persiste les clés sur un volume partagé ou dans un coffre-fort, et fixe SetApplicationName à l'identique sur toutes les instances.

Fiches de révision

401 ou 403 ?
401 : non authentifié. 403 : authentifié mais pas autorisé.
Cookie ou jeton ?
Même origine → cookie HttpOnly. Client tiers ou mobile → jeton.
La faille qu'on oublie ?
IDOR : authentifié mais pas propriétaire. Filtre toujours sur le propriétaire.
Hacher un mot de passe ?
Jamais soi-même : Identity (PBKDF2), bcrypt ou Argon2.
Sécurité en WebAssembly ?
Aucune : tout est public et modifiable. Le serveur décide.
Fermé par défaut ?
FallbackPolicy exigeant l'authentification + AllowAnonymous explicite.
À retenir