Files
MadJax dd36bbc425 Ajout de l'authentification (compte unique, JWT, reset par email)
Application mono-utilisateur : inscription unique, connexion par JWT (12 h),
protection de toutes les routes de données, réinitialisation de mot de passe
par email avec repli de journalisation, onboarding réorganisé en deux étapes
(inscription → connexion → profil + véhicule).

Backend (SchotzIadKmTracker.Api) :
- Entités Compte (singleton) et ResetPasswordToken + migration AjoutAuthentification.
- Hachage PasswordHasher, JwtService (HS256, durée configurable), IEmailService
  (MailKit + repli LoggingEmailService selon Smtp:Host).
- AuthService (register 409 si compte existant, login 401 générique, forgot-password
  non-divulgation, reset à usage unique + expiration), AuthController (5 endpoints
  publics), DTOs + validateurs FluentValidation.
- Sécurité pipeline : JwtBearer + filtre d'autorisation global, UseAuthentication
  avant UseAuthorization ; /health et /api/auth/* publics.

Frontend (schotziad-km-tracker, zoneless + signals) :
- AuthService (localStorage), authInterceptor (Bearer + purge/redirection 401),
  authGuard, écrans Login/Register/ForgotPassword/ResetPassword, routes publiques
  et protection [authGuard, profilConfigureGuard].

Configuration : .env.example et docker-compose.yml (JWT__SecretKey, durées, Smtp__*).

Tests : 54 tests backend (propriétés FsCheck P1–P14, exemples, intégration
WebApplicationFactory) + tests frontend (AuthService, authGuard, authInterceptor,
ResetPasswordComponent). Suite complète au vert.

Spec : .kiro/specs/authentification (requirements, design, tasks).
2026-09-14 09:07:08 +02:00

36 KiB
Raw Permalink Blame History

Document de conception — Authentification

Overview

Cette conception ajoute une couche d'authentification à l'application mono-utilisateur SchotzIAD KM Tracker (frontend Angular schotziad-km-tracker + backend SchotzIadKmTracker.Api). Elle introduit :

  • un Compte unique (Email + mot de passe haché à sel) ;
  • une session par Jeton_JWT émis à la connexion (durée 12 h par défaut, configurable) ;
  • la protection de toutes les routes de données existantes (trajets, profil, véhicules, carnet d'adresses, barème, résumés, montants réglés, exports PDF, recalcul) ;
  • un mécanisme de réinitialisation par email avec repli de journalisation si aucun SMTP n'est configuré ;
  • la réorganisation de l'onboarding en deux étapes : d'abord inscription puis connexion, ensuite saisie du profil et du premier véhicule (comportement actuel inchangé).

La conception s'insère dans l'architecture existante sans la bouleverser : controllers MVC classiques, EF Core + SQLite avec migrations appliquées au démarrage via dbContext.Database.MigrateAsync(), DTOs en record dans Models/DTOs, validation FluentValidation auto‑validée, services enregistrés en DI dans Program.cs, ProblemDetails (RFC 7807) pour les erreurs. Côté frontend : Angular standalone zoneless (état async via signals), provideHttpClient(withInterceptors(...)), gardes CanActivateFn, environment.apiUrl.

Décisions structurantes (résumé)

Sujet Décision Justification
Hachage ASP.NET Core Identity PasswordHasher<T> Standard .NET, à sel, itératif (PBKDF2), aucune dépendance externe supplémentaire, VerifyHashedPassword sans reconstitution du clair (Exigence 8).
Jeton de reset Entité dédiée ResetPasswordToken (TokenHash, Expiration, UsedAt) Sépare le cycle de vie du reset du Compte, permet l'usage unique et l'expiration proprement, et le hachage du jeton en base (Exigences 6, 7).
JWT HMAC‑SHA256 (HS256) via AddJwtBearer Mono‑service, clé symétrique secrète configurable ; validation native ASP.NET Core (Exigences 4, 5).
SMTP IEmailService + implémentation MailKit + repli ILogger MailKit est robuste et recommandé vs SmtpClient obsolète ; repli qui journalise le lien si SMTP absent (Exigence 6).
Stockage JWT (front) localStorage Session persistante 12 h sur poste personnel mono‑utilisateur ; risque XSS discuté §Frontend.

Architecture

Vue d'ensemble backend

graph TD
    subgraph Frontend Angular
        LC[LoginComponent]
        RC[RegisterComponent]
        FPC[ForgotPasswordComponent]
        RPC[ResetPasswordComponent]
        AS[AuthService signals]
        AI[authInterceptor]
        AG[authGuard]
    end

    subgraph Backend SchotzIadKmTracker.Api
        AC[AuthController AllowAnonymous]
        SA[Service_Auth : IAuthService]
        PH[PasswordHasher Compte]
        JS[IJwtService]
        ES[IEmailService MailKit / Log]
        DB[(AppDbContext SQLite)]
        PROT[Controllers protégés Authorize<br/>Trajets, Profil, Vehicules,<br/>AdressesNommees, Bareme, Resumes,<br/>Remboursement, ExportsPdf, Navigation]
    end

    LC --> AS
    RC --> AS
    FPC --> AS
    RPC --> AS
    AS -->|HTTP| AC
    AI -.->|Bearer + gestion 401| PROT
    AC --> SA
    SA --> PH
    SA --> JS
    SA --> ES
    SA --> DB
    PROT -->|JwtBearer valide le token| JS

AuthController (routes publiques [AllowAnonymous]) délègue à Service_Auth (IAuthService), qui orchestre PasswordHasher<Compte>, IJwtService, IEmailService et AppDbContext. Les controllers de données existants deviennent des Route_Protegee via [Authorize] (Exigence 4).

Insertion dans le pipeline Program.cs

L'ordre du middleware est critique : UseAuthentication() doit être ajouté avant UseAuthorization() (déjà présent). Ajouts prévus :

// --- Options ---
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection(AuthOptions.SectionName));
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection(SmtpOptions.SectionName));

// --- Services d'auth ---
builder.Services.AddSingleton<IPasswordHasher<Compte>, PasswordHasher<Compte>>();
builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IAuthService, AuthService>();

// --- Sélection de l'implémentation Email selon la présence de config SMTP ---
var smtpOptions = builder.Configuration.GetSection(SmtpOptions.SectionName).Get<SmtpOptions>() ?? new SmtpOptions();
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
    builder.Services.AddScoped<IEmailService, LoggingEmailService>();   // repli journalisation (Exigence 6.5)
else
    builder.Services.AddScoped<IEmailService, SmtpEmailService>();       // MailKit (Exigence 6.4)

// --- Authentification JWT ---
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,          // rejette les jetons expirés (Exigence 5.2)
            ValidateIssuerSigningKey = true,  // rejette les signatures invalides (Exigence 5.3)
            ValidIssuer = jwtOptions.Issuer,
            ValidAudience = jwtOptions.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)),
            ClockSkew = TimeSpan.FromSeconds(30) // tolérance d'horloge réduite
        };
    });

Pipeline HTTP (extrait, ordre imposé) :

app.UseCors(CorsPolicyName);
app.UseAuthentication();  // AJOUT — doit précéder UseAuthorization
app.UseAuthorization();   // existant
app.MapControllers();

Components and Interfaces

Service d'authentification

namespace SchotzIadKmTracker.Api.Services;

public interface IAuthService
{
    Task<bool> CompteExisteAsync(CancellationToken ct);                                   // Exigence 1.2, 9.1 (GET /compte-existe)
    Task RegisterAsync(RegisterDto dto, CancellationToken ct);                            // Exigence 1 — lève CompteDejaExistantException si compte présent
    Task<string> LoginAsync(LoginDto dto, CancellationToken ct);                          // Exigence 2 — retourne le JWT ; lève IdentifiantsInvalidesException
    Task ForgotPasswordAsync(ForgotPasswordDto dto, string resetBaseUrl, CancellationToken ct); // Exigence 6 — toujours succès générique
    Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct);                  // Exigence 7 — lève LienInvalideException / LienExpireException
}

Service JWT

public interface IJwtService
{
    /// <summary>Émet un Jeton_JWT signé HS256 dont l'expiration = maintenant + JwtOptions.ExpirationMinutes.</summary>
    string GenererJeton(Compte compte);   // Exigences 2.1, 2.4, 5.4
}

Claims émis : sub = compte.Id, email = compte.Email, jti = GUID (identifiant unique du jeton), iat/exp gérés par la librairie. Aucune donnée sensible (jamais le hash).

Service email

public interface IEmailService
{
    Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct); // Exigence 6.4 / 6.5
}
  • SmtpEmailService (MailKit SmtpClient) : envoie un email HTML/texte contenant lienReset, configuré par SmtpOptions.
  • LoggingEmailService : journalise le lien via ILogger au niveau Information (repli lorsque Smtp__Host est vide).

La sélection se fait en DI (voir Program.cs ci‑dessus). Le service appelant n'a pas connaissance de l'implémentation retenue (transparence, Exigence 6).

Controller d'authentification

AuthController ([ApiController], [Route("api/auth")], [AllowAnonymous]) :

Méthode Route Rôle Codes
POST /api/auth/register Inscription du compte unique (Exigence 1) 200 / 400 / 409
POST /api/auth/login Connexion, retourne { token, expiration } (Exigence 2) 200 / 401
POST /api/auth/forgot-password Demande de reset, réponse générique (Exigence 6) 200
POST /api/auth/reset-password Réinitialisation via Jeton_Reset (Exigence 7) 200 / 400
GET /api/auth/compte-existe { existe: bool } pour orienter le frontend (inscription vs connexion) 200

La construction de l'URL de reset se fait côté serveur à partir d'une base configurable (Auth__FrontendBaseUrl) : "{FrontendBaseUrl}/reset-password?token={token}". La déconnexion (Exigence 3) est entièrement côté client (suppression du token) et ne nécessite pas d'endpoint.

Protection des controllers existants (Exigence 4)

Deux options :

  1. Filtre d'autorisation global (retenu) — impose l'authentification par défaut sur tout le pipeline MVC, puis on ouvre explicitement l'auth via [AllowAnonymous] sur AuthController et /health. C'est le plus sûr (aucune route de données oubliée) :
builder.Services.AddControllers(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy)); // toutes les routes protégées par défaut
});

/health reste public (endpoint minimal MapHealthChecks, non couvert par les filtres MVC). Les controllers de données (TrajetsController, ProfilController, VehiculesController, AdressesController, AdressesNommeesController, BaremeController, ResumesController, RemboursementController, ExportsPdfController, NavigationController) sont ainsi protégés sans modification individuelle — ce qui couvre trajets, profil, véhicules, carnet, barème, résumés, montants, exports PDF et recalcul (Exigence 4.4).

  1. Alternative documentée : annoter chaque controller de données avec [Authorize]. Plus verbeux et sujet à l'oubli ; non retenu.

Data Models

Entité Compte

namespace SchotzIadKmTracker.Api.Models.Entities;

/// <summary>Compte unique de l'application mono-utilisateur.</summary>
public class Compte
{
    public int Id { get; set; } = 1;                 // singleton, comme Profil
    [MaxLength(255)] public string Email { get; set; } = string.Empty;
    public string MotDePasseHash { get; set; } = string.Empty;  // jamais exposé (Exigence 8.2)
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

Entité ResetPasswordToken

/// <summary>Jeton de réinitialisation à usage unique et à durée limitée.</summary>
public class ResetPasswordToken
{
    public int Id { get; set; }
    public string TokenHash { get; set; } = string.Empty;  // SHA-256 du jeton, jamais le jeton en clair
    public DateTime Expiration { get; set; }               // Exigence 6.2 (défaut +60 min)
    public DateTime? UsedAt { get; set; }                  // null = non utilisé ; renseigné à l'usage (Exigence 7.2)
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

Justification du choix d'entité dédiée : bien que l'application soit mono‑utilisateur, séparer le jeton du Compte évite de polluer l'entité Compte de colonnes transitoires, permet d'exprimer clairement l'usage unique (UsedAt) et l'expiration, et de purger/historiser sans toucher au Compte. Le jeton est haché en base (TokenHash) : seul le jeton en clair transmis par email permet la réinitialisation, ce qui limite l'impact d'une fuite de la base.

Déclarations AppDbContext (ajouts)

public DbSet<Compte> Comptes => Set<Compte>();
public DbSet<ResetPasswordToken> ResetPasswordTokens => Set<ResetPasswordToken>();

// OnModelCreating
modelBuilder.Entity<Compte>(e =>
{
    e.ToTable("Comptes");
    e.HasKey(c => c.Id);
    e.Property(c => c.Id).ValueGeneratedNever();          // singleton Id = 1
    e.HasIndex(c => c.Email, "UX_Comptes_Email").IsUnique(); // Email unique
});

modelBuilder.Entity<ResetPasswordToken>(e =>
{
    e.ToTable("ResetPasswordTokens");
    e.HasIndex(t => t.TokenHash, "UX_ResetTokens_TokenHash").IsUnique();
    e.HasIndex(t => t.Expiration, "IX_ResetTokens_Expiration");
});

Migration EF

Une nouvelle migration AjoutAuthentification crée les tables Comptes et ResetPasswordTokens avec leurs index. Elle est appliquée automatiquement au démarrage par le MigrateAsync() existant de Program.cs, y compris dans le conteneur Docker (volume trajets-data). Aucune donnée existante n'est impactée.

DTOs (records, Models/DTOs/AuthDtos.cs)

namespace SchotzIadKmTracker.Api.Models.DTOs;

public record RegisterDto(string Email, string MotDePasse);
public record LoginDto(string Email, string MotDePasse);
public record LoginResultDto(string Token, DateTime Expiration);
public record ForgotPasswordDto(string Email);
public record ResetPasswordDto(string Token, string NouveauMotDePasse);
public record CompteExisteDto(bool Existe);

Aucun DTO n'expose MotDePasseHash (Exigence 8.2).

Configuration (Options)

public class JwtOptions   { public const string SectionName = "Jwt";
    public string SecretKey { get; set; } = ""; public string Issuer { get; set; } = "SchotzIadKmTracker";
    public string Audience { get; set; } = "SchotzIadKmTracker"; public int ExpirationMinutes { get; set; } = 720; } // Exigence 5.5

public class AuthOptions  { public const string SectionName = "Auth";
    public int ResetTokenLifetimeMinutes { get; set; } = 60;   // Exigence 6.3
    public string FrontendBaseUrl { get; set; } = "http://localhost:8080"; }

public class SmtpOptions  { public const string SectionName = "Smtp";
    public string Host { get; set; } = ""; public int Port { get; set; } = 587;
    public string User { get; set; } = ""; public string Password { get; set; } = ""; public string From { get; set; } = ""; }

Correctness Properties

Une propriété est une caractéristique ou un comportement qui doit rester vrai pour toutes les exécutions valides du système — une affirmation formelle de ce que le système doit faire. Les propriétés font le pont entre les spécifications lisibles par l'humain et des garanties de correction vérifiables par la machine.

Ces propriétés découlent de la prework d'analyse des critères d'acceptation. Elles seront implémentées avec FsCheck.Xunit (déjà présent dans le projet de tests), à raison d'au moins 100 itérations par propriété.

Property 1 : Round-trip du hachage de mot de passe

Pour tout mot de passe non vide, le hachage produit une valeur différente du clair, et PasswordHasher.VerifyHashedPassword(hash, motDePasse) retourne Success ; de plus deux hachages du même mot de passe diffèrent (présence de sel).

Validates: Requirements 1.5, 8.1, 8.3

Property 2 : Émission/validation JWT et préservation des claims

Pour toute valeur d'ExpirationMinutes positive et tout compte, le jeton émis est validable avec les paramètres de validation, préserve les claims sub et email, et son exp vaut iat + ExpirationMinutes (à la tolérance d'horloge près).

Validates: Requirements 2.1, 2.4, 5.1, 5.4, 5.5

Property 3 : Un jeton JWT expiré est rejeté

Pour tout jeton dont la date d'expiration est antérieure à l'instant courant, la validation échoue (jeton considéré invalide).

Validates: Requirements 4.3, 5.2

Property 4 : Une signature JWT invalide est rejetée

Pour tout jeton signé avec une clé différente de la clé de validation, ou dont la charge utile a été altérée, la validation échoue.

Validates: Requirements 5.3, 4.2

Property 5 : L'inscription crée le compte lorsqu'aucun compte n'existe

Pour toute paire (Email syntaxiquement valide, Mot_De_Passe d'au moins 8 caractères), à partir d'une base sans compte, l'inscription aboutit et il existe ensuite exactement un compte portant cet Email.

Validates: Requirements 1.1

Property 6 : L'inscription est rejetée lorsqu'un compte existe déjà

Pour toute seconde tentative d'inscription (avec n'importe quelle paire valide) alors qu'un compte existe déjà, la demande est rejetée avec un code 409 et le nombre de comptes reste égal à un.

Validates: Requirements 1.2

Property 7 : Un mot de passe trop court est refusé à l'inscription

Pour toute chaîne de longueur strictement inférieure à 8, l'inscription est rejetée avec un code 400 et aucun compte n'est créé.

Validates: Requirements 1.3

Property 8 : La connexion émet un jeton sur identifiants corrects et rejette les identifiants invalides

Pour tout compte enregistré, se connecter avec les identifiants exacts produit un jeton JWT validable ; et pour tout couple d'identifiants incorrects (Email inconnu ou Mot_De_Passe erroné), la connexion est rejetée avec un code 401 sans émettre de jeton.

Validates: Requirements 2.1, 2.2

Property 9 : Non-divulgation lors de la demande de réinitialisation

Pour tout Email fourni à forgot-password, qu'il corresponde ou non au compte, la réponse HTTP (statut et corps) est identique ; aucun jeton n'est créé pour un Email inconnu.

Validates: Requirements 6.6

Property 10 : Expiration du jeton de reset paramétrée

Pour toute valeur positive de ResetTokenLifetimeMinutes, tout jeton de reset généré possède une expiration égale à sa date de création plus cette durée (à la tolérance près) et un UsedAt nul.

Validates: Requirements 6.1, 6.2, 6.3

Property 11 : Une réinitialisation valide remplace le mot de passe

Pour tout jeton de reset valide, non expiré et non utilisé, et tout nouveau Mot_De_Passe d'au moins 8 caractères, après réinitialisation la connexion réussit avec le nouveau mot de passe et échoue avec l'ancien.

Validates: Requirements 7.1

Property 12 : Un jeton de reset est à usage unique

Pour tout jeton de reset utilisé une fois avec succès, toute réutilisation ultérieure du même jeton est rejetée avec un code 400 (jeton invalidé, UsedAt renseigné).

Validates: Requirements 7.2, 7.4

Property 13 : Un jeton de reset expiré ou inconnu est rejeté

Pour tout jeton de reset dont l'expiration est dépassée, ou pour tout jeton inconnu de la base, la réinitialisation est rejetée avec un code 400 et le mot de passe du compte reste inchangé.

Validates: Requirements 7.3, 7.4

Property 14 : Un nouveau mot de passe trop court est refusé à la réinitialisation

Pour tout jeton de reset valide et toute chaîne de longueur strictement inférieure à 8, la réinitialisation est rejetée avec un code 400 et le mot de passe du compte reste inchangé.

Validates: Requirements 7.5

Error Handling

Les erreurs sont renvoyées au format ProblemDetails (déjà activé via AddProblemDetails() et UseExceptionHandler()). AuthController traduit les exceptions métier en codes HTTP :

Situation Code HTTP Message Exigence
Compte déjà existant à l'inscription 409 Conflict « Un compte existe déjà. » 1.2
Mot de passe < 8 caractères (inscription/reset) 400 Bad Request « Le mot de passe doit comporter au moins 8 caractères. » 1.3, 7.5
Email syntaxiquement invalide 400 Bad Request « Adresse email invalide. » 1.4
Identifiants de connexion invalides 401 Unauthorized « Identifiants invalides. » (message générique) 2.2
Route protégée sans/avec JWT invalide ou expiré 401 Unauthorized (réponse standard JwtBearer) 4.2, 4.3, 5.2, 5.3
Jeton de reset expiré 400 Bad Request « Le lien de réinitialisation a expiré. » 7.3
Jeton de reset inconnu ou déjà utilisé 400 Bad Request « Le lien de réinitialisation est invalide. » 7.2, 7.4
Demande de réinitialisation (email connu ou non) 200 OK Réponse générique de succès 6.6

Principe de non-divulgation : forgot-password renvoie toujours 200 avec le même corps, que l'Email corresponde ou non à un compte, afin de ne pas révéler l'existence du compte (Exigence 6.6). De même, le message de connexion invalide est volontairement générique et ne distingue pas « email inconnu » de « mot de passe incorrect » (Exigence 2.2).

Frontend (Angular zoneless, signals)

AuthService

AuthService (providedIn: 'root', core/services/auth.service.ts) expose l'état d'authentification via signals (contrainte zoneless) :

private readonly _token = signal<string | null>(this.lireTokenStocke());
readonly estAuthentifie = computed(() => this._token() !== null);

private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly baseUrl = `${environment.apiUrl}/auth`; // cohérent avec ProfilService

login(dto: LoginDto): Observable<LoginResult> { /* POST /auth/login -> stocke token */ }
register(dto: RegisterDto): Observable<void>   { /* POST /auth/register */ }
forgotPassword(email: string): Observable<void> { /* POST /auth/forgot-password */ }
resetPassword(dto: ResetPasswordDto): Observable<void> { /* POST /auth/reset-password */ }
compteExiste(): Observable<{ existe: boolean }> { /* GET /auth/compte-existe */ }
logout(): void { this.supprimerToken(); this.router.navigate(['/login']); } // Exigence 3
getToken(): string | null { return this._token(); }

Stockage du JWT — localStorage vs sessionStorage : on retient localStorage. Justification : sur un poste personnel mono‑utilisateur, une session persistante de 12 h (fermeture/réouverture d'onglet sans reconnexion) correspond à l'usage attendu ; sessionStorage forcerait une reconnexion à chaque nouvel onglet. Risque XSS : tout stockage accessible en JavaScript (localStorage/sessionStorage) est exposé si une faille XSS est présente — un attaquant pourrait exfiltrer le jeton. Atténuations retenues : Angular échappe le HTML par défaut, expiration courte du JWT (12 h) limitant la fenêtre, et purge automatique sur 401. Une alternative plus robuste (cookie HttpOnly + SameSite) est notée mais non retenue ici car elle impose une gestion CSRF et complexifie le reverse‑proxy same‑origin ; elle reste une évolution possible.

authInterceptor

authInterceptor (HttpInterceptorFn, core/interceptors/auth.interceptor.ts) :

  • ajoute l'en‑tête Authorization: Bearer <token> aux requêtes vers environment.apiUrl lorsque AuthService.getToken() est non nul ;
  • n'ajoute pas l'en‑tête aux endpoints publics d'auth ni aux appels externes (BAN) ;
  • sur une réponse 401, purge le token (AuthService) et redirige vers /login (Exigence 4.7).

Il s'enregistre dans app.config.ts avant le timeoutInterceptor existant :

provideHttpClient(withInterceptors([authInterceptor, timeoutInterceptor]))

L'ordre place authInterceptor en premier pour injecter le Bearer avant le contrôle de timeout, tout en préservant la logique ServeurIndisponibleError existante.

authGuard

authGuard (CanActivateFn, core/guards/auth.guard.ts) : si AuthService.estAuthentifie() est faux, retourne un UrlTree vers /login ; sinon true (Exigences 4.6, 9.3). Elle se compose avec la profilConfigureGuard existante : sur les routes de données, on applique [authGuard, profilConfigureGuard] (l'authentification est vérifiée d'abord, puis la configuration du profil).

Écrans standalone

  • LoginComponent (/login) — email + mot de passe ; sur succès, stocke le token puis navigue vers la cible protégée.
  • RegisterComponent (/register) — Etape_1_Onboarding ; sur succès redirige vers /login (Exigences 1.6, 9.2).
  • ForgotPasswordComponent (/forgot-password) — saisie de l'email, affiche un message générique quel que soit le résultat.
  • ResetPasswordComponent (/reset-password) — lit le token depuis les query params de l'URL, saisit le nouveau mot de passe ; sur succès redirige vers /login (Exigence 7.6).

Tous ces écrans exposent leur état async (chargement, erreur) via des signals, conformément au mode zoneless.

Réorganisation de l'onboarding et routes

Le parcours devient : inscription (Etape 1) → connexion → saisie profil + premier véhicule (Etape 2). L'OnboardingComponent existant reste l'écran de l'Etape 2 (comportement inchangé — Exigence 9.5), désormais protégé par authGuard puis piloté par la profilConfigureGuard existante.

Routes mises à jour (app.routes.ts) :

export const routes: Routes = [
  // Routes PUBLIQUES (aucune garde)
  { path: 'login',           component: LoginComponent },
  { path: 'register',        component: RegisterComponent },
  { path: 'forgot-password', component: ForgotPasswordComponent },
  { path: 'reset-password',  component: ResetPasswordComponent },

  // Routes PROTEGEES : authGuard d'abord, puis profilConfigureGuard
  { path: 'onboarding',      component: OnboardingComponent, canActivate: [authGuard] }, // Etape 2 (Exigence 9.3, 9.4)
  { path: 'profil',          component: ProfilComponent,     canActivate: [authGuard] },
  { path: 'vehicules',       component: VehiculesComponent,  canActivate: [authGuard] },
  { path: 'consultation',    component: ConsultationJourComponent, canActivate: [authGuard, profilConfigureGuard] },
  { path: 'carnet-adresses', component: CarnetAdressesComponent,   canActivate: [authGuard, profilConfigureGuard] },
  { path: 'resume-mensuel',  component: ResumeMensuelComponent,    canActivate: [authGuard, profilConfigureGuard] },
  { path: 'resume-annuel',   component: ResumeAnnuelComponent,     canActivate: [authGuard, profilConfigureGuard] },
  { path: 'bareme',          component: BaremeComponent,           canActivate: [authGuard, profilConfigureGuard] },
  { path: 'montants-regles', component: MontantsReglesComponent,   canActivate: [authGuard, profilConfigureGuard] },

  { path: '', redirectTo: 'consultation', pathMatch: 'full' },
  { path: '**', redirectTo: 'consultation' }
];

Configuration et déploiement

Variables d'environnement à ajouter

Variable (env) Section config Défaut Rôle
JWT__SecretKey Jwt:SecretKey (aucun) Secret de signature HMAC — jamais commité
JWT__Issuer Jwt:Issuer SchotzIadKmTracker Émetteur du jeton
JWT__Audience Jwt:Audience SchotzIadKmTracker Audience du jeton
JWT__ExpirationMinutes Jwt:ExpirationMinutes 720 Durée de vie du JWT (12 h) — Exigence 5.5
Auth__ResetTokenLifetimeMinutes Auth:ResetTokenLifetimeMinutes 60 Durée de vie du jeton de reset — Exigence 6.3
Auth__FrontendBaseUrl Auth:FrontendBaseUrl http://localhost:8080 Base des liens de reset
Smtp__Host Smtp:Host (vide) Hôte SMTP ; vide ⇒ repli journalisation
Smtp__Port Smtp:Port 587 Port SMTP
Smtp__User / Smtp__Password Smtp:User/Password (vide) Identifiants SMTP (secrets)
Smtp__From Smtp:From (vide) Adresse expéditrice

JWT__SecretKey, Smtp__Password (et Smtp__User) sont des secrets : ils vont dans .env (déjà gitignoré) et jamais dans .env.example avec une valeur réelle. En dev local, JWT__SecretKey peut aussi passer par les User Secrets (UserSecretsId déjà présent).

.env.example (ajouts documentés)

# --- Authentification ---
# Secret de signature JWT (OBLIGATOIRE en production). Générez une valeur aléatoire longue (>= 32 octets).
# Ne mettez JAMAIS le secret réel ici ; renseignez-le dans .env (non commité).
JWT_SECRET_KEY=change-me-generate-a-long-random-secret
JWT_EXPIRATION_MINUTES=720
AUTH_RESET_TOKEN_LIFETIME_MINUTES=60
AUTH_FRONTEND_BASE_URL=http://localhost:8080

# --- SMTP (facultatif) : si SMTP_HOST est vide, le lien de reset est journalisé côté serveur ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=

docker-compose.yml (ajouts au service backend)

    environment:
      - ASPNETCORE_URLS=http://+:8080
      - ConnectionStrings__Default=Data Source=/data/trajets.db
      - ORS__ApiKey=${ORS_API_KEY:-}
      # --- Authentification ---
      - JWT__SecretKey=${JWT_SECRET_KEY:?JWT_SECRET_KEY requis}
      - JWT__ExpirationMinutes=${JWT_EXPIRATION_MINUTES:-720}
      - Auth__ResetTokenLifetimeMinutes=${AUTH_RESET_TOKEN_LIFETIME_MINUTES:-60}
      - Auth__FrontendBaseUrl=${AUTH_FRONTEND_BASE_URL:-http://localhost:8080}
      - Smtp__Host=${SMTP_HOST:-}
      - Smtp__Port=${SMTP_PORT:-587}
      - Smtp__User=${SMTP_USER:-}
      - Smtp__Password=${SMTP_PASSWORD:-}
      - Smtp__From=${SMTP_FROM:-}

La syntaxe :? sur JWT_SECRET_KEY fait échouer le démarrage si le secret est absent, évitant un déploiement avec une clé vide.

Diagrammes

Séquence — Inscription puis connexion

sequenceDiagram
    participant U as Utilisateur
    participant FE as Frontend (Register/Login)
    participant AC as AuthController
    participant SA as Service_Auth
    participant DB as AppDbContext

    U->>FE: Saisit email + mot de passe (inscription)
    FE->>AC: POST /api/auth/register
    AC->>SA: RegisterAsync(dto)
    alt Aucun compte n'existe
        SA->>SA: PasswordHasher.HashPassword(mdp)
        SA->>DB: Insert Compte (Email, MotDePasseHash)
        SA-->>AC: OK
        AC-->>FE: 200
        FE->>U: Redirige vers /login (Exig. 1.6)
    else Compte déjà existant
        SA-->>AC: CompteDejaExistant
        AC-->>FE: 409 (Exig. 1.2)
    end

    U->>FE: Saisit email + mot de passe (connexion)
    FE->>AC: POST /api/auth/login
    AC->>SA: LoginAsync(dto)
    SA->>DB: Lecture Compte par Email
    alt Identifiants valides
        SA->>SA: PasswordHasher.VerifyHashedPassword
        SA->>SA: JwtService.GenererJeton(compte)
        SA-->>AC: token + expiration
        AC-->>FE: 200 { token }
        FE->>FE: localStorage.setItem(token) (Exig. 2.3)
    else Invalides
        SA-->>AC: IdentifiantsInvalides
        AC-->>FE: 401 message générique (Exig. 2.2)
    end

Séquence — Réinitialisation avec repli journalisation

sequenceDiagram
    participant U as Utilisateur
    participant FE as Frontend
    participant AC as AuthController
    participant SA as Service_Auth
    participant DB as AppDbContext
    participant ES as IEmailService

    U->>FE: Demande réinitialisation (email)
    FE->>AC: POST /api/auth/forgot-password
    AC->>SA: ForgotPasswordAsync(email)
    alt Email correspond au compte
        SA->>SA: Génère token (usage unique) + hash
        SA->>DB: Insert ResetPasswordToken (Expiration +60min)
        SA->>SA: Construit lien {FrontendBaseUrl}/reset-password?token=...
        alt SMTP configuré
            SA->>ES: SmtpEmailService.EnvoyerLienResetAsync (Exig. 6.4)
        else SMTP absent
            SA->>ES: LoggingEmailService -> ILogger journalise le lien (Exig. 6.5)
        end
    else Email inconnu
        SA->>SA: Ne crée rien
    end
    SA-->>AC: Succès générique
    AC-->>FE: 200 identique dans tous les cas (Exig. 6.6)

    U->>FE: Ouvre le lien, saisit nouveau mot de passe
    FE->>AC: POST /api/auth/reset-password { token, nouveauMdp }
    AC->>SA: ResetPasswordAsync(dto)
    SA->>DB: Recherche token (hash), non expiré, non utilisé
    alt Valide
        SA->>SA: Hash nouveau mdp -> maj Compte
        SA->>DB: UsedAt = now (Exig. 7.2)
        AC-->>FE: 200 -> redirige /login (Exig. 7.6)
    else Expiré / invalide / mdp court
        AC-->>FE: 400 (Exig. 7.3, 7.4, 7.5)
    end

Flux — Garde de route frontend

flowchart TD
    A[Navigation vers une route protégée] --> B{authGuard : token présent ?}
    B -- Non --> C[UrlTree /login]
    B -- Oui --> D{Route de données ?}
    D -- Non ex. /profil, /onboarding --> E[Accès autorisé]
    D -- Oui --> F{profilConfigureGuard : profil configuré ?}
    F -- Non --> G[UrlTree /onboarding]
    F -- Oui --> H[Accès autorisé]

Composants — Backend

graph LR
    AC[AuthController] --> SA[Service_Auth]
    SA --> PH[PasswordHasher&lt;Compte&gt;]
    SA --> JS[JwtService]
    SA --> ES[IEmailService]
    SA --> DB[(AppDbContext)]
    ES --> SM[SmtpEmailService MailKit]
    ES --> LG[LoggingEmailService ILogger]

Testing Strategy

Le projet de tests existant SchotzIadKmTracker.Tests utilise xUnit, FluentAssertions, FsCheck.Xunit, Moq et Microsoft.AspNetCore.Mvc.Testing (WebApplicationFactory<Program>). La stratégie réutilise ces outils, sans dépendance nouvelle.

PBT applicable ici : le hachage, l'émission/validation JWT et le cycle du jeton de reset sont des logiques à entrées/sorties claires, avec de nombreuses propriétés universelles (round-trip, expiration, usage unique). On applique donc une approche duale.

Tests de propriétés (FsCheck, ≥ 100 itérations)

Chaque test référence sa propriété de conception via un commentaire au format : // Feature: authentification, Property {n}: {texte}. Chaque propriété est couverte par un seul test de propriété.

Propriété Test
P1 Hachage Pour tout mdp : hash != mdp, Verify=Success, deux hachages diffèrent
P2 Round-trip JWT Pour tout ExpirationMinutes > 0 : validation OK, claims sub/email préservés, exp = iat + minutes
P3 JWT expiré Pour toute durée négative : validation échoue
P4 Signature JWT Pour toute clé alternative / token altéré : validation échoue
P5 Inscription crée Base vide + paire valide ⇒ 1 compte avec l'email
P6 Doublon 409 Compte existant + 2e inscription ⇒ 409, toujours 1 compte
P7 Mdp court 400 Longueur 0..7 ⇒ 400, aucun compte
P8 Login Bons identifiants ⇒ token validable ; mauvais ⇒ 401 sans token
P9 Non-divulgation Email connu vs inconnus ⇒ réponse identique, aucun token pour inconnu
P10 Expiration reset Pour toute durée > 0 : Expiration = CreatedAt + durée, UsedAt nul
P11 Reset applique mdp Reset valide ⇒ login OK avec nouveau mdp, KO avec ancien
P12 Usage unique 2e usage du même token ⇒ 400, UsedAt renseigné
P13 Reset expiré/inconnu Token expiré ou inconnu ⇒ 400, mdp inchangé
P14 Reset mdp court Token valide + mdp < 8 ⇒ 400, mdp inchangé

Les tests logiques (P1–P4) portent sur IJwtService / PasswordHasher en isolation. Les tests de flux (P5–P14) s'exécutent sur IAuthService avec un AppDbContext SQLite en mémoire (Data Source=:memory:) pour rester rapides et déterministes ; IEmailService est mocké (Moq).

Tests unitaires et d'exemples

  • Validation d'entrée (FluentValidation) : emails invalides ⇒ 400 (Exigence 1.4, edge-cases : "a@", "abc", "@x", chaîne vide) ; email valide accepté.
  • Exclusion du hash : les corps de réponse d'auth ne contiennent aucun champ de hash (Exigence 8.2).
  • Sélection DI de IEmailService : Smtp__Host vide ⇒ LoggingEmailService ; renseigné ⇒ SmtpEmailService (Exigences 6.4, 6.5).
  • Frontend (tests de service/garde/composant) : AuthService stocke/supprime le token dans localStorage (2.3, 3.1), authGuard redirige un non‑authentifié vers /login (4.6, 9.3), authInterceptor purge le token et redirige sur 401 (4.7), ResetPasswordComponent lit le token depuis l'URL.

Tests d'intégration (WebApplicationFactory<Program>, 1–3 exemples)

  • Protection [Authorize] (Exigence 4.4/4.5) : sur un échantillon de routes de données (/api/trajets, /api/profil, /api/vehicules, /api/bareme, /api/resumes, exports PDF, recalcul), un appel sans jeton renvoie 401 ; avec un jeton valide, renvoie 2xx (Exigence 4.1). Les routes /api/auth/* et /health restent accessibles sans jeton.
  • Bout en bout : register → login → appel protégé avec le token obtenu ; puis forgot → reset → login avec le nouveau mot de passe.

Configuration / smoke

  • Liaison des options SmtpOptions, JwtOptions, AuthOptions depuis la configuration (Exigence 6.7) — vérification de démarrage.

Cette conception couvre l'ensemble des exigences 1 à 9 du document de requirements. Si des lacunes apparaissent (par exemple sur le comportement précis attendu d'un écran ou une variable de configuration additionnelle), il est proposé de revenir à la phase de clarification des exigences avant de passer aux tâches.