Implémentez l'authentification JWT complète avec C#, ASP.NET Core, SQL Server et Angular : access tokens, refresh tokens, RBAC, sécurité production.
1. Architecture JWT et flux d'authentification
Un JWT (JSON Web Token) se compose de trois parties encodées en Base64 séparées par des points : Header (algorithme), Payload (claims) et Signature. Sa nature stateless évite au serveur de stocker des sessions, idéal pour les architectures distribuées et les SPAs Angular.
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload (claims)
{ "sub": "42", "email": "user@test.com", "role": "Admin", "exp": 1746460800 }
// Signature = HMAC-SHA256(header + "." + payload, secretKey)
Flux complet client/serveur
Angular ASP.NET Core SQL Server
| | |
|-- POST /api/auth/login --------> | |
| { email, password } |-- SELECT user ---------> |
| | <-- user + hash ---------|
| | BCrypt.Verify(pwd) |
| <-- { accessToken(15min), |
| refreshToken(7j) } --------|
| |
|-- GET /api/produits -----------> |
| Authorization: Bearer JWT | JwtBearer valide sig |
| <-- 200 OK { [...] } -----------|
Pourquoi JWT pour C#/Angular ?
- Stateless : aucune session côté serveur, scalabilité horizontale facilitée
- Claims intégrés : userId, email, roles transportés dans le token
- CORS-friendly : le header Authorization traverse les origines sans cookie
- Interopérable : un même token fonctionne pour Angular, mobile, CLI
2. Configuration ASP.NET Core
Packages NuGet requis
# Authentification et tokens JWT
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
# Entity Framework Core + SQL Server
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
# Hash sécurisé des mots de passe
dotnet add package BCrypt.Net-Core
appsettings.json
{
"JwtSettings": {
"SecretKey": "votre-cle-secrete-tres-longue-minimum-32-car!",
"Issuer": "https://angularforall.com",
"Audience": "angular-client",
"ExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=AngularAuthDb;Trusted_Connection=True;"
}
}
DOTNET_JwtSettings__SecretKey) ou Azure Key Vault.
Program.cs — Enregistrement du middleware JWT
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = Encoding.ASCII.GetBytes(jwtSettings["SecretKey"]!);
// ── JWT Bearer ──────────────────────────────────────────────────
builder.Services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(secretKey),
ValidateIssuer = true,
ValidIssuer = jwtSettings["Issuer"],
ValidateAudience = true,
ValidAudience = jwtSettings["Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero // Expiration stricte (pas de marge)
};
});
// ── CORS pour Angular ───────────────────────────────────────────
builder.Services.AddCors(options => {
options.AddPolicy("AngularApp", policy =>
policy.WithOrigins("http://localhost:4200")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
builder.Services.AddControllers();
builder.Services.AddScoped<IAuthService, AuthService>();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseCors("AngularApp");
app.UseAuthentication(); // ← AVANT UseAuthorization() !
app.UseAuthorization();
app.MapControllers();
app.Run();
3. Design base de données SQL Server
La structure de base de données repose sur quatre tables : Users, Roles,
UserRoles (jonction) et RefreshTokens pour la gestion des sessions longue durée.
Modèles Entity Framework Core
// Models/User.cs
public class User
{
public int Id { get; set; }
public string Email { get; set; } = null!;
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string PasswordHash { get; set; } = null!; // BCrypt — jamais en clair
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
// Models/Role.cs
public class Role
{
public int Id { get; set; }
public string Name { get; set; } = null!; // "Admin", "Editor", "User"
public string Description { get; set; } = "";
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
}
// Models/UserRole.cs — Table de jonction User ↔ Role
public class UserRole
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public int RoleId { get; set; }
public Role Role { get; set; } = null!;
}
// Models/RefreshToken.cs
public class RefreshToken
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public string Token { get; set; } = null!; // UUID sans tirets
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; } // null = token actif
public string? IpAddress { get; set; }
}
AuthDbContext avec seed des rôles
// Data/AuthDbContext.cs
public class AuthDbContext : DbContext
{
public AuthDbContext(DbContextOptions<AuthDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; } = null!;
public DbSet<Role> Roles { get; set; } = null!;
public DbSet<UserRole> UserRoles { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Seed des rôles par défaut au démarrage
modelBuilder.Entity<Role>().HasData(
new Role { Id = 1, Name = "Admin", Description = "Accès complet" },
new Role { Id = 2, Name = "Editor", Description = "Gestion du contenu" },
new Role { Id = 3, Name = "User", Description = "Accès standard" }
);
// Index uniques — performances des requêtes fréquentes
modelBuilder.Entity<User>().HasIndex(u => u.Email).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(rt => rt.Token).IsUnique();
}
}
// Enregistrement dans Program.cs
builder.Services.AddDbContext<AuthDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
Migration et création des tables
# Générer la migration
dotnet ef migrations add InitialAuthSchema
# Appliquer à SQL Server (crée les tables)
dotnet ef database update
4. Service et Controller d'authentification
DTOs — Contrats de l'API
// DTOs (records C# 10+)
public record LoginRequest(
[Required, EmailAddress] string Email,
[Required, MinLength(8)] string Password
);
public record RegisterRequest(
[Required] string FirstName,
[Required] string LastName,
[Required, EmailAddress] string Email,
[Required, MinLength(8)] string Password
);
public record AuthResponse(
int UserId,
string Email,
string FirstName,
string AccessToken,
string RefreshToken,
int ExpiresIn, // Secondes avant expiration
List<string> Roles
);
AuthService — Génération et validation des tokens
public class AuthService : IAuthService
{
private readonly AuthDbContext _context;
private readonly IConfiguration _config;
public AuthService(AuthDbContext context, IConfiguration config)
{ _context = context; _config = config; }
// ── Login ────────────────────────────────────────────────────
public async Task<AuthResponse> LoginAsync(LoginRequest req)
{
var user = await _context.Users
.Include(u => u.UserRoles).ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(u => u.Email == req.Email && u.IsActive)
?? throw new UnauthorizedAccessException("Identifiants incorrects");
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new UnauthorizedAccessException("Identifiants incorrects");
user.LastLoginAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return BuildResponse(user,
GenerateAccessToken(user),
await GenerateRefreshTokenAsync(user));
}
// ── Register ─────────────────────────────────────────────────
public async Task<AuthResponse> RegisterAsync(RegisterRequest req)
{
if (await _context.Users.AnyAsync(u => u.Email == req.Email))
throw new InvalidOperationException("Email déjà utilisé");
var user = new User {
Email = req.Email,
FirstName = req.FirstName,
LastName = req.LastName,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password, workFactor: 12)
};
var defaultRole = await _context.Roles.FindAsync(3); // Rôle "User"
user.UserRoles.Add(new UserRole { User = user, Role = defaultRole! });
_context.Users.Add(user);
await _context.SaveChangesAsync();
return BuildResponse(user,
GenerateAccessToken(user),
await GenerateRefreshTokenAsync(user));
}
// ── Refresh ──────────────────────────────────────────────────
public async Task<AuthResponse> RefreshTokenAsync(string token)
{
var stored = await _context.RefreshTokens
.Include(rt => rt.User).ThenInclude(u => u.UserRoles).ThenInclude(ur => ur.Role)
.FirstOrDefaultAsync(rt => rt.Token == token)
?? throw new UnauthorizedAccessException("Token introuvable");
// Détection de réutilisation : token déjà révoqué → compromission
if (stored.RevokedAt != null)
{
await RevokeAllUserTokensAsync(stored.UserId);
throw new UnauthorizedAccessException("Compromission détectée. Reconnectez-vous.");
}
if (stored.ExpiresAt < DateTime.UtcNow)
throw new UnauthorizedAccessException("Refresh token expiré");
// Rotation : invalider l'ancien, créer un nouveau
stored.RevokedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
var user = stored.User;
return BuildResponse(user,
GenerateAccessToken(user),
await GenerateRefreshTokenAsync(user));
}
// ── Révocation ───────────────────────────────────────────────
public async Task RevokeTokenAsync(int userId, string token)
{
var stored = await _context.RefreshTokens
.FirstOrDefaultAsync(rt => rt.UserId == userId && rt.Token == token);
if (stored != null) { stored.RevokedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); }
}
private async Task RevokeAllUserTokensAsync(int userId)
{
var tokens = await _context.RefreshTokens
.Where(rt => rt.UserId == userId && rt.RevokedAt == null).ToListAsync();
tokens.ForEach(t => t.RevokedAt = DateTime.UtcNow);
await _context.SaveChangesAsync();
}
// ── Helpers privés ───────────────────────────────────────────
private string GenerateAccessToken(User user)
{
var jwt = _config.GetSection("JwtSettings");
var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwt["SecretKey"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim> {
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.GivenName, user.FirstName),
};
foreach (var ur in user.UserRoles)
claims.Add(new Claim(ClaimTypes.Role, ur.Role.Name));
var token = new JwtSecurityToken(
issuer: jwt["Issuer"], audience: jwt["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(int.Parse(jwt["ExpirationMinutes"]!)),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<string> GenerateRefreshTokenAsync(User user)
{
var rt = new RefreshToken {
UserId = user.Id,
Token = Guid.NewGuid().ToString("N"),
ExpiresAt = DateTime.UtcNow.AddDays(int.Parse(_config["JwtSettings:RefreshTokenExpirationDays"]!))
};
_context.RefreshTokens.Add(rt);
await _context.SaveChangesAsync();
return rt.Token;
}
private AuthResponse BuildResponse(User user, string access, string refresh) =>
new(user.Id, user.Email, user.FirstName, access, refresh,
int.Parse(_config["JwtSettings:ExpirationMinutes"]!) * 60,
user.UserRoles.Select(ur => ur.Role.Name).ToList());
}
AuthController
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _auth;
public AuthController(IAuthService auth) => _auth = auth;
[HttpPost("login")] [AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
try { return Ok(await _auth.LoginAsync(req)); }
catch (UnauthorizedAccessException ex) { return Unauthorized(new { message = ex.Message }); }
}
[HttpPost("register")] [AllowAnonymous]
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
{
try { return Created("", await _auth.RegisterAsync(req)); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
[HttpPost("refresh")] [AllowAnonymous]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
{
try { return Ok(await _auth.RefreshTokenAsync(req.RefreshToken)); }
catch (UnauthorizedAccessException ex) { return Unauthorized(new { message = ex.Message }); }
}
[HttpPost("logout")] [Authorize]
public async Task<IActionResult> Logout([FromBody] LogoutRequest req)
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await _auth.RevokeTokenAsync(userId, req.RefreshToken);
return Ok(new { message = "Déconnecté avec succès" });
}
[HttpGet("me")] [Authorize]
public IActionResult Me() => Ok(new {
email = User.FindFirstValue(ClaimTypes.Email),
firstName = User.FindFirstValue(ClaimTypes.GivenName),
roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value)
});
}
5. Intégration Angular — Service et Interceptor
Interface TypeScript
// models/auth.models.ts
export interface AuthResponse {
userId: number;
email: string;
firstName: string;
accessToken: string;
refreshToken: string;
expiresIn: number;
roles: string[];
}
AuthService Angular
// services/auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly apiUrl = 'http://localhost:5000/api/auth';
private currentUser$ = new BehaviorSubject<AuthResponse | null>(null);
constructor(private http: HttpClient) {
const stored = sessionStorage.getItem('currentUser');
if (stored) this.currentUser$.next(JSON.parse(stored));
}
login(email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/login`, { email, password })
.pipe(tap(res => this.saveSession(res)));
}
register(data: { firstName: string; lastName: string; email: string; password: string }): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/register`, data)
.pipe(tap(res => this.saveSession(res)));
}
refreshToken(): Observable<AuthResponse> {
const token = localStorage.getItem('refreshToken');
return this.http.post<AuthResponse>(`${this.apiUrl}/refresh`, { refreshToken: token })
.pipe(tap(res => this.saveSession(res)));
}
logout(): Observable<any> {
const token = localStorage.getItem('refreshToken');
return this.http.post(`${this.apiUrl}/logout`, { refreshToken: token })
.pipe(tap(() => this.clearSession()));
}
getAccessToken(): string | null { return sessionStorage.getItem('accessToken'); }
isAuthenticated(): boolean { return !!this.getAccessToken(); }
hasRole(role: string): boolean { return this.currentUser$.value?.roles.includes(role) ?? false; }
user$(): Observable<AuthResponse | null> { return this.currentUser$.asObservable(); }
private saveSession(res: AuthResponse): void {
sessionStorage.setItem('accessToken', res.accessToken);
sessionStorage.setItem('currentUser', JSON.stringify(res));
localStorage.setItem('refreshToken', res.refreshToken);
this.currentUser$.next(res);
}
private clearSession(): void {
sessionStorage.clear();
localStorage.removeItem('refreshToken');
this.currentUser$.next(null);
}
}
HttpInterceptor — Bearer token automatique + refresh
// interceptors/jwt.interceptor.ts
@Injectable()
export class JwtInterceptor implements HttpInterceptor {
private isRefreshing = false;
private refreshSubject = new BehaviorSubject<string | null>(null);
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.auth.getAccessToken();
if (token) req = this.addBearer(req, token);
return next.handle(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401 && !this.isRefreshing) return this.handle401(req, next);
return throwError(() => err);
})
);
}
private handle401(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
this.isRefreshing = true;
return this.auth.refreshToken().pipe(
switchMap(res => {
this.isRefreshing = false;
this.refreshSubject.next(res.accessToken);
return next.handle(this.addBearer(req, res.accessToken));
}),
catchError(err => {
this.isRefreshing = false;
this.auth.logout().subscribe();
return throwError(() => err);
})
);
}
private addBearer(req: HttpRequest<any>, token: string) {
return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
}
// Dans app.config.ts (Angular 17+ standalone) ou app.module.ts
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true }
6. Refresh tokens et rotation sécurisée
La rotation des refresh tokens est fondamentale : chaque utilisation génère un nouveau refresh token et révoque l'ancien. Toute tentative de réutilisation d'un token déjà révoqué déclenche la révocation de tous les tokens de l'utilisateur — signe d'une compromission.
| Stockage | Protection XSS | Protection CSRF | Usage recommandé |
|---|---|---|---|
| httpOnly Cookie | ✅ Protégé | ⚠️ Attention | Refresh token (prod) |
| sessionStorage | ⚠️ Exposé | ✅ OK | Access token |
| localStorage | ❌ Vulnérable | ✅ OK | À éviter |
| Memory JS | ✅ Protégé | ✅ OK | Access token (max sécu) |
Nettoyage automatique des tokens expirés
// Job à planifier (Hangfire, Quartz, ou hosted service)
public async Task CleanExpiredTokensAsync()
{
var expired = await _context.RefreshTokens
.Where(rt => rt.ExpiresAt < DateTime.UtcNow || rt.RevokedAt != null)
.ToListAsync();
_context.RefreshTokens.RemoveRange(expired);
await _context.SaveChangesAsync();
}
7. Rôles et permissions (RBAC)
Autorisation backend par rôle
// Réservé aux Admins uniquement
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public IActionResult Delete(int id) { ... }
// Accessible aux Admins ET Editors
[HttpPut("{id}")]
[Authorize(Roles = "Admin,Editor")]
public IActionResult Update(int id) { ... }
// Politique personnalisée (Program.cs)
builder.Services.AddAuthorization(options => {
options.AddPolicy("CanPublish", p => p.RequireRole("Admin", "Editor"));
});
[Authorize(Policy = "CanPublish")]
public IActionResult Publish(int id) { ... }
Auth Guard Angular
// guards/auth.guard.ts
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
if (!this.auth.isAuthenticated()) {
this.router.navigate(['/login']); return false;
}
const roles = route.data['roles'] as string[] | undefined;
if (roles?.length && !roles.some(r => this.auth.hasRole(r))) {
this.router.navigate(['/403']); return false;
}
return true;
}
}
// Routes protégées
const routes: Routes = [
{ path: 'admin', component: AdminComponent, canActivate: [AuthGuard], data: { roles: ['Admin'] } },
{ path: 'editor', component: EditorComponent, canActivate: [AuthGuard], data: { roles: ['Admin','Editor'] } },
{ path: 'dashboard', component: DashComponent, canActivate: [AuthGuard] },
];
8. Sécurité en production
Rate limiting sur les endpoints d'auth
dotnet add package AspNetCoreRateLimit
// appsettings.json
"IpRateLimiting": {
"EnableEndpointRateLimiting": true,
"GeneralRules": [
{ "Endpoint": "POST:/api/auth/login", "Period": "1m", "Limit": 5 },
{ "Endpoint": "POST:/api/auth/register", "Period": "1h", "Limit": 10 },
{ "Endpoint": "POST:/api/auth/refresh", "Period": "1m", "Limit": 20 }
]
}
- SecretKey > 32 caractères, en variable d'environnement uniquement
- HTTPS obligatoire —
UseHttpsRedirection()activé - CORS restreint aux origines autorisées (pas de wildcard
*) - BCrypt work factor ≥ 12 pour les mots de passe
- Rate limiting sur
/login,/register,/refresh - Rotation des refresh tokens + détection de réutilisation
- Job de nettoyage quotidien des tokens expirés
- Logs des connexions échouées et révocations de tokens
- Headers de sécurité : X-Content-Type-Options, X-Frame-Options
- Tester les failles OWASP Top 10 avant déploiement
9. Tests et debugging
Tests unitaires C# avec xUnit
public class AuthServiceTests
{
[Fact]
public async Task Login_AvecBonsIdentifiants_RetourneTokens()
{
// Arrange : utilisateur avec hash BCrypt
var hash = BCrypt.Net.BCrypt.HashPassword("Password123!");
// ... setup mock DbContext avec cet utilisateur ...
// Act
var result = await _authService.LoginAsync(
new LoginRequest("test@test.com", "Password123!"));
// Assert
Assert.NotNull(result.AccessToken);
Assert.NotEmpty(result.RefreshToken);
Assert.Contains("User", result.Roles);
}
[Fact]
public async Task Login_AvecMauvaisMotDePasse_LanceException()
{
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
_authService.LoginAsync(new LoginRequest("test@test.com", "Mauvais!")));
}
}
Tests Angular avec HttpClientTestingModule
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService]
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('stocke les tokens après login', () => {
const mockRes = {
accessToken: 'jwt-abc', refreshToken: 'ref-xyz', userId: 1,
email: 'a@b.com', firstName: 'Test', expiresIn: 900, roles: ['User']
};
service.login('a@b.com', 'Password123!').subscribe(() => {
expect(sessionStorage.getItem('accessToken')).toBe('jwt-abc');
expect(localStorage.getItem('refreshToken')).toBe('ref-xyz');
});
httpMock.expectOne(`${service['apiUrl']}/login`).flush(mockRes);
});
});
Debugging avec curl
# 1. Login — récupérer les tokens
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@test.com","password":"Password123!"}'
# 2. Appel protégé avec le token
curl http://localhost:5000/api/auth/me \
-H "Authorization: Bearer [votre-access-token]"
# 3. Refresh du token
curl -X POST http://localhost:5000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"[votre-refresh-token]"}'
Conclusion et checklist de production
Vous disposez maintenant d'une implémentation JWT complète et sécurisée pour votre stack C#/ASP.NET Core + SQL Server + Angular. L'architecture couvre tous les maillons : génération de tokens avec HMAC-SHA256, stockage en SQL Server via EF Core, rotation des refresh tokens avec détection de compromission, contrôle d'accès par rôle et intégration transparente dans Angular via l'interceptor HTTP.
- Backend :
[Authorize]sur toutes les routes sensibles - Backend : BCrypt work factor ≥ 12, mots de passe jamais en clair
- Backend : SecretKey en variable d'environnement
- Backend : Rotation + détection de réutilisation des refresh tokens
- Backend : Rate limiting sur /login, /register, /refresh
- Frontend : HttpInterceptor ajoute le Bearer automatiquement
- Frontend : AuthGuard protège toutes les routes privées
- Frontend : Refresh automatique en cas de 401
- BDD : Index sur
Users.EmailetRefreshTokens.Token - BDD : Job de nettoyage des tokens expirés (quotidien)
- Infra : HTTPS obligatoire, CORS restreint, logs d'auth activés
AddAuthentication().AddGoogle() pour un SSO complet en complément du JWT.