Back-end angularforall.com

- JWT : authentifier une API C#/Angular

Jwt Authentication Csharp Aspnet-Core Angular Sql-Server Entity-Framework Security Tokens Httpinterceptor Rbac
JWT : authentifier une API C#/Angular

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.

Structure d'un JWT décodé
// 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;"
  }
}
⚠️ Production : Ne jamais mettre la SecretKey dans appsettings.json. Utilisez les variables d'environnement (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.

Comparaison des stratégies de stockage
StockageProtection XSSProtection CSRFUsage recommandé
httpOnly Cookie✅ Protégé⚠️ AttentionRefresh token (prod)
sessionStorage⚠️ Exposé✅ OKAccess token
localStorage❌ Vulnérable✅ OKÀ éviter
Memory JS✅ Protégé✅ OKAccess 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.Email et RefreshTokens.Token
  • BDD : Job de nettoyage des tokens expirés (quotidien)
  • Infra : HTTPS obligatoire, CORS restreint, logs d'auth activés

Partager