Implémentez l'authentification JWT complète avec C#, ASP.NET Core, SQL Server et Angular : access tokens, refresh tokens, RBAC et 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.