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'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 ou jeton ? La vraie réponse
| Cookie de session | Jeton JWT | |
|---|---|---|
| Envoi | Automatique par le navigateur | À la main, en-tête Authorization |
| Révocation immédiate | Possible (côté serveur) | Difficile : valide jusqu'à expiration |
| Sensible au CSRF | Oui → antiforgerie nécessaire | Non (envoi non automatique) |
| Sensible au XSS | Non si HttpOnly | Oui s'il est stocké dans localStorage |
| Multi-domaines / mobile | Compliqué | Naturel |
| Idéal pour | Application web servie par le même serveur (Blazor, MVC, Razor Pages) | API consommée par un mobile, un front séparé, un autre service |
« 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.
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";
});
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
};
});
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();
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.
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");
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 ? »
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…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à
| Attaque | Protection intégrée | Ce qui reste à ta charge |
|---|---|---|
| Injection SQL | EF Core paramètre tout : LINQ, FromSql interpolé | Ne jamais concaténer dans FromSqlRaw/ExecuteSqlRaw (un analyseur t'avertit depuis EF 10) |
| XSS | Razor et Blazor échappent tout par défaut | Éviter MarkupString / @Html.Raw sur du contenu utilisateur ; assainir le HTML si tu dois l'afficher |
| CSRF | Antiforgerie automatique sur les formulaires ; .NET 11 protection CSRF activée par défaut | Cookies 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 brute | Verrouillage Identity | Ajouter une limitation de débit sur les endpoints de connexion |
| Redirection ouverte | LocalRedirect | Ne jamais rediriger vers une URL fournie par l'utilisateur sans vérification |
| Envoi de fichiers | — | Limiter 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érables | dotnet list package --vulnerable | L'exécuter dans la CI, régulièrement |
| Écoute réseau | HTTPS par défaut, HSTS | Rediriger en HTTPS, ne pas désactiver la validation de certificat « pour tester » |
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()));
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 :
- Plusieurs instances derrière un répartiteur de charge → chacune a ses clés → déconnexions aléatoires des utilisateurs.
- Un conteneur redéployé perd ses clés → tout le monde est déconnecté à chaque déploiement.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys")) // volume partagé
.SetApplicationName("Boutique") // identique sur toutes les instances
.ProtectKeysWithAzureKeyVault(uri, credential); // en cloudJournaliser 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
| Mode | Où vérifier les droits |
|---|---|
| Static SSR / InteractiveServer | Le code tourne sur le serveur : [Authorize] et AuthorizeView sont de vraies barrières |
| InteractiveWebAssembly / Auto | AuthorizeView 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 |
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 ?
2. Ton endpoint a RequireAuthorization() et renvoie
db.Factures.FindAsync(id). Est-ce sécurisé ?
3. Un JWT contient-il des informations confidentielles en sécurité ?
4. En Blazor WebAssembly, <AuthorizeView Roles="admin"> protège-t-il une action ?
5. Après un déploiement, tous tes utilisateurs sont déconnectés. Cause probable ?
SetApplicationName à l'identique sur toutes les instances.Fiches de révision
HttpOnly. Client tiers ou mobile → jeton.FallbackPolicy exigeant l'authentification + AllowAnonymous explicite.- Authentifier puis autoriser — dans cet ordre, dans le pipeline comme dans la tête.
- Fermé par défaut :
FallbackPolicy, etAllowAnonymousexplicite. - Vérifie toujours la propriété de la ressource, pas seulement l'authentification.
- Un JWT est lisible ; un cookie
HttpOnlyest invisible à JavaScript. - N'écris jamais ta propre cryptographie, ni ton propre hachage de mot de passe.
- Data Protection partagée entre instances, sinon déconnexions inexpliquées.