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).
This commit is contained in:
@@ -9,3 +9,18 @@
|
||||
# d'itinéraires. Obtenez une clé sur https://openrouteservice.org/dev/#/signup
|
||||
# Injectée dans le conteneur backend via ORS__ApiKey=${ORS_API_KEY}.
|
||||
ORS_API_KEY=your-openrouteservice-api-key-here
|
||||
|
||||
# --- 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=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "c444de76-b758-4bf8-9de8-2f233790bc08", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -0,0 +1,694 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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 :
|
||||
|
||||
```csharp
|
||||
// --- 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é) :
|
||||
|
||||
```csharp
|
||||
app.UseCors(CorsPolicyName);
|
||||
app.UseAuthentication(); // AJOUT — doit précéder UseAuthorization
|
||||
app.UseAuthorization(); // existant
|
||||
app.MapControllers();
|
||||
```
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### Service d'authentification
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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) :
|
||||
|
||||
```csharp
|
||||
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).
|
||||
|
||||
2. Alternative documentée : annoter chaque controller de données avec `[Authorize]`. Plus verbeux et sujet à l'oubli ; non retenu.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Entité `Compte`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
/// <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)
|
||||
|
||||
```csharp
|
||||
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`)
|
||||
|
||||
```csharp
|
||||
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)
|
||||
|
||||
```csharp
|
||||
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) :
|
||||
|
||||
```typescript
|
||||
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 :
|
||||
|
||||
```typescript
|
||||
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`) :
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```dotenv
|
||||
# --- 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`)
|
||||
|
||||
```yaml
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AC[AuthController] --> SA[Service_Auth]
|
||||
SA --> PH[PasswordHasher<Compte>]
|
||||
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.*
|
||||
@@ -0,0 +1,139 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
Cette fonctionnalité ajoute une couche d'authentification à l'application mono-utilisateur SchotzIAD KM Tracker. Elle introduit un compte unique (adresse email + mot de passe haché), une session par jeton JWT, la protection de toutes les routes de données existantes, un mécanisme de réinitialisation de mot de passe par email (avec repli de journalisation en l'absence de configuration SMTP), et réorganise l'onboarding en deux étapes : d'abord l'inscription et la connexion, puis la saisie du profil et du premier véhicule (comportement actuel inchangé).
|
||||
|
||||
L'application étant mono-utilisateur, la création de compte n'est autorisée qu'une seule fois : lorsqu'aucun compte n'existe encore. Une fois le compte créé, seule la connexion reste possible.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Systeme** : L'application SchotzIAD KM Tracker dans son ensemble (frontend Angular + backend ASP.NET Core).
|
||||
- **Service_Auth** : Le composant backend responsable de l'inscription, de la connexion, de la déconnexion et de la réinitialisation de mot de passe.
|
||||
- **API** : L'ensemble des points d'accès HTTP exposés par le backend ASP.NET Core.
|
||||
- **Compte** : L'unique enregistrement utilisateur contenant l'adresse email (identifiant) et le mot de passe haché.
|
||||
- **Email** : L'adresse électronique servant d'identifiant de connexion au Compte.
|
||||
- **Mot_De_Passe** : Le secret d'authentification saisi par l'utilisateur, d'une longueur minimale de 8 caractères.
|
||||
- **Jeton_JWT** : Le jeton JSON Web Token émis par le Service_Auth à la connexion, transmis par le navigateur pour authentifier les appels aux routes protégées.
|
||||
- **Jeton_Reset** : Le jeton à usage unique et à durée de vie limitée émis lors d'une demande de réinitialisation de mot de passe.
|
||||
- **Route_Protegee** : Toute route ou point d'API accessible uniquement à un utilisateur authentifié (trajets, profil, véhicules, carnet d'adresses, barème, résumés, montants, exports PDF, recalcul de distances).
|
||||
- **Route_Publique** : Toute route ou point d'API accessible sans authentification (inscription lorsqu'aucun Compte n'existe, connexion, demande de réinitialisation, réinitialisation via Jeton_Reset).
|
||||
- **Onboarding** : Le parcours de première utilisation, désormais organisé en deux étapes.
|
||||
- **Etape_1_Onboarding** : La première étape de l'Onboarding : inscription (Email + Mot_De_Passe) puis redirection vers la page de connexion.
|
||||
- **Etape_2_Onboarding** : La seconde étape de l'Onboarding, accessible uniquement après connexion : saisie du profil (nom, prénom, matricule, adresse de domicile) et du premier véhicule.
|
||||
- **Garde_Profil** : La garde de route existante `profilConfigureGuard` qui vérifie l'état de configuration du profil via `GET /api/profil/etat`.
|
||||
- **Garde_Auth** : La garde de route frontend qui empêche l'accès aux pages protégées tant que l'utilisateur n'est pas authentifié.
|
||||
- **Service_SMTP** : Le serveur d'envoi de courriels configuré par variables d'environnement (hôte, port, identifiants, expéditeur).
|
||||
- **Duree_Validite_Jeton** : La durée de vie configurée du Jeton_JWT, valant 12 heures (720 minutes) par défaut et configurable par variable d'environnement (par exemple `Jwt:ExpirationMinutes` / `JWT__ExpirationMinutes`).
|
||||
- **Duree_Validite_Reset** : La durée de vie configurée du Jeton_Reset (durée limitée après laquelle le jeton expire), valant 1 heure (60 minutes) par défaut et configurable par variable d'environnement (par exemple `Auth:ResetTokenLifetimeMinutes`).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1 : Inscription du compte unique
|
||||
|
||||
**User Story:** En tant qu'utilisateur de première utilisation, je veux créer un compte avec mon adresse email et un mot de passe, afin de sécuriser l'accès à mon suivi kilométrique.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN une inscription est demandée ET qu'aucun Compte n'existe, THE Service_Auth SHALL créer le Compte avec l'Email fourni et le Mot_De_Passe fourni.
|
||||
2. IF une inscription est demandée ALORS qu'un Compte existe déjà, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 409 et un message indiquant qu'un compte existe déjà.
|
||||
3. IF le Mot_De_Passe fourni comporte moins de 8 caractères, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 400 et un message indiquant la longueur minimale requise.
|
||||
4. IF l'Email fourni n'est pas une adresse électronique valide sur le plan syntaxique, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 400.
|
||||
5. WHEN le Service_Auth crée le Compte, THE Service_Auth SHALL stocker le Mot_De_Passe sous forme hachée.
|
||||
6. WHEN le Compte est créé avec succès, THE Systeme SHALL rediriger l'utilisateur vers la page de connexion.
|
||||
|
||||
### Requirement 2 : Connexion
|
||||
|
||||
**User Story:** En tant qu'utilisateur disposant d'un compte, je veux me connecter avec mon email et mon mot de passe, afin d'accéder à mes données.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN une connexion est demandée avec un Email et un Mot_De_Passe correspondant au Compte, THE Service_Auth SHALL émettre un Jeton_JWT et le retourner à l'utilisateur.
|
||||
2. IF une connexion est demandée avec un Email inconnu ou un Mot_De_Passe incorrect, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 401 et un message générique d'identifiants invalides.
|
||||
3. WHEN un Jeton_JWT est émis, THE Systeme SHALL stocker le Jeton_JWT côté navigateur.
|
||||
4. WHEN un Jeton_JWT est émis, THE Service_Auth SHALL associer au Jeton_JWT une date d'expiration.
|
||||
|
||||
### Requirement 3 : Déconnexion
|
||||
|
||||
**User Story:** En tant qu'utilisateur connecté, je veux me déconnecter, afin que ma session ne reste pas active sur l'appareil.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN l'utilisateur demande la déconnexion, THE Systeme SHALL supprimer le Jeton_JWT stocké côté navigateur.
|
||||
2. WHEN le Jeton_JWT a été supprimé, THE Systeme SHALL rediriger l'utilisateur vers la page de connexion.
|
||||
|
||||
### Requirement 4 : Protection des routes et de l'API
|
||||
|
||||
**User Story:** En tant que propriétaire des données, je veux que toutes mes données soient inaccessibles sans authentification, afin de préserver leur confidentialité.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN un appel est adressé à une Route_Protegee avec un Jeton_JWT valide et non expiré, THE API SHALL traiter la requête normalement.
|
||||
2. IF un appel est adressé à une Route_Protegee sans Jeton_JWT ou avec un Jeton_JWT invalide, THEN THE API SHALL rejeter la requête avec un code d'erreur HTTP 401.
|
||||
3. IF un appel est adressé à une Route_Protegee avec un Jeton_JWT expiré, THEN THE API SHALL rejeter la requête avec un code d'erreur HTTP 401.
|
||||
4. THE API SHALL classer comme Route_Protegee les points d'accès de trajets, profil, véhicules, carnet d'adresses, barème, résumés, montants réglés, exports PDF et recalcul de distances.
|
||||
5. THE API SHALL classer comme Route_Publique les points d'accès d'inscription, de connexion, de demande de réinitialisation et de réinitialisation via Jeton_Reset.
|
||||
6. WHEN un utilisateur non authentifié tente d'accéder à une page frontend protégée, THE Garde_Auth SHALL rediriger l'utilisateur vers la page de connexion.
|
||||
7. WHEN l'API rejette un appel frontend avec un code HTTP 401, THE Systeme SHALL supprimer le Jeton_JWT stocké et rediriger l'utilisateur vers la page de connexion.
|
||||
|
||||
### Requirement 5 : Expiration et validité du jeton
|
||||
|
||||
**User Story:** En tant que propriétaire des données, je veux que ma session expire après une durée définie, afin de limiter la fenêtre d'accès en cas de compromission de l'appareil.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHILE le Jeton_JWT n'a pas atteint sa date d'expiration, THE API SHALL considérer le Jeton_JWT comme valide.
|
||||
2. WHEN le Jeton_JWT atteint sa date d'expiration, THE API SHALL considérer le Jeton_JWT comme invalide.
|
||||
3. IF un Jeton_JWT présente une signature non valide, THEN THE API SHALL considérer le Jeton_JWT comme invalide.
|
||||
4. WHEN un Jeton_JWT est émis, THE Service_Auth SHALL fixer sa date d'expiration à 12 heures après l'émission par défaut.
|
||||
5. THE Systeme SHALL permettre de configurer la Duree_Validite_Jeton via une variable d'environnement (par exemple `JWT__ExpirationMinutes`), avec une valeur par défaut de 720 minutes.
|
||||
|
||||
### Requirement 6 : Demande de réinitialisation de mot de passe
|
||||
|
||||
**User Story:** En tant qu'utilisateur ayant oublié mon mot de passe, je veux demander un lien de réinitialisation par email, afin de retrouver l'accès à mon compte.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN une demande de réinitialisation est reçue pour un Email correspondant au Compte, THE Service_Auth SHALL générer un Jeton_Reset à usage unique assorti d'une Duree_Validite_Reset.
|
||||
2. WHEN un Jeton_Reset est généré, THE Service_Auth SHALL fixer l'expiration du Jeton_Reset à 1 heure après sa génération par défaut.
|
||||
3. THE Systeme SHALL permettre de configurer la Duree_Validite_Reset via une variable d'environnement (par exemple `Auth__ResetTokenLifetimeMinutes`), avec une valeur par défaut de 60 minutes.
|
||||
4. WHEN un Jeton_Reset est généré ET que le Service_SMTP est configuré, THE Service_Auth SHALL envoyer par email un lien de réinitialisation contenant le Jeton_Reset.
|
||||
5. IF aucun Service_SMTP n'est configuré, THEN THE Service_Auth SHALL journaliser côté serveur le lien de réinitialisation contenant le Jeton_Reset.
|
||||
6. WHEN une demande de réinitialisation est reçue pour un Email inconnu, THE Service_Auth SHALL retourner la même réponse de succès générique que pour un Email connu, sans indiquer si l'Email correspond à un Compte.
|
||||
7. THE Service_SMTP SHALL être configuré par les variables d'environnement d'hôte, de port, d'identifiants et d'expéditeur.
|
||||
|
||||
### Requirement 7 : Réinitialisation du mot de passe via jeton
|
||||
|
||||
**User Story:** En tant qu'utilisateur ayant reçu un lien de réinitialisation, je veux définir un nouveau mot de passe via ce lien, afin de restaurer l'accès à mon compte.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN une réinitialisation est soumise avec un Jeton_Reset valide, non expiré et non utilisé, ET un nouveau Mot_De_Passe d'au moins 8 caractères, THE Service_Auth SHALL remplacer le Mot_De_Passe du Compte par la version hachée du nouveau Mot_De_Passe.
|
||||
2. WHEN un Jeton_Reset est utilisé avec succès, THE Service_Auth SHALL invalider le Jeton_Reset pour tout usage ultérieur.
|
||||
3. IF une réinitialisation est soumise avec un Jeton_Reset expiré, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 400 et un message indiquant que le lien a expiré.
|
||||
4. IF une réinitialisation est soumise avec un Jeton_Reset inconnu ou déjà utilisé, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 400 et un message indiquant que le lien est invalide.
|
||||
5. IF une réinitialisation est soumise avec un nouveau Mot_De_Passe de moins de 8 caractères, THEN THE Service_Auth SHALL rejeter la demande avec un code d'erreur HTTP 400 et un message indiquant la longueur minimale requise.
|
||||
6. WHEN la réinitialisation aboutit, THE Systeme SHALL rediriger l'utilisateur vers la page de connexion.
|
||||
|
||||
### Requirement 8 : Sécurité des mots de passe
|
||||
|
||||
**User Story:** En tant que propriétaire des données, je veux que mon mot de passe ne soit jamais stocké ou exposé en clair, afin de me protéger en cas de fuite de données.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Service_Auth SHALL stocker le Mot_De_Passe uniquement sous forme hachée avec un algorithme de hachage de mot de passe à sel.
|
||||
2. THE Service_Auth SHALL exclure le Mot_De_Passe haché de toute réponse d'API.
|
||||
3. WHEN le Service_Auth vérifie un Mot_De_Passe lors de la connexion, THE Service_Auth SHALL comparer la valeur saisie à la version hachée stockée sans reconstituer le Mot_De_Passe en clair.
|
||||
|
||||
### Requirement 9 : Onboarding en deux étapes
|
||||
|
||||
**User Story:** En tant qu'utilisateur de première utilisation, je veux créer mon compte puis me connecter avant de renseigner mon profil, afin que mes données de profil soient protégées dès le départ.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN l'utilisateur démarre l'Onboarding ET qu'aucun Compte n'existe, THE Systeme SHALL présenter l'Etape_1_Onboarding d'inscription.
|
||||
2. WHEN l'Etape_1_Onboarding d'inscription aboutit, THE Systeme SHALL rediriger l'utilisateur vers la page de connexion.
|
||||
3. WHILE l'utilisateur n'est pas authentifié, THE Systeme SHALL empêcher l'accès à l'Etape_2_Onboarding.
|
||||
4. WHEN l'utilisateur est authentifié ET que le profil n'est pas encore configuré, THE Garde_Profil SHALL autoriser l'accès à l'Etape_2_Onboarding de saisie du profil et du premier véhicule.
|
||||
5. THE Etape_2_Onboarding SHALL conserver le comportement actuel de saisie du profil (nom, prénom, matricule, adresse de domicile) et du premier véhicule.
|
||||
6. WHEN l'utilisateur authentifié a configuré son profil, THE Garde_Profil SHALL autoriser l'accès aux Routes_Protegees applicatives.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Implementation Plan : Authentification
|
||||
|
||||
## Overview
|
||||
|
||||
Ce plan découpe l'ajout de l'authentification en tâches de codage incrémentales, chacune s'appuyant sur les précédentes et se terminant par un câblage complet (pas de code orphelin). Le backend (`SchotzIadKmTracker.Api`, ASP.NET Core .NET 8, EF Core + SQLite) est construit couche par couche — modèle → options → hachage → JWT → email → service métier → controller → sécurité pipeline — puis le frontend Angular zoneless (signals) — service → interceptor/garde → écrans → routes — et enfin la configuration/déploiement. Les tests de propriétés (FsCheck.Xunit) et d'exemples/intégration (xUnit + WebApplicationFactory) sont des sous-tâches placées au plus près du code qu'elles valident.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Backend — Modèle de données et persistance
|
||||
- [x] 1.1 Créer les entités `Compte` et `ResetPasswordToken`
|
||||
- Créer `Models/Entities/Compte.cs` (singleton `Id = 1` non généré, `Email`, `MotDePasseHash`, `CreatedAt`, `UpdatedAt`)
|
||||
- Créer `Models/Entities/ResetPasswordToken.cs` (`Id`, `TokenHash`, `Expiration`, `UsedAt?`, `CreatedAt`)
|
||||
- _Requirements: 1.5, 6.1, 7.2, 8.1_
|
||||
|
||||
- [x] 1.2 Déclarer les DbSet et la configuration `OnModelCreating`
|
||||
- Ajouter `DbSet<Compte>` et `DbSet<ResetPasswordToken>` dans `Data/AppDbContext.cs`
|
||||
- Configurer `Compte` : `HasKey`, `Id` `ValueGeneratedNever()`, index unique `UX_Comptes_Email` sur `Email`
|
||||
- Configurer `ResetPasswordToken` : index unique `UX_ResetTokens_TokenHash`, index `IX_ResetTokens_Expiration`
|
||||
- _Requirements: 1.1, 1.2, 6.1_
|
||||
|
||||
- [x] 1.3 Générer la migration EF `AjoutAuthentification`
|
||||
- Exécuter `dotnet ef migrations add AjoutAuthentification` pour créer les tables `Comptes` et `ResetPasswordTokens` avec leurs index
|
||||
- Vérifier que la migration est appliquée par le `MigrateAsync()` existant de `Program.cs` (aucune modification du démarrage requise)
|
||||
- _Requirements: 1.1, 4.4_
|
||||
|
||||
- [x] 2. Backend — Options de configuration
|
||||
- [x] 2.1 Créer les classes d'options et les lier à la configuration
|
||||
- Créer `Configuration/JwtOptions.cs` (`SecretKey`, `Issuer`, `Audience`, `ExpirationMinutes = 720`, `SectionName = "Jwt"`)
|
||||
- Créer `Configuration/AuthOptions.cs` (`ResetTokenLifetimeMinutes = 60`, `FrontendBaseUrl`, `SectionName = "Auth"`)
|
||||
- Créer `Configuration/SmtpOptions.cs` (`Host`, `Port = 587`, `User`, `Password`, `From`, `SectionName = "Smtp"`)
|
||||
- Enregistrer les trois via `builder.Services.Configure<...>(...GetSection(...))` dans `Program.cs`
|
||||
- _Requirements: 5.5, 6.3, 6.7_
|
||||
|
||||
- [x]* 2.2 Écrire un test de liaison des options
|
||||
- Vérifier que `JwtOptions`, `AuthOptions`, `SmtpOptions` se lient correctement depuis la configuration (démarrage)
|
||||
- _Requirements: 5.5, 6.3, 6.7_
|
||||
|
||||
- [x] 3. Backend — Hachage des mots de passe
|
||||
- [x] 3.1 Intégrer `PasswordHasher<Compte>` en DI
|
||||
- Enregistrer `IPasswordHasher<Compte>` -> `PasswordHasher<Compte>` en singleton dans `Program.cs`
|
||||
- _Requirements: 1.5, 8.1, 8.3_
|
||||
|
||||
- [x]* 3.2 Écrire le test de propriété du hachage
|
||||
- **Property 1 : Round-trip du hachage de mot de passe**
|
||||
- **Validates: Requirements 1.5, 8.1, 8.3**
|
||||
|
||||
- [x] 4. Backend — Service JWT
|
||||
- [x] 4.1 Implémenter `IJwtService` / `JwtService`
|
||||
- Créer `Services/IJwtService.cs` et `Services/JwtService.cs`
|
||||
- `GenererJeton(Compte)` : émission HS256 avec claims `sub`, `email`, `jti`, expiration = maintenant + `JwtOptions.ExpirationMinutes` ; aucune donnée sensible (jamais le hash)
|
||||
- Enregistrer `IJwtService` -> `JwtService` (scoped) dans `Program.cs`
|
||||
- _Requirements: 2.1, 2.4, 5.1, 5.4, 5.5_
|
||||
|
||||
- [x]* 4.2 Écrire le test de propriété émission/validation et claims
|
||||
- **Property 2 : Émission/validation JWT et préservation des claims**
|
||||
- **Validates: Requirements 2.1, 2.4, 5.1, 5.4, 5.5**
|
||||
|
||||
- [x]* 4.3 Écrire le test de propriété jeton expiré
|
||||
- **Property 3 : Un jeton JWT expiré est rejeté**
|
||||
- **Validates: Requirements 4.3, 5.2**
|
||||
|
||||
- [x]* 4.4 Écrire le test de propriété signature invalide
|
||||
- **Property 4 : Une signature JWT invalide est rejetée**
|
||||
- **Validates: Requirements 5.3, 4.2**
|
||||
|
||||
- [x] 5. Backend — Service email
|
||||
- [x] 5.1 Implémenter `IEmailService` et ses deux implémentations
|
||||
- Créer `Services/IEmailService.cs` (`EnvoyerLienResetAsync(destinataire, lienReset, ct)`)
|
||||
- Créer `Services/SmtpEmailService.cs` (MailKit, configuré par `SmtpOptions`, email HTML/texte contenant le lien)
|
||||
- Créer `Services/LoggingEmailService.cs` (repli : journalise le lien via `ILogger` niveau Information)
|
||||
- Ajouter la dépendance NuGet MailKit au projet API
|
||||
- _Requirements: 6.4, 6.5, 6.7_
|
||||
|
||||
- [x] 5.2 Câbler la sélection DI selon `Smtp__Host`
|
||||
- Dans `Program.cs`, enregistrer `LoggingEmailService` si `Smtp:Host` est vide, sinon `SmtpEmailService`
|
||||
- _Requirements: 6.4, 6.5_
|
||||
|
||||
- [x]* 5.3 Écrire le test de sélection DI de `IEmailService`
|
||||
- `Smtp__Host` vide ⇒ `LoggingEmailService` ; renseigné ⇒ `SmtpEmailService`
|
||||
- _Requirements: 6.4, 6.5_
|
||||
|
||||
- [x] 6. Backend — DTOs, validateurs et service d'authentification
|
||||
- [x] 6.1 Créer les DTOs d'authentification et les validateurs FluentValidation
|
||||
- Créer `Models/DTOs/AuthDtos.cs` (records `RegisterDto`, `LoginDto`, `LoginResultDto`, `ForgotPasswordDto`, `ResetPasswordDto`, `CompteExisteDto`) — aucun n'expose `MotDePasseHash`
|
||||
- Créer les validateurs FluentValidation : email syntaxiquement valide, `MotDePasse`/`NouveauMotDePasse` >= 8 caractères
|
||||
- _Requirements: 1.3, 1.4, 7.5, 8.2_
|
||||
|
||||
- [x] 6.2 Implémenter `IAuthService` / `AuthService`
|
||||
- Créer `Services/IAuthService.cs` et `Services/AuthService.cs` + exceptions métier (compte existant, identifiants invalides, lien invalide/expiré)
|
||||
- `CompteExisteAsync` ; `RegisterAsync` (compte unique, sinon exception -> 409, hachage du mdp) ; `LoginAsync` (vérifie via `VerifyHashedPassword`, retourne JWT, sinon exception générique -> 401)
|
||||
- `ForgotPasswordAsync` (réponse générique ; si email connu : crée un `ResetPasswordToken` haché avec expiration = now + `ResetTokenLifetimeMinutes`, construit le lien `{FrontendBaseUrl}/reset-password?token=...`, appelle `IEmailService`) ; `ResetPasswordAsync` (jeton valide/non expiré/non utilisé, remplace le mdp haché, marque `UsedAt`)
|
||||
- Enregistrer `IAuthService` -> `AuthService` (scoped) dans `Program.cs`
|
||||
- _Requirements: 1.1, 1.2, 2.1, 2.2, 6.1, 6.2, 6.4, 6.5, 6.6, 7.1, 7.2, 7.3, 7.4, 8.3, 9.1_
|
||||
|
||||
- [x]* 6.3 Écrire le test de propriété inscription crée le compte
|
||||
- **Property 5 : L'inscription crée le compte lorsqu'aucun compte n'existe**
|
||||
- **Validates: Requirements 1.1**
|
||||
|
||||
- [x]* 6.4 Écrire le test de propriété inscription rejetée si compte existant
|
||||
- **Property 6 : L'inscription est rejetée lorsqu'un compte existe déjà**
|
||||
- **Validates: Requirements 1.2**
|
||||
|
||||
- [x]* 6.5 Écrire le test de propriété mot de passe trop court à l'inscription
|
||||
- **Property 7 : Un mot de passe trop court est refusé à l'inscription**
|
||||
- **Validates: Requirements 1.3**
|
||||
|
||||
- [x]* 6.6 Écrire le test de propriété connexion (émission/rejet)
|
||||
- **Property 8 : La connexion émet un jeton sur identifiants corrects et rejette les identifiants invalides**
|
||||
- **Validates: Requirements 2.1, 2.2**
|
||||
|
||||
- [x]* 6.7 Écrire le test de propriété non-divulgation forgot-password
|
||||
- **Property 9 : Non-divulgation lors de la demande de réinitialisation**
|
||||
- **Validates: Requirements 6.6**
|
||||
|
||||
- [x]* 6.8 Écrire le test de propriété expiration du jeton de reset
|
||||
- **Property 10 : Expiration du jeton de reset paramétrée**
|
||||
- **Validates: Requirements 6.1, 6.2, 6.3**
|
||||
|
||||
- [x]* 6.9 Écrire le test de propriété réinitialisation valide remplace le mot de passe
|
||||
- **Property 11 : Une réinitialisation valide remplace le mot de passe**
|
||||
- **Validates: Requirements 7.1**
|
||||
|
||||
- [x]* 6.10 Écrire le test de propriété jeton de reset à usage unique
|
||||
- **Property 12 : Un jeton de reset est à usage unique**
|
||||
- **Validates: Requirements 7.2, 7.4**
|
||||
|
||||
- [x]* 6.11 Écrire le test de propriété jeton de reset expiré ou inconnu
|
||||
- **Property 13 : Un jeton de reset expiré ou inconnu est rejeté**
|
||||
- **Validates: Requirements 7.3, 7.4**
|
||||
|
||||
- [x]* 6.12 Écrire le test de propriété nouveau mot de passe trop court à la réinitialisation
|
||||
- **Property 14 : Un nouveau mot de passe trop court est refusé à la réinitialisation**
|
||||
- **Validates: Requirements 7.5**
|
||||
|
||||
- [x]* 6.13 Écrire les tests d'exemples de validation et d'exclusion du hash
|
||||
- Emails invalides (`"a@"`, `"abc"`, `"@x"`, chaîne vide) ⇒ 400 ; email valide accepté
|
||||
- Les corps de réponse d'auth ne contiennent aucun champ de hash
|
||||
- _Requirements: 1.4, 8.2_
|
||||
|
||||
- [x] 7. Backend — Controller d'authentification
|
||||
- [x] 7.1 Implémenter `AuthController` avec les 5 endpoints
|
||||
- Créer `Controllers/AuthController.cs` (`[ApiController]`, `[Route("api/auth")]`, `[AllowAnonymous]`)
|
||||
- `POST /register`, `POST /login`, `POST /forgot-password`, `POST /reset-password`, `GET /compte-existe`
|
||||
- Mapper les exceptions métier en codes HTTP via `ProblemDetails` : 409 (compte existant), 401 (identifiants génériques), 400 (mdp court / email invalide / lien invalide ou expiré), 200 générique (forgot-password)
|
||||
- _Requirements: 1.2, 1.3, 1.4, 2.1, 2.2, 6.6, 7.3, 7.4, 7.5, 9.1_
|
||||
|
||||
- [x] 8. Backend — Sécurité du pipeline
|
||||
- [x] 8.1 Configurer l'authentification JWT et l'autorisation globale
|
||||
- Ajouter `AddAuthentication(JwtBearer).AddJwtBearer(...)` avec `TokenValidationParameters` (issuer, audience, lifetime, signing key depuis `JwtOptions`, `ClockSkew` 30s)
|
||||
- Ajouter le filtre global `AuthorizeFilter(RequireAuthenticatedUser())` sur `AddControllers`
|
||||
- Insérer `app.UseAuthentication()` **avant** `app.UseAuthorization()`
|
||||
- Conserver `[AllowAnonymous]` sur `AuthController` et laisser `/health` public
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.2, 5.3_
|
||||
|
||||
- [x]* 8.2 Écrire le test d'intégration de protection des routes
|
||||
- Via `WebApplicationFactory<Program>` : routes de données (`/api/trajets`, `/api/profil`, `/api/vehicules`, `/api/bareme`, `/api/resumes`, exports PDF, recalcul) ⇒ 401 sans jeton, 2xx avec jeton valide
|
||||
- `/api/auth/*` et `/health` accessibles sans jeton
|
||||
- _Requirements: 4.1, 4.2, 4.4, 4.5_
|
||||
|
||||
- [x]* 8.3 Écrire le test d'intégration bout en bout
|
||||
- register → login → appel protégé avec le token ; puis forgot → reset → login avec le nouveau mot de passe
|
||||
- _Requirements: 1.1, 2.1, 7.1_
|
||||
|
||||
- [x] 9. Checkpoint backend
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 10. Frontend — Service, modèles, interceptor et garde
|
||||
- [x] 10.1 Créer les modèles TS et `AuthService` (signals + localStorage)
|
||||
- Créer les types `LoginDto`, `RegisterDto`, `LoginResult`, `ResetPasswordDto`
|
||||
- Créer `core/services/auth.service.ts` : signal `_token` initialisé depuis `localStorage`, `estAuthentifie` (computed), `login/register/forgotPassword/resetPassword/compteExiste/logout/getToken`, base `${environment.apiUrl}/auth`
|
||||
- _Requirements: 2.3, 3.1, 3.2, 9.1_
|
||||
|
||||
- [x] 10.2 Implémenter `authInterceptor` et l'enregistrer avant `timeoutInterceptor`
|
||||
- Créer `core/interceptors/auth.interceptor.ts` : ajoute `Authorization: Bearer` aux requêtes vers `environment.apiUrl` (hors endpoints publics d'auth et appels externes) ; sur 401, purge le token et redirige vers `/login`
|
||||
- Dans `app.config.ts` : `withInterceptors([authInterceptor, timeoutInterceptor])`
|
||||
- _Requirements: 4.7_
|
||||
|
||||
- [x] 10.3 Implémenter `authGuard`
|
||||
- Créer `core/guards/auth.guard.ts` (`CanActivateFn`) : si non authentifié -> `UrlTree` vers `/login`, sinon `true`
|
||||
- _Requirements: 4.6, 9.3_
|
||||
|
||||
- [x]* 10.4 Écrire les tests de service, interceptor et garde
|
||||
- `AuthService` stocke/supprime le token dans `localStorage` ; `authGuard` redirige un non-authentifié vers `/login` ; `authInterceptor` purge le token et redirige sur 401
|
||||
- _Requirements: 2.3, 3.1, 4.6, 4.7, 9.3_
|
||||
|
||||
- [x] 11. Frontend — Écrans standalone (signals)
|
||||
- [x] 11.1 Implémenter `LoginComponent` (`/login`)
|
||||
- Formulaire email + mot de passe ; sur succès, stocke le token puis navigue vers la cible protégée ; état async via signals
|
||||
- _Requirements: 2.1, 2.3_
|
||||
|
||||
- [x] 11.2 Implémenter `RegisterComponent` (`/register`, Etape 1)
|
||||
- Formulaire email + mot de passe ; sur succès redirige vers `/login` ; état async via signals
|
||||
- _Requirements: 1.6, 9.1, 9.2_
|
||||
|
||||
- [x] 11.3 Implémenter `ForgotPasswordComponent` (`/forgot-password`)
|
||||
- Saisie email ; affiche un message générique quel que soit le résultat ; état async via signals
|
||||
- _Requirements: 6.6_
|
||||
|
||||
- [x] 11.4 Implémenter `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` ; état async via signals
|
||||
- _Requirements: 7.6_
|
||||
|
||||
- [x]* 11.5 Écrire le test de lecture du token par `ResetPasswordComponent`
|
||||
- Vérifie la lecture du `token` depuis l'URL
|
||||
- _Requirements: 7.6_
|
||||
|
||||
- [x] 12. Frontend — Routes et onboarding en deux étapes
|
||||
- [x] 12.1 Mettre à jour `app.routes.ts`
|
||||
- Ajouter les routes publiques `/login`, `/register`, `/forgot-password`, `/reset-password` (sans garde)
|
||||
- Protéger les routes de données par `[authGuard, profilConfigureGuard]` et `/onboarding` (Etape 2) + `/profil` + `/vehicules` par `[authGuard]`
|
||||
- Conserver l'`OnboardingComponent` existant comme écran de l'Etape 2 (comportement inchangé)
|
||||
- _Requirements: 4.6, 9.3, 9.4, 9.5, 9.6_
|
||||
|
||||
- [x] 13. Configuration et déploiement
|
||||
- [x] 13.1 Mettre à jour `.env.example` et `docker-compose.yml`
|
||||
- `.env.example` : documenter `JWT_SECRET_KEY`, `JWT_EXPIRATION_MINUTES=720`, `AUTH_RESET_TOKEN_LIFETIME_MINUTES=60`, `AUTH_FRONTEND_BASE_URL`, `SMTP_HOST/PORT/USER/PASSWORD/FROM` (sans valeurs secrètes réelles)
|
||||
- `docker-compose.yml` (service `backend`) : mapper `JWT__SecretKey` (`:?` requis), `JWT__ExpirationMinutes`, `Auth__ResetTokenLifetimeMinutes`, `Auth__FrontendBaseUrl`, `Smtp__*`
|
||||
- _Requirements: 5.5, 6.3, 6.7_
|
||||
|
||||
- [x] 13.2 Vérifier les builds
|
||||
- Exécuter `dotnet build` sur le backend et corriger les erreurs de compilation
|
||||
- Exécuter `ng build` sur le frontend `schotziad-km-tracker` et corriger les erreurs de compilation
|
||||
- _Requirements: 4.4, 9.6_
|
||||
|
||||
- [x] 14. Checkpoint final
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Les tâches marquées `*` sont des tests optionnels et peuvent être ignorées pour un MVP plus rapide ; elles ne sont pas implémentées automatiquement.
|
||||
- Chaque tâche référence des exigences précises pour la traçabilité, et chaque test de propriété référence sa propriété de conception (P1–P14).
|
||||
- Les tests de propriétés (FsCheck.Xunit, ≥ 100 itérations) valident les propriétés universelles ; les tests d'exemples et d'intégration (xUnit + WebApplicationFactory) couvrent la validation d'entrée, la sélection DI et la protection des routes.
|
||||
- Les checkpoints assurent une validation incrémentale (backend d'abord, puis l'ensemble).
|
||||
- Le séquençage respecte le contexte projet : entités → migration EF appliquée au démarrage → options → services → controller → sécurité pipeline (ordre `UseAuthentication` avant `UseAuthorization`), puis frontend zoneless (signals) et déploiement Docker.
|
||||
|
||||
## Task Dependency Graph
|
||||
|
||||
```json
|
||||
{
|
||||
"waves": [
|
||||
{ "id": 0, "tasks": ["1.1", "2.1", "10.1"] },
|
||||
{ "id": 1, "tasks": ["1.2", "2.2", "3.1", "5.1", "10.2", "10.3", "11.1", "11.2", "11.3", "11.4"] },
|
||||
{ "id": 2, "tasks": ["1.3", "3.2", "4.1", "5.2", "6.1", "10.4", "11.5", "12.1"] },
|
||||
{ "id": 3, "tasks": ["4.2", "4.3", "4.4", "5.3", "6.2", "13.1"] },
|
||||
{ "id": 4, "tasks": ["6.3", "6.4", "6.5", "6.6", "6.7", "6.8", "6.9", "6.10", "6.11", "6.12", "6.13", "7.1"] },
|
||||
{ "id": 5, "tasks": ["8.1"] },
|
||||
{ "id": 6, "tasks": ["8.2", "8.3", "13.2"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,654 @@
|
||||
{
|
||||
"pbtResults": {},
|
||||
"executionHistory": {
|
||||
"1.1 Créer les entités `Compte` et `ResetPasswordToken`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367186782
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367202818
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367320120
|
||||
}
|
||||
],
|
||||
"1.2 Déclarer les DbSet et la configuration `OnModelCreating`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367186971
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367333400
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367482585
|
||||
}
|
||||
],
|
||||
"1.3 Générer la migration EF `AjoutAuthentification`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187066
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368256169
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368286823
|
||||
}
|
||||
],
|
||||
"2.1 Créer les classes d'options et les lier à la configuration": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187157
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367204303
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367321104
|
||||
}
|
||||
],
|
||||
"3.1 Intégrer `PasswordHasher<Compte>` en DI": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187246
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367496182
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367655104
|
||||
}
|
||||
],
|
||||
"4.1 Implémenter `IJwtService` / `JwtService`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187341
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367677548
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367800474
|
||||
}
|
||||
],
|
||||
"5.1 Implémenter `IEmailService` et ses deux implémentations": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187432
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367334259
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367483557
|
||||
}
|
||||
],
|
||||
"5.2 Câbler la sélection DI selon `Smtp__Host`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187526
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367817812
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367870446
|
||||
}
|
||||
],
|
||||
"6.1 Créer les DTOs d'authentification et les validateurs FluentValidation": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187612
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367678436
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367801818
|
||||
}
|
||||
],
|
||||
"6.2 Implémenter `IAuthService` / `AuthService`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187704
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367871329
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368076060
|
||||
}
|
||||
],
|
||||
"7.1 Implémenter `AuthController` avec les 5 endpoints": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187799
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368077733
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368159447
|
||||
}
|
||||
],
|
||||
"8.1 Configurer l'authentification JWT et l'autorisation globale": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367187890
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368160204
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368254979
|
||||
}
|
||||
],
|
||||
"9. Checkpoint backend": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188045
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369554684
|
||||
}
|
||||
],
|
||||
"10.1 Créer les modèles TS et `AuthService` (signals + localStorage)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188156
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367205129
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367322447
|
||||
}
|
||||
],
|
||||
"10.2 Implémenter `authInterceptor` et l'enregistrer avant `timeoutInterceptor`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188248
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367335669
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367484979
|
||||
}
|
||||
],
|
||||
"10.3 Implémenter `authGuard`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188340
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367337185
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367486334
|
||||
}
|
||||
],
|
||||
"11.1 Implémenter `LoginComponent` (`/login`)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188465
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367337987
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367487169
|
||||
}
|
||||
],
|
||||
"11.2 Implémenter `RegisterComponent` (`/register`, Etape 1)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188550
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367498335
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367651865
|
||||
}
|
||||
],
|
||||
"11.3 Implémenter `ForgotPasswordComponent` (`/forgot-password`)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188651
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367498477
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367652784
|
||||
}
|
||||
],
|
||||
"11.4 Implémenter `ResetPasswordComponent` (`/reset-password`)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188757
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367499473
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367654113
|
||||
}
|
||||
],
|
||||
"12.1 Mettre à jour `app.routes.ts`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188847
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367679881
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367802793
|
||||
}
|
||||
],
|
||||
"13.1 Mettre à jour `.env.example` et `docker-compose.yml`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367188965
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367680868
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367803900
|
||||
}
|
||||
],
|
||||
"13.2 Vérifier les builds": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367189105
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368287520
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368322862
|
||||
}
|
||||
],
|
||||
"14. Checkpoint final": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367189198
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369555493
|
||||
}
|
||||
],
|
||||
"2. Backend — Options de configuration": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367321242
|
||||
}
|
||||
],
|
||||
"10. Frontend — Service, modèles, interceptor et garde": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367486494
|
||||
}
|
||||
],
|
||||
"11. Frontend — Écrans standalone (signals)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367654216
|
||||
}
|
||||
],
|
||||
"3. Backend — Hachage des mots de passe": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367655230
|
||||
}
|
||||
],
|
||||
"4. Backend — Service JWT": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367800614
|
||||
}
|
||||
],
|
||||
"12. Frontend — Routes et onboarding en deux étapes": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367802947
|
||||
}
|
||||
],
|
||||
"5. Backend — Service email": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789367870646
|
||||
}
|
||||
],
|
||||
"6. Backend — DTOs, validateurs et service d'authentification": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368076248
|
||||
}
|
||||
],
|
||||
"7. Backend — Controller d'authentification": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368159632
|
||||
}
|
||||
],
|
||||
"8. Backend — Sécurité du pipeline": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368255118
|
||||
}
|
||||
],
|
||||
"1. Backend — Modèle de données et persistance": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368286988
|
||||
}
|
||||
],
|
||||
"13. Configuration et déploiement": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368322956
|
||||
}
|
||||
],
|
||||
"3.2 Écrire le test de propriété du hachage": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368508497
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369503520
|
||||
}
|
||||
],
|
||||
"6.3 Écrire le test de propriété inscription crée le compte": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368509798
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369504375
|
||||
}
|
||||
],
|
||||
"8.2 Écrire le test d'intégration de protection des routes": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368511227
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369505830
|
||||
}
|
||||
],
|
||||
"10.4 Écrire les tests de service, interceptor et garde": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789368512087
|
||||
},
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369506703
|
||||
}
|
||||
],
|
||||
"2.2 Écrire un test de liaison des options": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369516660
|
||||
}
|
||||
],
|
||||
"4.2 Écrire le test de propriété émission/validation et claims": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369517664
|
||||
}
|
||||
],
|
||||
"4.3 Écrire le test de propriété jeton expiré": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369519001
|
||||
}
|
||||
],
|
||||
"4.4 Écrire le test de propriété signature invalide": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369519964
|
||||
}
|
||||
],
|
||||
"5.3 Écrire le test de sélection DI de `IEmailService`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369521121
|
||||
}
|
||||
],
|
||||
"6.4 Écrire le test de propriété inscription rejetée si compte existant": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369525805
|
||||
}
|
||||
],
|
||||
"6.5 Écrire le test de propriété mot de passe trop court à l'inscription": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369527195
|
||||
}
|
||||
],
|
||||
"6.6 Écrire le test de propriété connexion (émission/rejet)": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369528183
|
||||
}
|
||||
],
|
||||
"6.7 Écrire le test de propriété non-divulgation forgot-password": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369529620
|
||||
}
|
||||
],
|
||||
"6.8 Écrire le test de propriété expiration du jeton de reset": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369530512
|
||||
}
|
||||
],
|
||||
"6.9 Écrire le test de propriété réinitialisation valide remplace le mot de passe": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369535219
|
||||
}
|
||||
],
|
||||
"6.10 Écrire le test de propriété jeton de reset à usage unique": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369536677
|
||||
}
|
||||
],
|
||||
"6.11 Écrire le test de propriété jeton de reset expiré ou inconnu": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369537543
|
||||
}
|
||||
],
|
||||
"6.12 Écrire le test de propriété nouveau mot de passe trop court à la réinitialisation": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369538945
|
||||
}
|
||||
],
|
||||
"6.13 Écrire les tests d'exemples de validation et d'exclusion du hash": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369540419
|
||||
}
|
||||
],
|
||||
"8.3 Écrire le test d'intégration bout en bout": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369541873
|
||||
}
|
||||
],
|
||||
"11.5 Écrire le test de lecture du token par `ResetPasswordComponent`": [
|
||||
{
|
||||
"executionId": "dc2d2428-de30-4d42-8076-ba61cb79b626",
|
||||
"chatSessionId": "sess_20c9bee5-b332-43f8-922c-20b9225bb8d8",
|
||||
"timestamp": 1789369543071
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
/// <summary>Options de configuration de l'authentification (réinitialisation de mot de passe).</summary>
|
||||
public class AuthOptions
|
||||
{
|
||||
public const string SectionName = "Auth";
|
||||
|
||||
/// <summary>Durée de validité d'un jeton de réinitialisation en minutes.</summary>
|
||||
public int ResetTokenLifetimeMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>URL de base du frontend, utilisée pour construire le lien de réinitialisation.</summary>
|
||||
public string FrontendBaseUrl { get; set; } = "http://localhost:8080";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
/// <summary>Options de configuration des jetons JWT d'authentification.</summary>
|
||||
public class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Jwt";
|
||||
|
||||
/// <summary>Clé secrète utilisée pour signer les jetons (HS256).</summary>
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Émetteur (issuer) des jetons.</summary>
|
||||
public string Issuer { get; set; } = "SchotzIadKmTracker";
|
||||
|
||||
/// <summary>Audience attendue des jetons.</summary>
|
||||
public string Audience { get; set; } = "SchotzIadKmTracker";
|
||||
|
||||
/// <summary>Durée de validité d'un jeton en minutes (720 = 12 h).</summary>
|
||||
public int ExpirationMinutes { get; set; } = 720;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
/// <summary>Options de configuration du serveur SMTP pour l'envoi d'emails.</summary>
|
||||
public class SmtpOptions
|
||||
{
|
||||
public const string SectionName = "Smtp";
|
||||
|
||||
/// <summary>Hôte du serveur SMTP (vide = envoi désactivé, repli sur la journalisation).</summary>
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Port du serveur SMTP.</summary>
|
||||
public int Port { get; set; } = 587;
|
||||
|
||||
/// <summary>Nom d'utilisateur pour l'authentification SMTP.</summary>
|
||||
public string User { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Mot de passe pour l'authentification SMTP.</summary>
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Adresse email expéditrice.</summary>
|
||||
public string From { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints publics d'authentification de l'application mono-utilisateur :
|
||||
/// inscription, connexion, demande et application de la réinitialisation du
|
||||
/// mot de passe, ainsi que la vérification de l'existence du compte.
|
||||
/// Toutes les routes sont anonymes (<see cref="AllowAnonymousAttribute"/>) : elles
|
||||
/// restent accessibles même une fois l'autorisation globale activée (Exigence 9.1).
|
||||
/// Les erreurs sont renvoyées au format <c>ProblemDetails</c>, comme les autres controllers.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inscrit le compte unique de l'application. Les erreurs de validation
|
||||
/// (email invalide, mot de passe trop court) sont renvoyées automatiquement
|
||||
/// en 400 par FluentValidation (Exigences 1.3, 1.4). Un compte déjà existant
|
||||
/// est rejeté en 409 (Exigence 1.2).
|
||||
/// </summary>
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Register(
|
||||
[FromBody] RegisterDto dto,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _authService.RegisterAsync(dto, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
catch (CompteDejaExistantException ex)
|
||||
{
|
||||
return Problem(detail: ex.Message, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentifie le compte et retourne le jeton JWT accompagné de sa date
|
||||
/// d'expiration. En cas d'identifiants invalides, renvoie un 401 avec un
|
||||
/// message générique afin de ne pas distinguer email inconnu et mot de passe
|
||||
/// erroné (Exigences 2.1, 2.2).
|
||||
/// </summary>
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType(typeof(LoginResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<LoginResultDto>> Login(
|
||||
[FromBody] LoginDto dto,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resultat = await _authService.LoginAsync(dto, cancellationToken);
|
||||
return Ok(resultat);
|
||||
}
|
||||
catch (IdentifiantsInvalidesException)
|
||||
{
|
||||
return Problem(detail: "Identifiants invalides.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traite une demande de réinitialisation de mot de passe. Par principe de
|
||||
/// non-divulgation, renvoie toujours 200 avec la même réponse, que l'email
|
||||
/// corresponde ou non à un compte (Exigence 6.6). La logique de non-divulgation
|
||||
/// est portée par le service ; les erreurs techniques réelles remontent.
|
||||
/// </summary>
|
||||
[HttpPost("forgot-password")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ForgotPassword(
|
||||
[FromBody] ForgotPasswordDto dto,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.ForgotPasswordAsync(dto, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Réinitialise le mot de passe à partir d'un jeton de réinitialisation.
|
||||
/// Un jeton expiré (Exigence 7.3) ou invalide/déjà utilisé (Exigence 7.4)
|
||||
/// est rejeté en 400. Les erreurs de validation du nouveau mot de passe sont
|
||||
/// renvoyées automatiquement en 400 par FluentValidation (Exigence 7.5).
|
||||
/// </summary>
|
||||
[HttpPost("reset-password")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
[FromBody] ResetPasswordDto dto,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _authService.ResetPasswordAsync(dto, cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
catch (LienResetExpireException)
|
||||
{
|
||||
return Problem(detail: "Le lien de réinitialisation a expiré.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (LienResetInvalideException)
|
||||
{
|
||||
return Problem(detail: "Le lien de réinitialisation est invalide.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indique si un compte existe déjà, afin d'orienter le frontend entre
|
||||
/// inscription et connexion (Exigences 1.2, 9.1).
|
||||
/// </summary>
|
||||
[HttpGet("compte-existe")]
|
||||
[ProducesResponseType(typeof(CompteExisteDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CompteExisteDto>> CompteExiste(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(new CompteExisteDto(await _authService.CompteExisteAsync(cancellationToken)));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<AdresseNommee> AdressesNommees => Set<AdresseNommee>();
|
||||
public DbSet<TrancheBareme> TranchesBareme => Set<TrancheBareme>();
|
||||
public DbSet<MontantRegle> MontantsRegles => Set<MontantRegle>();
|
||||
public DbSet<Compte> Comptes => Set<Compte>();
|
||||
public DbSet<ResetPasswordToken> ResetPasswordTokens => Set<ResetPasswordToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -110,5 +112,22 @@ public class AppDbContext : DbContext
|
||||
.HasForeignKey(m => m.VehiculeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// --- Comptes (singleton Id = 1) ---
|
||||
modelBuilder.Entity<Compte>(entity =>
|
||||
{
|
||||
entity.ToTable("Comptes");
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.Property(c => c.Id).ValueGeneratedNever();
|
||||
entity.HasIndex(c => c.Email, "UX_Comptes_Email").IsUnique();
|
||||
});
|
||||
|
||||
// --- ResetPasswordTokens (jetons de réinitialisation à usage unique) ---
|
||||
modelBuilder.Entity<ResetPasswordToken>(entity =>
|
||||
{
|
||||
entity.ToTable("ResetPasswordTokens");
|
||||
entity.HasIndex(t => t.TokenHash, "UX_ResetTokens_TokenHash").IsUnique();
|
||||
entity.HasIndex(t => t.Expiration, "IX_ResetTokens_Expiration");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+390
@@ -0,0 +1,390 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260914064431_AjoutAuthentification")]
|
||||
partial class AjoutAuthentification
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseCache", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DerniereUtilisation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Adresse")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdresseCache", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseNommee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Adresse")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DenominationNormalisee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("GeocodageReussi")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MotDePasseHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Comptes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Mois")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Montant")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VehiculeId");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MontantsRegles", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
|
||||
|
||||
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Profil", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseDomicile")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("DomicileGeocode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("DomicileLatitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<double?>("DomicileLongitude")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Matricule")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prenom")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Expiration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
|
||||
|
||||
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ResetPasswordTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AdresseArrivee")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AdresseDepart")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Commentaire")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Distance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GeometrieJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Position")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VehiculeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
|
||||
|
||||
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
|
||||
|
||||
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
|
||||
|
||||
b.ToTable("Trajets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
|
||||
|
||||
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.TrancheBareme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Annee")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Base")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Coefficient")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("KmMax")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("KmMin")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
|
||||
|
||||
b.ToTable("TranchesBareme", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("DateEffet")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Denomination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Plaque")
|
||||
.IsRequired()
|
||||
.HasMaxLength(15)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PuissanceFiscale")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
|
||||
|
||||
b.ToTable("Vehicules", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany()
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
|
||||
.WithMany("Trajets")
|
||||
.HasForeignKey("VehiculeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Vehicule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
|
||||
{
|
||||
b.Navigation("Trajets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AjoutAuthentification : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Comptes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
|
||||
MotDePasseHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Comptes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ResetPasswordTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TokenHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Expiration = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
UsedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ResetPasswordTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Comptes_Email",
|
||||
table: "Comptes",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ResetTokens_Expiration",
|
||||
table: "ResetPasswordTokens",
|
||||
column: "Expiration");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ResetTokens_TokenHash",
|
||||
table: "ResetPasswordTokens",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Comptes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ResetPasswordTokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,34 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
|
||||
b.ToTable("AdressesNommees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MotDePasseHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Comptes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -169,6 +197,35 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
|
||||
b.ToTable("Profil", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Expiration")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
|
||||
|
||||
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ResetPasswordTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
// DTOs d'authentification. Aucun n'expose le hash du mot de passe (Requirement 8.2).
|
||||
|
||||
// Inscription du compte unique (Requirement 1).
|
||||
public record RegisterDto(string Email, string MotDePasse);
|
||||
|
||||
// Connexion (Requirement 2).
|
||||
public record LoginDto(string Email, string MotDePasse);
|
||||
|
||||
// Réponse de connexion : jeton JWT et date d'expiration (Requirement 2.1, 2.4).
|
||||
public record LoginResultDto(string Token, DateTime Expiration);
|
||||
|
||||
// Demande de réinitialisation de mot de passe (Requirement 6).
|
||||
public record ForgotPasswordDto(string Email);
|
||||
|
||||
// Réinitialisation via jeton de reset (Requirement 7).
|
||||
public record ResetPasswordDto(string Token, string NouveauMotDePasse);
|
||||
|
||||
// Indique si un compte existe déjà, pour orienter le frontend (Requirement 1.2, 9.1).
|
||||
public record CompteExisteDto(bool Existe);
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Compte unique de l'application mono-utilisateur (singleton, comme le Profil).</summary>
|
||||
public class Compte
|
||||
{
|
||||
/// <summary>Identifiant du compte. Valeur fixe (1) non générée : le compte est un singleton.</summary>
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
/// <summary>Adresse email du compte, unique, utilisée comme identifiant de connexion (1–255 caractères).</summary>
|
||||
[Required, MaxLength(255)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Hachage salé du mot de passe (PBKDF2 via <c>PasswordHasher</c>). Jamais exposé ni journalisé (Exigence 8.2).</summary>
|
||||
[Required]
|
||||
public string MotDePasseHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Date de création du compte (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Date de dernière modification du compte (UTC), mise à jour notamment lors d'une réinitialisation du mot de passe.</summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
/// <summary>Jeton de réinitialisation de mot de passe à usage unique et à durée de vie limitée.</summary>
|
||||
public class ResetPasswordToken
|
||||
{
|
||||
/// <summary>Identifiant technique du jeton.</summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Hachage SHA-256 du jeton. Le jeton en clair n'est jamais persisté : seul celui transmis par email permet la réinitialisation.</summary>
|
||||
[Required]
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Date d'expiration du jeton (UTC). Au-delà, le jeton est rejeté (Exigence 6.2, défaut création + 60 min).</summary>
|
||||
public DateTime Expiration { get; set; }
|
||||
|
||||
/// <summary>Date d'utilisation du jeton (UTC). <c>null</c> tant que le jeton n'a pas servi ; renseignée à l'usage pour garantir l'usage unique (Exigence 7.2).</summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>Date de création du jeton (UTC).</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using System.Text;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Validators;
|
||||
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
@@ -20,6 +25,12 @@ builder.Services.Configure<BANOptions>(
|
||||
builder.Configuration.GetSection(BANOptions.SectionName));
|
||||
builder.Services.Configure<ORSOptions>(
|
||||
builder.Configuration.GetSection(ORSOptions.SectionName));
|
||||
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));
|
||||
|
||||
// --- Client typé BAN (Base Adresses Nationale) ---
|
||||
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
|
||||
@@ -70,7 +81,15 @@ builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
||||
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
||||
|
||||
// --- Services de base ---
|
||||
builder.Services.AddControllers();
|
||||
// Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié.
|
||||
// AuthController porte [AllowAnonymous] ; /health (endpoint minimal) n'est pas couvert par les filtres MVC.
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
var policy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
options.Filters.Add(new Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter(policy));
|
||||
});
|
||||
|
||||
// --- Health checks (endpoint GET /health, utilisé par Docker Compose) ---
|
||||
builder.Services.AddHealthChecks();
|
||||
@@ -87,7 +106,39 @@ var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseSqlite(connectionString));
|
||||
|
||||
// --- Hachage des mots de passe (ASP.NET Core Identity PasswordHasher) ---
|
||||
builder.Services.AddSingleton<IPasswordHasher<Compte>, PasswordHasher<Compte>>();
|
||||
|
||||
// --- Repositories & services métier ---
|
||||
builder.Services.AddScoped<IJwtService, JwtService>();
|
||||
|
||||
// --- 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)
|
||||
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
// --- Authentification JWT Bearer ---
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<SchotzIadKmTracker.Api.Repositories.IVehiculeRepository,
|
||||
SchotzIadKmTracker.Api.Repositories.VehiculeRepository>();
|
||||
builder.Services.AddScoped<IVehiculeService, VehiculeService>();
|
||||
@@ -152,6 +203,8 @@ app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors(CorsPolicyName);
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="MailKit" Version="4.18.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -17,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="QuestPDF" Version="2024.10.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly IPasswordHasher<Compte> _hasher;
|
||||
private readonly IJwtService _jwt;
|
||||
private readonly IEmailService _email;
|
||||
private readonly AuthOptions _authOptions;
|
||||
private readonly JwtOptions _jwtOptions;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
public AuthService(
|
||||
AppDbContext context,
|
||||
IPasswordHasher<Compte> hasher,
|
||||
IJwtService jwt,
|
||||
IEmailService email,
|
||||
IOptions<AuthOptions> authOptions,
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
ILogger<AuthService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_hasher = hasher;
|
||||
_jwt = jwt;
|
||||
_email = email;
|
||||
_authOptions = authOptions.Value;
|
||||
_jwtOptions = jwtOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> CompteExisteAsync(CancellationToken ct)
|
||||
{
|
||||
// Vrai s'il existe au moins un compte (Exigences 1.2, 9.1).
|
||||
return await _context.Comptes.AnyAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RegisterAsync(RegisterDto dto, CancellationToken ct)
|
||||
{
|
||||
// L'application est mono-utilisateur : refus si un compte existe déjà (Exigence 1.2).
|
||||
if (await _context.Comptes.AnyAsync(ct))
|
||||
{
|
||||
throw new CompteDejaExistantException("Un compte existe déjà.");
|
||||
}
|
||||
|
||||
// La longueur >= 8 et la validité de l'email sont garanties par FluentValidation en amont.
|
||||
var compte = new Compte
|
||||
{
|
||||
Id = 1, // singleton
|
||||
Email = dto.Email.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Hachage salé du mot de passe (PBKDF2 via PasswordHasher). Le clair n'est jamais persisté (Exigence 8).
|
||||
compte.MotDePasseHash = _hasher.HashPassword(compte, dto.MotDePasse);
|
||||
|
||||
_context.Comptes.Add(compte);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LoginResultDto> LoginAsync(LoginDto dto, CancellationToken ct)
|
||||
{
|
||||
var email = dto.Email.Trim();
|
||||
|
||||
// Récupération du compte par email (comparaison insensible à la casse).
|
||||
var compte = await _context.Comptes
|
||||
.FirstOrDefaultAsync(c => c.Email.ToLower() == email.ToLower(), ct);
|
||||
|
||||
// Message générique : ne distingue pas email inconnu et mot de passe erroné (Exigence 2.2).
|
||||
if (compte is null)
|
||||
{
|
||||
throw new IdentifiantsInvalidesException("Identifiants invalides.");
|
||||
}
|
||||
|
||||
var resultat = _hasher.VerifyHashedPassword(compte, compte.MotDePasseHash, dto.MotDePasse);
|
||||
if (resultat == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new IdentifiantsInvalidesException("Identifiants invalides.");
|
||||
}
|
||||
|
||||
// Émission du jeton JWT et de sa date d'expiration (Exigences 2.1, 2.4).
|
||||
var token = _jwt.GenererJeton(compte);
|
||||
var expiration = DateTime.UtcNow.AddMinutes(_jwtOptions.ExpirationMinutes);
|
||||
|
||||
return new LoginResultDto(token, expiration);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ForgotPasswordAsync(ForgotPasswordDto dto, CancellationToken ct)
|
||||
{
|
||||
var email = dto.Email.Trim();
|
||||
|
||||
// Principe de non-divulgation (Exigence 6.6) : on ne révèle jamais si l'email correspond à un compte.
|
||||
// La méthode se termine toujours sans exception ; le controller renvoie une réponse générique 200.
|
||||
var compte = await _context.Comptes
|
||||
.FirstOrDefaultAsync(c => c.Email.ToLower() == email.ToLower(), ct);
|
||||
|
||||
if (compte is null)
|
||||
{
|
||||
// Email inconnu : aucune action, aucun jeton créé.
|
||||
return;
|
||||
}
|
||||
|
||||
// Génération d'un jeton aléatoire sûr (32 octets), transmis EN CLAIR dans le lien.
|
||||
var tokenClair = GenererTokenAleatoire();
|
||||
|
||||
// Seul le hachage SHA-256 du jeton est persisté (Exigence 6 / 7).
|
||||
var resetToken = new ResetPasswordToken
|
||||
{
|
||||
TokenHash = HacherToken(tokenClair),
|
||||
Expiration = DateTime.UtcNow.AddMinutes(_authOptions.ResetTokenLifetimeMinutes),
|
||||
UsedAt = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_context.ResetPasswordTokens.Add(resetToken);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
|
||||
// Construction du lien de réinitialisation (token en clair) et envoi par email.
|
||||
var lien = $"{_authOptions.FrontendBaseUrl}/reset-password?token={tokenClair}";
|
||||
await _email.EnvoyerLienResetAsync(compte.Email, lien, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct)
|
||||
{
|
||||
// Recherche du jeton par son hachage SHA-256 (le clair n'est jamais persisté).
|
||||
var tokenHash = HacherToken(dto.Token);
|
||||
|
||||
var resetToken = await _context.ResetPasswordTokens
|
||||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash, ct);
|
||||
|
||||
// Jeton inconnu ou déjà utilisé : invalide (usage unique, Exigences 7.2, 7.4).
|
||||
if (resetToken is null || resetToken.UsedAt is not null)
|
||||
{
|
||||
throw new LienResetInvalideException("Le lien de réinitialisation est invalide.");
|
||||
}
|
||||
|
||||
// Jeton expiré (Exigence 7.3).
|
||||
if (resetToken.Expiration < DateTime.UtcNow)
|
||||
{
|
||||
throw new LienResetExpireException("Le lien de réinitialisation a expiré.");
|
||||
}
|
||||
|
||||
var compte = await _context.Comptes.FirstOrDefaultAsync(ct);
|
||||
if (compte is null)
|
||||
{
|
||||
// Cas théorique : jeton présent mais aucun compte. On traite comme un lien invalide.
|
||||
throw new LienResetInvalideException("Le lien de réinitialisation est invalide.");
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe et invalidation du jeton (usage unique), en transaction
|
||||
// car plusieurs écritures doivent aboutir de façon atomique.
|
||||
var maintenant = DateTime.UtcNow;
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync(ct);
|
||||
try
|
||||
{
|
||||
// La longueur >= 8 du nouveau mot de passe est garantie par FluentValidation en amont.
|
||||
compte.MotDePasseHash = _hasher.HashPassword(compte, dto.NouveauMotDePasse);
|
||||
compte.UpdatedAt = maintenant;
|
||||
|
||||
resetToken.UsedAt = maintenant;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Génère un jeton aléatoire cryptographiquement sûr (32 octets) encodé en base64url.
|
||||
/// </summary>
|
||||
private static string GenererTokenAleatoire()
|
||||
{
|
||||
var octets = RandomNumberGenerator.GetBytes(32);
|
||||
return Base64UrlEncode(octets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calcule le hachage SHA-256 d'un jeton et le retourne en base64.
|
||||
/// </summary>
|
||||
private static string HacherToken(string token)
|
||||
{
|
||||
var octets = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToBase64String(octets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode des octets en base64url (sans remplissage), adapté à une transmission en query string.
|
||||
/// </summary>
|
||||
private static string Base64UrlEncode(byte[] octets)
|
||||
{
|
||||
return Convert.ToBase64String(octets)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'une tentative d'inscription est effectuée alors qu'un compte existe déjà.
|
||||
/// L'application étant mono-utilisateur, un seul compte peut être créé.
|
||||
/// Convertie en réponse HTTP 409 (Conflict) par le controller (Exigence 1.2).
|
||||
/// </summary>
|
||||
public class CompteDejaExistantException : Exception
|
||||
{
|
||||
public CompteDejaExistantException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public CompteDejaExistantException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsque les identifiants de connexion sont invalides (email inconnu ou mot de passe erroné).
|
||||
/// Le message est volontairement générique afin de ne pas distinguer les deux cas (Exigence 2.2).
|
||||
/// Convertie en réponse HTTP 401 (Unauthorized) par le controller.
|
||||
/// </summary>
|
||||
public class IdentifiantsInvalidesException : Exception
|
||||
{
|
||||
public IdentifiantsInvalidesException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public IdentifiantsInvalidesException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un jeton de réinitialisation a expiré (date d'expiration dépassée).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) par le controller (Exigence 7.3).
|
||||
/// </summary>
|
||||
public class LienResetExpireException : Exception
|
||||
{
|
||||
public LienResetExpireException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public LienResetExpireException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Levée lorsqu'un jeton de réinitialisation est inconnu ou a déjà été utilisé (usage unique).
|
||||
/// Convertie en réponse HTTP 400 (Bad Request) par le controller (Exigences 7.2, 7.4).
|
||||
/// </summary>
|
||||
public class LienResetInvalideException : Exception
|
||||
{
|
||||
public LienResetInvalideException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public LienResetInvalideException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Logique métier d'authentification de l'application mono-utilisateur :
|
||||
/// existence du compte, inscription, connexion, demande et application de la
|
||||
/// réinitialisation du mot de passe.
|
||||
/// </summary>
|
||||
public interface IAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Indique si un compte existe déjà, afin d'orienter le frontend (inscription vs connexion).
|
||||
/// </summary>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
/// <returns><c>true</c> s'il existe au moins un compte, <c>false</c> sinon (Exigences 1.2, 9.1).</returns>
|
||||
Task<bool> CompteExisteAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Inscrit le compte unique. Lève <see cref="Exceptions.CompteDejaExistantException"/>
|
||||
/// si un compte existe déjà (Exigence 1).
|
||||
/// </summary>
|
||||
/// <param name="dto">Email et mot de passe fournis à l'inscription.</param>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
Task RegisterAsync(RegisterDto dto, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Authentifie le compte et émet un jeton JWT. Lève
|
||||
/// <see cref="Exceptions.IdentifiantsInvalidesException"/> si les identifiants sont invalides (Exigence 2).
|
||||
/// </summary>
|
||||
/// <param name="dto">Identifiants de connexion (email et mot de passe).</param>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
/// <returns>Le jeton JWT émis et sa date d'expiration.</returns>
|
||||
Task<LoginResultDto> LoginAsync(LoginDto dto, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Traite une demande de réinitialisation de mot de passe. Par principe de non-divulgation,
|
||||
/// se termine toujours sans exception, que l'email corresponde ou non à un compte (Exigence 6.6).
|
||||
/// </summary>
|
||||
/// <param name="dto">Email pour lequel demander la réinitialisation.</param>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
Task ForgotPasswordAsync(ForgotPasswordDto dto, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Réinitialise le mot de passe à partir d'un jeton de réinitialisation. Lève
|
||||
/// <see cref="Exceptions.LienResetInvalideException"/> si le jeton est inconnu ou déjà utilisé,
|
||||
/// <see cref="Exceptions.LienResetExpireException"/> s'il a expiré (Exigence 7).
|
||||
/// </summary>
|
||||
/// <param name="dto">Jeton de réinitialisation et nouveau mot de passe.</param>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service d'envoi d'emails de l'application. L'implémentation concrète
|
||||
/// (SMTP via MailKit ou repli par journalisation) est sélectionnée en DI
|
||||
/// dans <c>Program.cs</c> selon la présence d'une configuration SMTP.
|
||||
/// Le service appelant ignore l'implémentation retenue (transparence).
|
||||
/// </summary>
|
||||
public interface IEmailService
|
||||
{
|
||||
/// <summary>
|
||||
/// Envoie au destinataire le lien de réinitialisation de mot de passe.
|
||||
/// </summary>
|
||||
/// <param name="destinataire">Adresse email du destinataire.</param>
|
||||
/// <param name="lienReset">Lien de réinitialisation à transmettre au destinataire.</param>
|
||||
/// <param name="ct">Jeton d'annulation de l'opération.</param>
|
||||
Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service d'émission des jetons JWT d'authentification.
|
||||
/// </summary>
|
||||
public interface IJwtService
|
||||
{
|
||||
/// <summary>
|
||||
/// Émet un Jeton_JWT signé en HS256 pour le compte fourni.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le jeton porte les claims <c>sub</c> (identifiant du compte), <c>email</c> et un
|
||||
/// identifiant unique <c>jti</c>. Sa date d'expiration vaut l'instant courant plus
|
||||
/// <c>JwtOptions.ExpirationMinutes</c> (720 minutes / 12 h par défaut). Aucune donnée
|
||||
/// sensible n'est incluse (jamais le hachage du mot de passe).
|
||||
/// Exigences 2.1 (émission du jeton à la connexion), 2.4 (date d'expiration associée),
|
||||
/// 5.4 (expiration à 12 h par défaut).
|
||||
/// </remarks>
|
||||
/// <param name="compte">Le compte pour lequel émettre le jeton.</param>
|
||||
/// <returns>Le Jeton_JWT sérialisé (chaîne compacte).</returns>
|
||||
string GenererJeton(Compte compte);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation de <see cref="IJwtService"/> émettant des jetons JWT signés en HMAC-SHA256
|
||||
/// à partir des options <see cref="JwtOptions"/>.
|
||||
/// </summary>
|
||||
public class JwtService : IJwtService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initialise le service à partir des options JWT liées à la configuration.
|
||||
/// </summary>
|
||||
/// <param name="options">Options JWT (clé secrète, émetteur, audience, durée d'expiration).</param>
|
||||
public JwtService(IOptions<JwtOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenererJeton(Compte compte)
|
||||
{
|
||||
// Claims émis : sub = identifiant du compte, email = adresse du compte,
|
||||
// jti = identifiant unique du jeton. Aucune donnée sensible (jamais le hash du mot de passe).
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, compte.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, compte.Email),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
// Signature symétrique HMAC-SHA256 à partir de la clé secrète configurée.
|
||||
var cleSignature = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
|
||||
var identifiants = new SigningCredentials(cleSignature, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
// Expiration = instant courant + durée configurée (720 min / 12 h par défaut).
|
||||
var expiration = DateTime.UtcNow.AddMinutes(_options.ExpirationMinutes);
|
||||
|
||||
var jeton = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiration,
|
||||
signingCredentials: identifiants);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(jeton);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation de repli de <see cref="IEmailService"/> qui n'envoie aucun
|
||||
/// email mais journalise le lien de réinitialisation via <see cref="ILogger"/>
|
||||
/// au niveau <c>Information</c>. Utilisée lorsqu'aucun serveur SMTP n'est
|
||||
/// configuré (<c>Smtp:Host</c> vide), notamment en développement.
|
||||
/// </summary>
|
||||
public class LoggingEmailService : IEmailService
|
||||
{
|
||||
private readonly ILogger<LoggingEmailService> _logger;
|
||||
|
||||
public LoggingEmailService(ILogger<LoggingEmailService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Lien de réinitialisation pour {Destinataire} : {LienReset}",
|
||||
destinataire, lienReset);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation de <see cref="IEmailService"/> reposant sur MailKit pour
|
||||
/// l'envoi d'emails via un serveur SMTP. La configuration (hôte, port,
|
||||
/// identifiants, expéditeur) provient de <see cref="SmtpOptions"/>. Cette
|
||||
/// implémentation est enregistrée en DI lorsque <c>Smtp:Host</c> est renseigné.
|
||||
/// </summary>
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly SmtpOptions _options;
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
|
||||
public SmtpEmailService(IOptions<SmtpOptions> options, ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(_options.From));
|
||||
message.To.Add(MailboxAddress.Parse(destinataire));
|
||||
message.Subject = "Réinitialisation de votre mot de passe";
|
||||
|
||||
var corps = new BodyBuilder
|
||||
{
|
||||
TextBody =
|
||||
"Bonjour,\r\n\r\n" +
|
||||
"Vous avez demandé la réinitialisation de votre mot de passe.\r\n" +
|
||||
"Cliquez sur le lien suivant pour définir un nouveau mot de passe :\r\n\r\n" +
|
||||
lienReset + "\r\n\r\n" +
|
||||
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.",
|
||||
HtmlBody =
|
||||
"<p>Bonjour,</p>" +
|
||||
"<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>" +
|
||||
"<p>Cliquez sur le lien suivant pour définir un nouveau mot de passe :</p>" +
|
||||
$"<p><a href=\"{lienReset}\">{lienReset}</a></p>" +
|
||||
"<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>"
|
||||
};
|
||||
message.Body = corps.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
_logger.LogInformation(
|
||||
"Envoi de l'email de réinitialisation à {Destinataire} via {Host}:{Port}.",
|
||||
destinataire, _options.Host, _options.Port);
|
||||
|
||||
await client.ConnectAsync(_options.Host, _options.Port, SecureSocketOptions.StartTls, ct);
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.User))
|
||||
{
|
||||
await client.AuthenticateAsync(_options.User, _options.Password, ct);
|
||||
}
|
||||
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Email de réinitialisation envoyé à {Destinataire}.", destinataire);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using FluentValidation;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.Api.Validators;
|
||||
|
||||
// Validation de l'inscription (Requirements 1.3, 1.4, 8.2).
|
||||
public class RegisterDtoValidator : AbstractValidator<RegisterDto>
|
||||
{
|
||||
public RegisterDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
|
||||
.EmailAddress().WithMessage("Adresse email invalide.");
|
||||
|
||||
RuleFor(x => x.MotDePasse)
|
||||
.NotEmpty().WithMessage("Le mot de passe est obligatoire.")
|
||||
.MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validation de la connexion (Requirements 1.4, 2.2).
|
||||
public class LoginDtoValidator : AbstractValidator<LoginDto>
|
||||
{
|
||||
public LoginDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
|
||||
.EmailAddress().WithMessage("Adresse email invalide.");
|
||||
|
||||
RuleFor(x => x.MotDePasse)
|
||||
.NotEmpty().WithMessage("Le mot de passe est obligatoire.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validation de la demande de réinitialisation (Requirements 1.4, 6).
|
||||
public class ForgotPasswordDtoValidator : AbstractValidator<ForgotPasswordDto>
|
||||
{
|
||||
public ForgotPasswordDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
|
||||
.EmailAddress().WithMessage("Adresse email invalide.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validation de la réinitialisation du mot de passe (Requirements 7.4, 7.5).
|
||||
public class ResetPasswordDtoValidator : AbstractValidator<ResetPasswordDto>
|
||||
{
|
||||
public ResetPasswordDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.Token)
|
||||
.NotEmpty().WithMessage("Le jeton de réinitialisation est obligatoire.");
|
||||
|
||||
RuleFor(x => x.NouveauMotDePasse)
|
||||
.NotEmpty().WithMessage("Le mot de passe est obligatoire.")
|
||||
.MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'intégration de l'authentification (feature authentification), exécutés sur
|
||||
/// l'hôte réel via <see cref="WebApplicationFactory{TEntryPoint}"/> (Program expose
|
||||
/// <c>public partial class Program {}</c>).
|
||||
///
|
||||
/// Contexte de conception (design.md, section « Testing Strategy » — Tests d'intégration) :
|
||||
/// - Toutes les routes MVC de données sont protégées par un filtre d'autorisation global
|
||||
/// (RequireAuthenticatedUser) ; AuthController est [AllowAnonymous] et /health est public.
|
||||
/// - La configuration exige JWT__SecretKey (>= 32 octets) : fournie ici via une config de test.
|
||||
/// - La base est ISOLÉE par test : chaque test crée sa propre factory pointant vers un fichier
|
||||
/// SQLite temporaire unique (Guid). Cela évite le conflit « compte unique » entre tests
|
||||
/// (un seul register possible par base). Le fichier est supprimé en fin de test (IDisposable).
|
||||
/// - Smtp__Host est laissé VIDE pour forcer le repli journalisation (LoggingEmailService),
|
||||
/// afin de ne jamais tenter d'envoi SMTP réel pendant les tests.
|
||||
///
|
||||
/// Chaque test instancie <see cref="IsolatedAuthFactory"/> dans un bloc <c>using</c> :
|
||||
/// base neuve garantie et nettoyage du fichier temporaire.
|
||||
/// </summary>
|
||||
public class AuthIntegrationTests
|
||||
{
|
||||
// Clé JWT de test : >= 32 octets (exigence de configuration). Valeur factice, jamais utilisée en production.
|
||||
private const string JwtTestSecretKey = "test-secret-key-integration-authentification-0123456789";
|
||||
|
||||
/// <summary>
|
||||
/// Factory de test isolée : chaque instance possède son propre fichier SQLite temporaire
|
||||
/// (nom unique via Guid) et surcharge la configuration (ConnectionStrings__Default,
|
||||
/// JWT__SecretKey, Smtp__Host vide) via AddInMemoryCollection. Le fichier est supprimé
|
||||
/// à la libération de la factory.
|
||||
/// </summary>
|
||||
private sealed class IsolatedAuthFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"schotz-auth-it-{Guid.NewGuid():N}.db");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
// Program.cs lit la configuration (Jwt, ConnectionStrings, Smtp) DÈS la construction
|
||||
// du builder (via builder.Configuration.GetSection(...).Get<...>() pour enregistrer
|
||||
// AddJwtBearer et le DbContext). Ces valeurs doivent donc être présentes très tôt.
|
||||
// UseSetting alimente la configuration de l'hôte web, disponible au moment où Program
|
||||
// consomme ces sections (contrairement à ConfigureAppConfiguration, appliquée trop tard
|
||||
// pour ces lectures « eager »). On isole ainsi la base par factory sans variable d'env
|
||||
// globale au process (qui casserait l'isolation entre factories parallèles).
|
||||
builder.UseSetting("ConnectionStrings:Default", $"Data Source={_dbPath}");
|
||||
builder.UseSetting("Jwt:SecretKey", JwtTestSecretKey);
|
||||
builder.UseSetting("Smtp:Host", string.Empty);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
TryDeleteDbFiles();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteDbFiles()
|
||||
{
|
||||
// SQLite peut créer des fichiers annexes (-wal, -shm) : on les nettoie aussi.
|
||||
foreach (var path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nettoyage best-effort : un fichier temporaire résiduel ne doit pas faire échouer le test.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crée un client HTTP sans redirection auto ni cookies : on teste les codes de statut bruts.
|
||||
/// </summary>
|
||||
private static HttpClient CreerClient(IsolatedAuthFactory factory) =>
|
||||
factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
|
||||
private static RegisterDto NouveauCompte() =>
|
||||
new($"user-{Guid.NewGuid():N}@example.com", "MotDePasse123!");
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tâche 8.2 — Protection des routes (Requirements 4.1, 4.2, 4.4, 4.5)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/vehicules")]
|
||||
[InlineData("/api/profil")]
|
||||
[InlineData("/api/bareme/2025")]
|
||||
public async Task Route_de_donnees_sans_jeton_renvoie_401(string route)
|
||||
{
|
||||
// Requirement 4.2 / 4.5 : sans jeton, une route de données est rejetée (401).
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/auth/compte-existe")]
|
||||
[InlineData("/health")]
|
||||
public async Task Route_publique_accessible_sans_jeton_renvoie_200(string route)
|
||||
{
|
||||
// Requirement 4.4 : les endpoints publics (auth + /health) restent accessibles sans jeton.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_de_donnees_avec_jeton_valide_renvoie_2xx()
|
||||
{
|
||||
// Requirement 4.1 : avec un jeton valide, la route de données est autorisée (2xx).
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var token = await RegisterEtLoginAsync(client);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await client.GetAsync("/api/vehicules");
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue(
|
||||
"un jeton valide doit autoriser l'accès à une route protégée (statut {0})", response.StatusCode);
|
||||
((int)response.StatusCode).Should().BeInRange(200, 299);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tâche 8.3 — Parcours bout en bout (Requirements 1.1, 2.1, 7.1)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Bout_en_bout_register_puis_login_puis_route_protegee()
|
||||
{
|
||||
// Requirement 1.1 (register) -> 2.1 (login + token) -> accès protégé avec le token.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var compte = NouveauCompte();
|
||||
|
||||
// Register (200) — Requirement 1.1.
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Login (200 + token) — Requirement 2.1.
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login", new LoginDto(compte.Email, compte.MotDePasse));
|
||||
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<LoginResultDto>();
|
||||
loginResult.Should().NotBeNull();
|
||||
loginResult!.Token.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
// Accès à une route protégée avec le token obtenu (2xx).
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResult.Token);
|
||||
var protectedResponse = await client.GetAsync("/api/profil");
|
||||
|
||||
protectedResponse.IsSuccessStatusCode.Should().BeTrue(
|
||||
"le token émis par login doit ouvrir l'accès protégé (statut {0})", protectedResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bout_en_bout_forgot_password_puis_reset_password_avec_token_invalide()
|
||||
{
|
||||
// Requirement 7.1 (cycle de réinitialisation).
|
||||
//
|
||||
// Choix d'implémentation pour l'e2e du reset (option b, documentée) :
|
||||
// le jeton de reset n'est jamais persisté en clair (seul son hachage SHA-256 est stocké
|
||||
// dans ResetPasswordTokens.TokenHash) et le jeton en clair n'est disponible que dans le
|
||||
// lien journalisé par LoggingEmailService. Le reconstituer depuis la base ou les logs
|
||||
// n'est pas fiable en intégration. On vérifie donc de façon déterministe :
|
||||
// - forgot-password renvoie toujours 200 (non-divulgation, Requirement 6.6) ;
|
||||
// - reset-password avec un jeton bidon renvoie 400 (lien invalide, Requirement 7.4).
|
||||
// Le remplacement effectif du mot de passe via un jeton valide est couvert par les
|
||||
// tests de propriété (P11) au niveau du service, où le jeton en clair est accessible.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var compte = NouveauCompte();
|
||||
|
||||
// Prérequis : un compte existe.
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// forgot-password renvoie 200 (email connu).
|
||||
var forgotResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/forgot-password", new ForgotPasswordDto(compte.Email));
|
||||
forgotResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// reset-password avec un token bidon => 400 (lien invalide).
|
||||
var resetResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/reset-password", new ResetPasswordDto("token-bidon-inexistant", "NouveauMotDePasse123!"));
|
||||
resetResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inscrit puis connecte un nouveau compte et retourne le jeton JWT obtenu.
|
||||
/// </summary>
|
||||
private static async Task<string> RegisterEtLoginAsync(HttpClient client)
|
||||
{
|
||||
var compte = NouveauCompte();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login", new LoginDto(compte.Email, compte.MotDePasse));
|
||||
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<LoginResultDto>();
|
||||
loginResult.Should().NotBeNull();
|
||||
loginResult!.Token.Should().NotBeNullOrWhiteSpace();
|
||||
return loginResult.Token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la logique de sélection en DI de l'implémentation d'<see cref="IEmailService"/>,
|
||||
/// telle que décrite dans <c>Program.cs</c> :
|
||||
/// Smtp:Host vide => LoggingEmailService (repli journalisation, Exigence 6.5)
|
||||
/// Smtp:Host renseigné => SmtpEmailService (MailKit, Exigence 6.4)
|
||||
///
|
||||
/// On reconstruit un <see cref="ServiceCollection"/> minimal appliquant la même condition
|
||||
/// (<c>string.IsNullOrWhiteSpace(smtpOptions.Host)</c>) puis on résout le service pour
|
||||
/// confirmer le type concret retenu, sans démarrer l'hôte complet (évite l'accès à la base réelle).
|
||||
/// </summary>
|
||||
public class AuthServiceEmailSelectionTests
|
||||
{
|
||||
private static IEmailService ResoudreEmailService(string host)
|
||||
{
|
||||
var smtpOptions = new SmtpOptions { Host = host };
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.Configure<SmtpOptions>(o => o.Host = host);
|
||||
|
||||
// Réplique exacte de la condition de Program.cs (section « Sélection de l'implémentation Email »).
|
||||
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
|
||||
services.AddScoped<IEmailService, LoggingEmailService>();
|
||||
else
|
||||
services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IEmailService>();
|
||||
}
|
||||
|
||||
// Feature: authentification, sélection DI email (5.3) : Host vide => LoggingEmailService.
|
||||
// Validates: Requirements 6.5
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Host_vide_selectionne_LoggingEmailService(string host)
|
||||
{
|
||||
var service = ResoudreEmailService(host);
|
||||
service.Should().BeOfType<LoggingEmailService>();
|
||||
}
|
||||
|
||||
// Feature: authentification, sélection DI email (5.3) : Host renseigné => SmtpEmailService.
|
||||
// Validates: Requirements 6.4
|
||||
[Fact]
|
||||
public void Host_renseigne_selectionne_SmtpEmailService()
|
||||
{
|
||||
var service = ResoudreEmailService("smtp.example.com");
|
||||
service.Should().BeOfType<SmtpEmailService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) du service d'authentification,
|
||||
/// couvrant les Correctness Properties P5–P14 de la conception.
|
||||
///
|
||||
/// Les tests de flux s'exécutent sur un <see cref="AuthTestHarness"/> avec une base SQLite
|
||||
/// en mémoire recréée à chaque cas (isolation), un vrai PasswordHasher / JwtService et un
|
||||
/// IEmailService mocké.
|
||||
/// </summary>
|
||||
public class AuthServiceProperties
|
||||
{
|
||||
// --- Générateurs contraints à l'espace d'entrée pertinent ------------------
|
||||
|
||||
/// <summary>Emails syntaxiquement valides (locale@domaine.tld).</summary>
|
||||
private static Arbitrary<string> EmailArb()
|
||||
{
|
||||
var lettre = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789".ToCharArray());
|
||||
Gen<string> partie(int min, int max) =>
|
||||
from n in Gen.Choose(min, max)
|
||||
from cs in Gen.ArrayOf(n, lettre)
|
||||
select new string(cs);
|
||||
|
||||
var tld = Gen.Elements("com", "fr", "org", "net", "io");
|
||||
|
||||
var email =
|
||||
from local in partie(1, 10)
|
||||
from domaine in partie(1, 10)
|
||||
from t in tld
|
||||
select $"{local}@{domaine}.{t}";
|
||||
|
||||
return Arb.From(email);
|
||||
}
|
||||
|
||||
/// <summary>Mots de passe d'au moins 8 caractères (imprimables ASCII).</summary>
|
||||
private static Arbitrary<string> MotDePasseValideArb()
|
||||
{
|
||||
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%".ToCharArray());
|
||||
var g =
|
||||
from n in Gen.Choose(8, 24)
|
||||
from cs in Gen.ArrayOf(n, carac)
|
||||
select new string(cs);
|
||||
return Arb.From(g);
|
||||
}
|
||||
|
||||
private static Property ForEmailEtMdp(Func<string, string, Task> corps)
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), (email, mdp) =>
|
||||
{
|
||||
corps(email, mdp).GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Feature: authentification, Property 5: L'inscription crée le compte lorsqu'aucun compte n'existe
|
||||
// Pour toute paire (email valide, mdp >= 8), base vide + RegisterAsync => exactement 1 compte avec cet email.
|
||||
// Validates: Requirements 1.1
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P5_Inscription_cree_le_compte_sur_base_vide()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
var comptes = await h.Context.Comptes.ToListAsync();
|
||||
comptes.Should().ContainSingle();
|
||||
comptes.Single().Email.Should().Be(email.Trim());
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 6: L'inscription est rejetée lorsqu'un compte existe déjà
|
||||
// Compte déjà présent + 2e RegisterAsync => CompteDejaExistantException, toujours 1 compte.
|
||||
// Validates: Requirements 1.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P6_Deuxieme_inscription_rejetee()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), EmailArb(), MotDePasseValideArb(),
|
||||
(email1, email2, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
|
||||
h.Service.RegisterAsync(new RegisterDto(email1, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
Func<Task> deuxieme = () =>
|
||||
h.Service.RegisterAsync(new RegisterDto(email2, mdp), CancellationToken.None);
|
||||
|
||||
deuxieme.Should().ThrowAsync<CompteDejaExistantException>().GetAwaiter().GetResult();
|
||||
|
||||
h.Context.Comptes.Count().Should().Be(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 8: La connexion émet un jeton sur identifiants corrects et rejette les invalides
|
||||
// Bons identifiants => LoginResultDto avec token non vide validable ;
|
||||
// mauvais mdp ou email inconnu => IdentifiantsInvalidesException.
|
||||
// Validates: Requirements 2.1, 2.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P8_Login_emet_jeton_ou_rejette()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
// Bons identifiants => token validable.
|
||||
var resultat = await h.Service.LoginAsync(new LoginDto(email, mdp), CancellationToken.None);
|
||||
resultat.Token.Should().NotBeNullOrWhiteSpace();
|
||||
JwtTestUtils.EstValide(resultat.Token, AuthTestHarness.SecretKey).Should().BeTrue();
|
||||
|
||||
// Mauvais mot de passe => rejet.
|
||||
Func<Task> mauvaisMdp = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, mdp + "X"), CancellationToken.None);
|
||||
await mauvaisMdp.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
|
||||
// Email inconnu => rejet.
|
||||
Func<Task> emailInconnu = () =>
|
||||
h.Service.LoginAsync(new LoginDto("inconnu-" + email, mdp), CancellationToken.None);
|
||||
await emailInconnu.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 9: Non-divulgation lors de la demande de réinitialisation
|
||||
// ForgotPasswordAsync ne lève jamais ; email inconnu => aucun token créé et email NON appelé ;
|
||||
// email du compte => exactement un token créé et EnvoyerLienResetAsync appelé une fois.
|
||||
// Validates: Requirements 6.6
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P9_Non_divulgation_forgot_password()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
// Email inconnu : ne lève pas, ne crée aucun token, n'appelle pas l'email.
|
||||
Func<Task> inconnu = () =>
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto("autre-" + email), CancellationToken.None);
|
||||
await inconnu.Should().NotThrowAsync();
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(0);
|
||||
h.NbAppelsEmail.Should().Be(0);
|
||||
|
||||
// Email du compte : ne lève pas, crée exactement un token, appelle l'email une fois.
|
||||
Func<Task> connu = () =>
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None);
|
||||
await connu.Should().NotThrowAsync();
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(1);
|
||||
h.NbAppelsEmail.Should().Be(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 10: Expiration du jeton de reset paramétrée
|
||||
// Pour toute ResetTokenLifetimeMinutes > 0, le token créé a Expiration ≈ CreatedAt + minutes et UsedAt == null.
|
||||
// Validates: Requirements 6.1, 6.2, 6.3
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P10_Expiration_reset_parametree()
|
||||
{
|
||||
var minutesArb = Arb.From(Gen.Choose(1, 100_000));
|
||||
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), minutesArb, (email, mdp, minutes) =>
|
||||
{
|
||||
using var h = new AuthTestHarness(resetTokenLifetimeMinutes: minutes);
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.UsedAt.Should().BeNull();
|
||||
|
||||
var attendu = token.CreatedAt.AddMinutes(minutes);
|
||||
token.Expiration.Should().BeCloseTo(attendu, TimeSpan.FromSeconds(5));
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 11: Une réinitialisation valide remplace le mot de passe
|
||||
// Après ForgotPassword (token capturé via le lien mocké), ResetPasswordAsync(token, nouveauMdp>=8)
|
||||
// => LoginAsync réussit avec le nouveau mdp et échoue avec l'ancien.
|
||||
// Validates: Requirements 7.1
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P11_Reset_valide_remplace_mdp()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, ancienMdp, nouveauMdp) =>
|
||||
{
|
||||
// On veut deux mots de passe distincts pour tester l'ancien vs le nouveau.
|
||||
if (ancienMdp == nouveauMdp)
|
||||
{
|
||||
nouveauMdp += "Z9";
|
||||
}
|
||||
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, ancienMdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Login OK avec le nouveau mot de passe.
|
||||
Func<Task> nouveau = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, nouveauMdp), CancellationToken.None);
|
||||
nouveau.Should().NotThrowAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Login KO avec l'ancien mot de passe.
|
||||
Func<Task> ancien = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, ancienMdp), CancellationToken.None);
|
||||
ancien.Should().ThrowAsync<IdentifiantsInvalidesException>().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 12: Un jeton de reset est à usage unique
|
||||
// Un token utilisé une fois => 2e ResetPasswordAsync avec le même token lève LienResetInvalideException ; UsedAt renseigné.
|
||||
// Validates: Requirements 7.2, 7.4
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P12_Reset_usage_unique()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, mdp, nouveau) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
// Premier usage : succès.
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// UsedAt renseigné.
|
||||
h.Context.ResetPasswordTokens.Single().UsedAt.Should().NotBeNull();
|
||||
|
||||
// Deuxième usage du même token : rejeté (avant toute prise en compte du mot de passe).
|
||||
Func<Task> deuxieme = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None);
|
||||
deuxieme.Should().ThrowAsync<LienResetInvalideException>().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 13: Un jeton de reset expiré ou inconnu est rejeté
|
||||
// Token expiré (Expiration forcée dans le passé) => LienResetExpireException ;
|
||||
// token inconnu => LienResetInvalideException ; le mdp du compte reste inchangé.
|
||||
// Validates: Requirements 7.3, 7.4
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P13_Reset_expire_ou_inconnu_rejete()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, mdp, nouveauMdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
// --- Token inconnu de la base ---
|
||||
Func<Task> inconnu = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto("token-inexistant", nouveauMdp), CancellationToken.None);
|
||||
inconnu.Should().ThrowAsync<LienResetInvalideException>().GetAwaiter().GetResult();
|
||||
|
||||
// --- Token expiré : on génère un token via forgot puis on force Expiration dans le passé ---
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.Expiration = DateTime.UtcNow.AddMinutes(-1);
|
||||
h.Context.SaveChanges();
|
||||
|
||||
Func<Task> expire = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None);
|
||||
expire.Should().ThrowAsync<LienResetExpireException>().GetAwaiter().GetResult();
|
||||
|
||||
// Le mot de passe du compte reste inchangé dans les deux cas.
|
||||
h.Context.Entry(h.Context.Comptes.Single()).Reload();
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires / d'exemples du flux d'authentification (exemples ciblés
|
||||
/// complémentaires aux tests de propriétés) : login, non-divulgation,
|
||||
/// réinitialisation valide et cas d'erreur.
|
||||
/// </summary>
|
||||
public class AuthServiceTests
|
||||
{
|
||||
private const string Email = "utilisateur@example.com";
|
||||
private const string MotDePasse = "MotDePasse123";
|
||||
|
||||
// Feature: authentification, Property 8 (exemple) : login OK puis rejet mauvais mdp / email inconnu.
|
||||
// Validates: Requirements 2.1, 2.2
|
||||
[Fact]
|
||||
public async Task Login_avec_bons_identifiants_emet_un_jeton_validable()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
var resultat = await h.Service.LoginAsync(new LoginDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
resultat.Token.Should().NotBeNullOrWhiteSpace();
|
||||
JwtTestUtils.EstValide(resultat.Token, AuthTestHarness.SecretKey).Should().BeTrue();
|
||||
resultat.Expiration.Should().BeAfter(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_avec_mauvais_mot_de_passe_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
Func<Task> acte = () => h.Service.LoginAsync(new LoginDto(Email, "MauvaisMotDePasse"), CancellationToken.None);
|
||||
|
||||
await acte.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_avec_email_inconnu_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
Func<Task> acte = () => h.Service.LoginAsync(new LoginDto("autre@example.com", MotDePasse), CancellationToken.None);
|
||||
|
||||
await acte.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 9 (exemple) : forgot-password réponse identique connu/inconnu.
|
||||
// Validates: Requirements 6.6
|
||||
[Fact]
|
||||
public async Task ForgotPassword_email_inconnu_ne_cree_aucun_token_et_n_appelle_pas_email()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto("inconnu@example.com"), CancellationToken.None);
|
||||
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(0);
|
||||
h.NbAppelsEmail.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgotPassword_email_connu_cree_un_token_et_appelle_email_une_fois()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(1);
|
||||
h.NbAppelsEmail.Should().Be(1);
|
||||
h.DernierLienReset.Should().Contain("/reset-password?token=");
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 11 (exemple) : reset valide remplace le mot de passe.
|
||||
// Validates: Requirements 7.1
|
||||
[Fact]
|
||||
public async Task ResetPassword_valide_permet_login_avec_nouveau_mdp()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
const string nouveauMdp = "NouveauMdp456";
|
||||
|
||||
await h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None);
|
||||
|
||||
var ok = await h.Service.LoginAsync(new LoginDto(Email, nouveauMdp), CancellationToken.None);
|
||||
ok.Token.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
Func<Task> ancien = () => h.Service.LoginAsync(new LoginDto(Email, MotDePasse), CancellationToken.None);
|
||||
await ancien.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 12 (exemple) : usage unique du token de reset.
|
||||
// Validates: Requirements 7.2, 7.4
|
||||
[Fact]
|
||||
public async Task ResetPassword_deuxieme_usage_du_meme_token_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
await h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "PremierMdp123"), CancellationToken.None);
|
||||
h.Context.ResetPasswordTokens.Single().UsedAt.Should().NotBeNull();
|
||||
|
||||
Func<Task> reuse = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "DeuxiemeMdp123"), CancellationToken.None);
|
||||
await reuse.Should().ThrowAsync<LienResetInvalideException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 13 (exemples) : token inconnu / expiré rejetés, mdp inchangé.
|
||||
// Validates: Requirements 7.3, 7.4
|
||||
[Fact]
|
||||
public async Task ResetPassword_token_inconnu_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
Func<Task> acte = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto("inexistant", "PeuImporte123"), CancellationToken.None);
|
||||
await acte.Should().ThrowAsync<LienResetInvalideException>();
|
||||
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_token_expire_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.Expiration = DateTime.UtcNow.AddMinutes(-1);
|
||||
await h.Context.SaveChangesAsync();
|
||||
|
||||
Func<Task> acte = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "PeuImporte123"), CancellationToken.None);
|
||||
await acte.Should().ThrowAsync<LienResetExpireException>();
|
||||
|
||||
h.Context.Entry(h.Context.Comptes.Single()).Reload();
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Banc d'essai pour tester <see cref="AuthService"/> en isolation.
|
||||
///
|
||||
/// Utilise un <see cref="AppDbContext"/> adossé à une base SQLite EN MÉMOIRE
|
||||
/// ("Data Source=:memory:") dont la connexion est maintenue ouverte pour la
|
||||
/// durée du harnais (la base disparaît à la fermeture de la connexion).
|
||||
/// On emploie de vrais <see cref="PasswordHasher{Compte}"/> et <see cref="JwtService"/>
|
||||
/// (JWT signé HS256 avec une clé >= 32 octets), et on mocke <see cref="IEmailService"/>
|
||||
/// (Moq) afin de capturer le lien de réinitialisation transmis.
|
||||
///
|
||||
/// Chaque instance crée une base fraîche : l'isolation entre itérations de
|
||||
/// propriété est obtenue en instanciant un nouveau harnais par cas.
|
||||
/// </summary>
|
||||
public sealed class AuthTestHarness : IDisposable
|
||||
{
|
||||
// Clé secrète >= 32 octets (exigée par HMAC-SHA256).
|
||||
public const string SecretKey = "cle-secrete-de-test-suffisamment-longue-1234567890";
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public AppDbContext Context { get; }
|
||||
public AuthService Service { get; }
|
||||
public Mock<IEmailService> EmailMock { get; }
|
||||
public JwtService Jwt { get; }
|
||||
public PasswordHasher<Compte> Hasher { get; }
|
||||
public JwtOptions JwtOptions { get; }
|
||||
public AuthOptions AuthOptions { get; }
|
||||
|
||||
/// <summary>Dernier lien de réinitialisation capturé via le mock IEmailService.</summary>
|
||||
public string? DernierLienReset { get; private set; }
|
||||
|
||||
/// <summary>Nombre d'appels à EnvoyerLienResetAsync capturés.</summary>
|
||||
public int NbAppelsEmail { get; private set; }
|
||||
|
||||
public AuthTestHarness(int jwtExpirationMinutes = 720, int resetTokenLifetimeMinutes = 60)
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open(); // maintenue ouverte : garde la base en vie
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
Context = new AppDbContext(options);
|
||||
Context.Database.EnsureCreated();
|
||||
|
||||
Hasher = new PasswordHasher<Compte>();
|
||||
|
||||
JwtOptions = new JwtOptions
|
||||
{
|
||||
SecretKey = SecretKey,
|
||||
Issuer = "SchotzIadKmTracker",
|
||||
Audience = "SchotzIadKmTracker",
|
||||
ExpirationMinutes = jwtExpirationMinutes,
|
||||
};
|
||||
|
||||
AuthOptions = new AuthOptions
|
||||
{
|
||||
ResetTokenLifetimeMinutes = resetTokenLifetimeMinutes,
|
||||
FrontendBaseUrl = "http://localhost:8080",
|
||||
};
|
||||
|
||||
Jwt = new JwtService(Options.Create(JwtOptions));
|
||||
|
||||
EmailMock = new Mock<IEmailService>();
|
||||
EmailMock
|
||||
.Setup(e => e.EnvoyerLienResetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, CancellationToken>((_, lien, _) =>
|
||||
{
|
||||
DernierLienReset = lien;
|
||||
NbAppelsEmail++;
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Service = new AuthService(
|
||||
Context,
|
||||
Hasher,
|
||||
Jwt,
|
||||
EmailMock.Object,
|
||||
Options.Create(AuthOptions),
|
||||
Options.Create(JwtOptions),
|
||||
NullLogger<AuthService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Extrait la valeur du paramètre "token" de la query string d'un lien de reset.</summary>
|
||||
public static string ExtraireToken(string lienReset)
|
||||
{
|
||||
var uri = new Uri(lienReset);
|
||||
var query = uri.Query.TrimStart('?');
|
||||
foreach (var paire in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = paire.Split('=', 2);
|
||||
if (kv.Length == 2 && kv[0] == "token")
|
||||
{
|
||||
return Uri.UnescapeDataString(kv[1]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Aucun paramètre 'token' dans le lien : {lienReset}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Context.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using FluentValidation.Results;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Validators;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de la validation FluentValidation des DTOs d'authentification.
|
||||
/// La longueur du mot de passe et la validité syntaxique de l'email sont
|
||||
/// contrôlées au niveau du validateur (en amont d'AuthService).
|
||||
/// </summary>
|
||||
public class AuthValidatorTests
|
||||
{
|
||||
private static readonly RegisterDtoValidator RegisterValidator = new();
|
||||
private static readonly LoginDtoValidator LoginValidator = new();
|
||||
private static readonly ResetPasswordDtoValidator ResetValidator = new();
|
||||
|
||||
private const string EmailValide = "utilisateur@example.com";
|
||||
|
||||
// Feature: authentification, Property 7: Un mot de passe trop court est refusé à l'inscription
|
||||
// Toute chaîne de longueur 0..7 => invalide ; >= 8 => valide (avec email valide).
|
||||
// Validates: Requirements 1.3
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P7_RegisterValidator_rejette_mdp_court_accepte_mdp_long()
|
||||
{
|
||||
// Génère des mots de passe de longueur 0..20 à partir de caractères imprimables.
|
||||
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789ABCDEF!@#".ToCharArray());
|
||||
var mdpArb = Arb.From(
|
||||
from n in Gen.Choose(0, 20)
|
||||
from cs in Gen.ArrayOf(n, carac)
|
||||
select new string(cs));
|
||||
|
||||
return Prop.ForAll(mdpArb, mdp =>
|
||||
{
|
||||
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, mdp));
|
||||
|
||||
if (mdp.Length < 8)
|
||||
{
|
||||
resultat.IsValid.Should().BeFalse($"un mot de passe de longueur {mdp.Length} (< 8) doit être rejeté");
|
||||
}
|
||||
else
|
||||
{
|
||||
resultat.IsValid.Should().BeTrue($"un mot de passe de longueur {mdp.Length} (>= 8) doit être accepté");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 14: Un nouveau mot de passe trop court est refusé à la réinitialisation
|
||||
// ResetPasswordDtoValidator : NouveauMotDePasse < 8 => invalide ; >= 8 (token non vide) => valide.
|
||||
// Validates: Requirements 7.5
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P14_ResetValidator_rejette_nouveau_mdp_court()
|
||||
{
|
||||
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789ABCDEF!@#".ToCharArray());
|
||||
var mdpArb = Arb.From(
|
||||
from n in Gen.Choose(0, 20)
|
||||
from cs in Gen.ArrayOf(n, carac)
|
||||
select new string(cs));
|
||||
|
||||
return Prop.ForAll(mdpArb, mdp =>
|
||||
{
|
||||
var resultat = ResetValidator.Validate(new ResetPasswordDto("un-jeton-non-vide", mdp));
|
||||
|
||||
if (mdp.Length < 8)
|
||||
{
|
||||
resultat.IsValid.Should().BeFalse($"un nouveau mot de passe de longueur {mdp.Length} (< 8) doit être rejeté");
|
||||
}
|
||||
else
|
||||
{
|
||||
resultat.IsValid.Should().BeTrue($"un nouveau mot de passe de longueur {mdp.Length} (>= 8) doit être accepté");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 7 (exemples) : bornes de longueur du mot de passe.
|
||||
// Validates: Requirements 1.3
|
||||
[Theory]
|
||||
[InlineData("", false)]
|
||||
[InlineData("1234567", false)] // 7 caractères
|
||||
[InlineData("12345678", true)] // 8 caractères
|
||||
[InlineData("123456789", true)] // 9 caractères
|
||||
public void RegisterValidator_longueur_mdp_bornes(string mdp, bool valideAttendu)
|
||||
{
|
||||
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, mdp));
|
||||
resultat.IsValid.Should().Be(valideAttendu);
|
||||
}
|
||||
|
||||
// Feature: authentification, tests d'exemples (6.13) : emails invalides rejetés.
|
||||
// Validates: Requirements 1.4
|
||||
[Theory]
|
||||
[InlineData("a@")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("@x")]
|
||||
[InlineData("")]
|
||||
public void RegisterValidator_rejette_les_emails_invalides(string email)
|
||||
{
|
||||
var resultat = RegisterValidator.Validate(new RegisterDto(email, "MotDePasse123"));
|
||||
resultat.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a@")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("@x")]
|
||||
[InlineData("")]
|
||||
public void LoginValidator_rejette_les_emails_invalides(string email)
|
||||
{
|
||||
var resultat = LoginValidator.Validate(new LoginDto(email, "MotDePasse123"));
|
||||
resultat.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterValidator_accepte_un_email_valide()
|
||||
{
|
||||
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, "MotDePasse123"));
|
||||
resultat.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
// Feature: authentification, tests d'exemples (6.13) : aucun DTO d'auth n'expose de champ de hash.
|
||||
// LoginResultDto ne comporte que Token + Expiration (Requirement 8.2).
|
||||
// Validates: Requirements 8.2
|
||||
[Fact]
|
||||
public void LoginResultDto_n_expose_que_token_et_expiration()
|
||||
{
|
||||
var proprietes = typeof(LoginResultDto).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
proprietes.Should().BeEquivalentTo(new[] { "Token", "Expiration" });
|
||||
proprietes.Should().NotContain(n => n.ToLowerInvariant().Contains("hash"));
|
||||
proprietes.Should().NotContain(n => n.ToLowerInvariant().Contains("motdepasse"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aucun_dto_d_auth_ne_comporte_de_champ_hash_ou_mot_de_passe_hash()
|
||||
{
|
||||
var types = new[]
|
||||
{
|
||||
typeof(RegisterDto), typeof(LoginDto), typeof(LoginResultDto),
|
||||
typeof(ForgotPasswordDto), typeof(ResetPasswordDto), typeof(CompteExisteDto)
|
||||
};
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var noms = type.GetProperties().Select(p => p.Name.ToLowerInvariant());
|
||||
noms.Should().NotContain(n => n.Contains("hash"), $"{type.Name} ne doit exposer aucun champ de hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Propriétés de correction du service JWT (feature authentification).
|
||||
/// Couvre l'émission/validation des jetons (Property 2), le rejet des jetons expirés
|
||||
/// (Property 3) et le rejet des signatures invalides (Property 4).
|
||||
/// </summary>
|
||||
public class JwtServiceProperties
|
||||
{
|
||||
// Clé de signature >= 32 octets requise pour HS256 (sinon SecurityTokenEncryptionKeySizeException).
|
||||
private const string CleSecrete = "clef-secrete-de-test-suffisamment-longue-0123456789";
|
||||
private const string Issuer = "SchotzIadKmTracker";
|
||||
private const string Audience = "SchotzIadKmTracker";
|
||||
|
||||
/// <summary>Construit un JwtService réel avec la durée d'expiration fournie.</summary>
|
||||
private static JwtService CreerService(int expirationMinutes)
|
||||
{
|
||||
var options = Options.Create(new JwtOptions
|
||||
{
|
||||
SecretKey = CleSecrete,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience,
|
||||
ExpirationMinutes = expirationMinutes
|
||||
});
|
||||
return new JwtService(options);
|
||||
}
|
||||
|
||||
/// <summary>Paramètres de validation alignés sur ceux du pipeline (Program.cs).</summary>
|
||||
private static TokenValidationParameters CreerParametresValidation(string cleSecrete = CleSecrete)
|
||||
=> new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidAudience = Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cleSecrete)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
/// <summary>Générateur d'ExpirationMinutes borné (1..100000) pour éviter les valeurs absurdes.</summary>
|
||||
private static Arbitrary<int> ExpirationMinutesArb()
|
||||
=> Gen.Choose(1, 100_000).ToArbitrary();
|
||||
|
||||
/// <summary>Générateur d'emails non vides (bornés) pour les comptes de test.</summary>
|
||||
private static Gen<string> EmailGen()
|
||||
=> from local in Gen.Elements("alice", "bob", "carol", "utilisateur", "compte.unique")
|
||||
from domaine in Gen.Elements("example.com", "test.fr", "mail.org")
|
||||
select $"{local}@{domaine}";
|
||||
|
||||
// Feature: authentification, Property 2: 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(MaxTest = 100)]
|
||||
public Property Le_jeton_emis_est_validable_et_preserve_les_claims()
|
||||
{
|
||||
var arb = Arb.From(
|
||||
from expiration in ExpirationMinutesArb().Generator
|
||||
from id in Gen.Choose(1, 1_000_000)
|
||||
from email in EmailGen()
|
||||
select (expiration, id, email));
|
||||
|
||||
return Prop.ForAll(arb, tuple =>
|
||||
{
|
||||
var (expirationMinutes, id, email) = tuple;
|
||||
var service = CreerService(expirationMinutes);
|
||||
var compte = new Compte { Id = id, Email = email };
|
||||
|
||||
var jeton = service.GenererJeton(compte);
|
||||
|
||||
// Ne pas remapper les noms de claims entrants (sub reste "sub", email reste "email").
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
handler.ValidateToken(jeton, CreerParametresValidation(), out var validated);
|
||||
|
||||
var jwt = (JwtSecurityToken)validated;
|
||||
|
||||
// Claims sub et email préservés (lecture sur le jeton validé, noms d'origine).
|
||||
var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value;
|
||||
var emailClaim = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Email)?.Value;
|
||||
|
||||
sub.Should().Be(id.ToString());
|
||||
emailClaim.Should().Be(email);
|
||||
|
||||
// exp ≈ iat + ExpirationMinutes (tolérance de 5 secondes pour l'écart d'exécution).
|
||||
var ecart = jwt.ValidTo - jwt.ValidFrom;
|
||||
ecart.Should().BeCloseTo(TimeSpan.FromMinutes(expirationMinutes), TimeSpan.FromSeconds(5));
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 3: Pour tout jeton dont la date d'expiration est antérieure
|
||||
// à l'instant courant, la validation échoue (SecurityTokenExpiredException).
|
||||
// Validates: Requirements 4.3, 5.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Un_jeton_expire_est_rejete()
|
||||
{
|
||||
// On fabrique un jeton déjà expiré via le handler avec la même clé.
|
||||
// minutesEcoulees > ClockSkew (30 s) pour dépasser la tolérance d'horloge.
|
||||
var arb = Gen.Choose(2, 10_000).ToArbitrary();
|
||||
|
||||
return Prop.ForAll(arb, minutesEcoulees =>
|
||||
{
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(CleSecrete));
|
||||
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jetonExpire = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, "1") },
|
||||
notBefore: maintenant.AddMinutes(-minutesEcoulees - 1),
|
||||
expires: maintenant.AddMinutes(-minutesEcoulees),
|
||||
signingCredentials: credentials);
|
||||
|
||||
var jeton = new JwtSecurityTokenHandler().WriteToken(jetonExpire);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
Action validation = () => handler.ValidateToken(jeton, CreerParametresValidation(), out _);
|
||||
|
||||
validation.Should().Throw<SecurityTokenExpiredException>();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 4: 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(MaxTest = 100)]
|
||||
public Property Une_signature_invalide_est_rejetee()
|
||||
{
|
||||
// Deux cas : (a) jeton signé avec une autre clé, (b) charge utile altérée.
|
||||
var arb = Arb.From(
|
||||
from id in Gen.Choose(1, 1_000_000)
|
||||
from email in EmailGen()
|
||||
from altererCharge in Gen.Elements(true, false)
|
||||
select (id, email, altererCharge));
|
||||
|
||||
return Prop.ForAll(arb, tuple =>
|
||||
{
|
||||
var (id, email, altererCharge) = tuple;
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parametres = CreerParametresValidation();
|
||||
|
||||
string jeton;
|
||||
if (altererCharge)
|
||||
{
|
||||
// Jeton valide dont on altère la charge utile (segment central).
|
||||
var service = CreerService(720);
|
||||
var valide = service.GenererJeton(new Compte { Id = id, Email = email });
|
||||
var segments = valide.Split('.');
|
||||
// Inverser deux caractères de la charge (payload) pour casser la signature.
|
||||
var charge = segments[1].ToCharArray();
|
||||
if (charge.Length >= 2)
|
||||
{
|
||||
(charge[0], charge[1]) = (charge[1], charge[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
charge = (segments[1] + "X").ToCharArray();
|
||||
}
|
||||
segments[1] = new string(charge);
|
||||
jeton = string.Join('.', segments);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Jeton signé avec une clé DIFFÉRENTE (>= 32 octets) de celle de validation.
|
||||
const string autreCle = "AUTRE-clef-secrete-differente-mais-assez-longue-9876543210";
|
||||
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(autreCle));
|
||||
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email)
|
||||
},
|
||||
notBefore: maintenant,
|
||||
expires: maintenant.AddMinutes(720),
|
||||
signingCredentials: credentials);
|
||||
jeton = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||
}
|
||||
|
||||
// La validation doit échouer : soit une exception de signature (SecurityTokenException,
|
||||
// p. ex. SecurityTokenInvalidSignatureException / SecurityTokenSignatureKeyNotFoundException),
|
||||
// soit un rejet au décodage lorsque la charge altérée n'est plus décodable (ArgumentException).
|
||||
Exception? levee = null;
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(jeton, parametres, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
levee = ex;
|
||||
}
|
||||
|
||||
levee.Should().NotBeNull("un jeton à signature invalide ou charge altérée doit être rejeté");
|
||||
levee.Should().Match(e => e is SecurityTokenException || e is ArgumentException);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Utilitaires de validation de jetons JWT pour les tests, alignés sur les
|
||||
/// paramètres de validation de <c>Program.cs</c> (issuer / audience / clé de
|
||||
/// signature / durée de vie).
|
||||
/// </summary>
|
||||
internal static class JwtTestUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Valide un jeton HS256 avec la clé fournie. Retourne <c>true</c> si le jeton
|
||||
/// est signé par cette clé, non expiré et porte l'issuer/audience attendus.
|
||||
/// </summary>
|
||||
public static bool EstValide(
|
||||
string token,
|
||||
string secretKey,
|
||||
string issuer = "SchotzIadKmTracker",
|
||||
string audience = "SchotzIadKmTracker")
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = issuer,
|
||||
ValidAudience = audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(token, parameters, out _);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'exemple vérifiant la liaison des options d'authentification depuis une configuration
|
||||
/// in-memory, ainsi que les valeurs par défaut attendues (feature authentification, tâche 2.2).
|
||||
/// </summary>
|
||||
public class OptionsBindingTests
|
||||
{
|
||||
private static IConfiguration ConstruireConfiguration(IEnumerable<KeyValuePair<string, string?>> valeurs)
|
||||
=> new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(valeurs)
|
||||
.Build();
|
||||
|
||||
// Validates: Requirements 5.5
|
||||
[Fact]
|
||||
public void JwtOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
// Seul SecretKey est fourni : Issuer/Audience/ExpirationMinutes doivent prendre leurs défauts.
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:SecretKey"] = "clef-secrete-de-test-suffisamment-longue-0123456789"
|
||||
});
|
||||
|
||||
var options = new JwtOptions();
|
||||
config.GetSection(JwtOptions.SectionName).Bind(options);
|
||||
|
||||
options.SecretKey.Should().Be("clef-secrete-de-test-suffisamment-longue-0123456789");
|
||||
options.Issuer.Should().Be("SchotzIadKmTracker");
|
||||
options.Audience.Should().Be("SchotzIadKmTracker");
|
||||
options.ExpirationMinutes.Should().Be(720);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtOptions_prend_les_valeurs_fournies_par_la_configuration()
|
||||
{
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:SecretKey"] = "une-autre-clef-de-test-tres-longue-abcdefghijklmnop",
|
||||
["Jwt:Issuer"] = "MonIssuer",
|
||||
["Jwt:Audience"] = "MonAudience",
|
||||
["Jwt:ExpirationMinutes"] = "60"
|
||||
});
|
||||
|
||||
var options = new JwtOptions();
|
||||
config.GetSection(JwtOptions.SectionName).Bind(options);
|
||||
|
||||
options.Issuer.Should().Be("MonIssuer");
|
||||
options.Audience.Should().Be("MonAudience");
|
||||
options.ExpirationMinutes.Should().Be(60);
|
||||
}
|
||||
|
||||
// Validates: Requirements 6.3
|
||||
[Fact]
|
||||
public void AuthOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
// Configuration vide : les défauts doivent s'appliquer.
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>());
|
||||
|
||||
var options = new AuthOptions();
|
||||
config.GetSection(AuthOptions.SectionName).Bind(options);
|
||||
|
||||
options.ResetTokenLifetimeMinutes.Should().Be(60);
|
||||
options.FrontendBaseUrl.Should().Be("http://localhost:8080");
|
||||
}
|
||||
|
||||
// Validates: Requirements 6.7
|
||||
[Fact]
|
||||
public void SmtpOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>());
|
||||
|
||||
var options = new SmtpOptions();
|
||||
config.GetSection(SmtpOptions.SectionName).Bind(options);
|
||||
|
||||
options.Host.Should().BeEmpty();
|
||||
options.Port.Should().Be(587);
|
||||
options.User.Should().BeEmpty();
|
||||
options.Password.Should().BeEmpty();
|
||||
options.From.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Propriété de correction du hachage de mot de passe (feature authentification).
|
||||
/// Utilise le PasswordHasher<Compte> réel d'ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public class PasswordHasherProperties
|
||||
{
|
||||
// Feature: authentification, Property 1: Pour tout mot de passe non vide, le hachage produit une
|
||||
// valeur différente du clair, VerifyHashedPassword retourne Success, et deux hachages du même
|
||||
// mot de passe diffèrent (présence de sel).
|
||||
// Validates: Requirements 1.5, 8.1, 8.3
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Le_hachage_est_verifiable_et_sale()
|
||||
{
|
||||
// Générateur de mots de passe non vides et bornés (1..64 caractères imprimables).
|
||||
var arb = Arb.From(
|
||||
Gen.Choose(1, 64)
|
||||
.SelectMany(longueur =>
|
||||
Gen.ArrayOf(longueur, Gen.Choose(33, 126).Select(c => (char)c))
|
||||
.Select(chars => new string(chars))));
|
||||
|
||||
return Prop.ForAll(arb, motDePasse =>
|
||||
{
|
||||
var hasher = new PasswordHasher<Compte>();
|
||||
var compte = new Compte { Id = 1, Email = "test@example.com" };
|
||||
|
||||
var hash1 = hasher.HashPassword(compte, motDePasse);
|
||||
var hash2 = hasher.HashPassword(compte, motDePasse);
|
||||
|
||||
// Le hash diffère du clair.
|
||||
hash1.Should().NotBe(motDePasse);
|
||||
|
||||
// La vérification réussit.
|
||||
hasher.VerifyHashedPassword(compte, hash1, motDePasse)
|
||||
.Should().BeOneOf(PasswordVerificationResult.Success,
|
||||
PasswordVerificationResult.SuccessRehashNeeded);
|
||||
|
||||
// Deux hachages du même mot de passe diffèrent (sel aléatoire).
|
||||
hash1.Should().NotBe(hash2);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,18 @@ services:
|
||||
- ConnectionStrings__Default=Data Source=/data/trajets.db
|
||||
# La clé ORS est fournie par l'environnement / .env — jamais intégrée à l'image.
|
||||
- ORS__ApiKey=${ORS_API_KEY:-}
|
||||
# --- Authentification ---
|
||||
# :? fait échouer le démarrage si le secret est absent (jamais de clé vide).
|
||||
- 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 (facultatif) : Smtp__Host vide ⇒ repli journalisation du lien de reset ---
|
||||
- Smtp__Host=${SMTP_HOST:-}
|
||||
- Smtp__Port=${SMTP_PORT:-587}
|
||||
- Smtp__User=${SMTP_USER:-}
|
||||
- Smtp__Password=${SMTP_PASSWORD:-}
|
||||
- Smtp__From=${SMTP_FROM:-}
|
||||
volumes:
|
||||
- trajets-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/interceptors/auth.interceptor';
|
||||
import { timeoutInterceptor } from './core/interceptors/timeout.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -13,7 +14,11 @@ export const appConfig: ApplicationConfig = {
|
||||
// qui empêchait la détection de changement de se déclencher sur les réponses HTTP.
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
// TimeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9).
|
||||
provideHttpClient(withInterceptors([timeoutInterceptor]))
|
||||
// Intercepteurs HTTP (ordre significatif) :
|
||||
// - authInterceptor : injecte le Bearer JWT et gère les 401 (Exigence 4.7) ;
|
||||
// - timeoutInterceptor : timeout 10s + gestion des erreurs réseau (Exigence 1.9).
|
||||
// authInterceptor est placé AVANT timeoutInterceptor pour injecter le Bearer
|
||||
// avant le contrôle de timeout.
|
||||
provideHttpClient(withInterceptors([authInterceptor, timeoutInterceptor]))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { profilConfigureGuard } from './core/guards/profil-configure.guard';
|
||||
import { ForgotPasswordComponent } from './features/auth/forgot-password/forgot-password.component';
|
||||
import { LoginComponent } from './features/auth/login/login.component';
|
||||
import { RegisterComponent } from './features/auth/register/register.component';
|
||||
import { ResetPasswordComponent } from './features/auth/reset-password/reset-password.component';
|
||||
import { BaremeComponent } from './features/bareme/bareme.component';
|
||||
import { CarnetAdressesComponent } from './features/carnet-adresses/carnet-adresses.component';
|
||||
import { ConsultationJourComponent } from './features/consultation-jour/consultation-jour.component';
|
||||
@@ -14,33 +19,42 @@ import { VehiculesComponent } from './features/vehicules/vehicules.component';
|
||||
/**
|
||||
* Routes de l'application (équivalent standalone de `app-routing.module.ts`).
|
||||
*
|
||||
* Placeholders pour le socle :
|
||||
* - `/consultation` : vue principale de consultation des trajets (tâche 16.4).
|
||||
* - `/onboarding` : parcours de première utilisation (tâche 21.1), accessible sans garde.
|
||||
* Organisation de l'accès :
|
||||
* - Routes PUBLIQUES (sans garde) : `login`, `register`, `forgot-password`,
|
||||
* `reset-password`. Elles permettent à un utilisateur non authentifié de
|
||||
* s'inscrire, se connecter ou réinitialiser son mot de passe (onboarding Etape 1).
|
||||
* - Routes PROTEGEES par `authGuard` seul : `onboarding` (Etape 2, saisie du profil
|
||||
* et du premier véhicule — comportement inchangé), `profil` et `vehicules`.
|
||||
* `authGuard` est vérifiée en premier ; on n'y applique pas `profilConfigureGuard`
|
||||
* pour ne pas piéger un utilisateur partiellement configuré.
|
||||
* - Routes de données PROTEGEES par `[authGuard, profilConfigureGuard]` :
|
||||
* `consultation`, `carnet-adresses`, `resume-mensuel`, `resume-annuel`, `bareme`,
|
||||
* `montants-regles`. L'authentification est contrôlée d'abord, puis la
|
||||
* configuration du profil (redirection vers `/onboarding` si `estConfigure = false`).
|
||||
*
|
||||
* La garde `profilConfigureGuard` (tâche 21.2) protège l'accès à la saisie /
|
||||
* consultation des trajets et aux résumés tant que le profil n'est pas configuré.
|
||||
*
|
||||
* Décision de routage :
|
||||
* - Routes protégées par la garde (bloquées si `estConfigure = false`, redirection
|
||||
* vers `/onboarding`) : `consultation`, `carnet-adresses`, `resume-mensuel`,
|
||||
* `resume-annuel`, `bareme`, `montants-regles`.
|
||||
* - Routes laissées accessibles SANS garde pour permettre à l'utilisateur de terminer
|
||||
* sa configuration : `onboarding` (toujours atteignable), `profil` et `vehicules`.
|
||||
* Appliquer la garde à `/profil` / `/vehicules` risquerait de piéger un utilisateur
|
||||
* partiellement configuré ; la garde ne vise que la saisie/consultation des trajets
|
||||
* et les rapports (voir design « ProfilConfigureGuard »).
|
||||
* Redirections : `''` -> `consultation` et wildcard `**` -> `consultation`.
|
||||
*/
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'consultation', pathMatch: 'full' },
|
||||
{ path: 'consultation', component: ConsultationJourComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'carnet-adresses', component: CarnetAdressesComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'profil', component: ProfilComponent },
|
||||
{ path: 'vehicules', component: VehiculesComponent },
|
||||
{ path: 'resume-mensuel', component: ResumeMensuelComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'resume-annuel', component: ResumeAnnuelComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'bareme', component: BaremeComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'montants-regles', component: MontantsReglesComponent, canActivate: [profilConfigureGuard] },
|
||||
{ path: 'onboarding', component: OnboardingComponent },
|
||||
|
||||
// Routes PUBLIQUES (sans garde)
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
{ path: 'forgot-password', component: ForgotPasswordComponent },
|
||||
{ path: 'reset-password', component: ResetPasswordComponent },
|
||||
|
||||
// Routes PROTEGEES par authGuard seul (Etape 2 de l'onboarding + profil / véhicules)
|
||||
{ path: 'onboarding', component: OnboardingComponent, canActivate: [authGuard] },
|
||||
{ path: 'profil', component: ProfilComponent, canActivate: [authGuard] },
|
||||
{ path: 'vehicules', component: VehiculesComponent, canActivate: [authGuard] },
|
||||
|
||||
// Routes de données PROTEGEES : authGuard d'abord, puis profilConfigureGuard
|
||||
{ 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' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, UrlTree } from '@angular/router';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
RouterStateSnapshot
|
||||
} from '@angular/router';
|
||||
|
||||
import { authGuard } from './auth.guard';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Tests unitaires de la garde `authGuard` (CanActivateFn).
|
||||
*
|
||||
* Tâche 10.4 — Exigences 4.6 (routes protégées), 9.3 (redirection onboarding/login).
|
||||
* La garde est exécutée via `TestBed.runInInjectionContext` pour disposer du
|
||||
* contexte d'injection (`inject(...)`).
|
||||
*/
|
||||
describe('authGuard', () => {
|
||||
let estAuthentifieValeur: boolean;
|
||||
let urlTreeFactice: UrlTree;
|
||||
let createUrlTreeSpy: jasmine.Spy;
|
||||
|
||||
// Paramètres de la CanActivateFn (non utilisés par la garde, mais requis).
|
||||
const route = {} as ActivatedRouteSnapshot;
|
||||
const state = { url: '/consultation' } as RouterStateSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
urlTreeFactice = {} as UrlTree;
|
||||
|
||||
const authServiceMock: Partial<AuthService> = {
|
||||
estAuthentifie: (() => estAuthentifieValeur) as AuthService['estAuthentifie']
|
||||
};
|
||||
|
||||
createUrlTreeSpy = jasmine.createSpy('createUrlTree').and.returnValue(urlTreeFactice);
|
||||
const routerMock: Partial<Router> = {
|
||||
createUrlTree: createUrlTreeSpy as unknown as Router['createUrlTree']
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{ provide: Router, useValue: routerMock }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('retourne true lorsque l utilisateur est authentifié', () => {
|
||||
estAuthentifieValeur = true;
|
||||
|
||||
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(resultat).toBeTrue();
|
||||
expect(createUrlTreeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retourne un UrlTree vers /login lorsque l utilisateur n est pas authentifié', () => {
|
||||
estAuthentifieValeur = false;
|
||||
|
||||
const resultat = TestBed.runInInjectionContext(() => authGuard(route, state));
|
||||
|
||||
expect(createUrlTreeSpy).toHaveBeenCalledWith(['/login']);
|
||||
expect(resultat).toBe(urlTreeFactice);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router, UrlTree } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Garde de route `CanActivateFn` protégeant l'accès aux routes nécessitant une
|
||||
* authentification.
|
||||
*
|
||||
* Comportement (Exigences 4.6, 9.3) :
|
||||
* - Si `AuthService.estAuthentifie()` est vrai → navigation autorisée (retourne `true`).
|
||||
* - Sinon → redirection vers `/login` (retourne un `UrlTree`), ce qui bloque l'accès
|
||||
* à la route protégée tant que l'utilisateur n'est pas connecté. (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).
|
||||
*/
|
||||
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
return authService.estAuthentifie() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClient,
|
||||
provideHttpClient,
|
||||
withInterceptors
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting
|
||||
} from '@angular/common/http/testing';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Tests unitaires de l'intercepteur `authInterceptor`.
|
||||
*
|
||||
* Tâche 10.4 — Exigence 4.7 :
|
||||
* - injection du Bearer sur les appels API lorsqu'un token est présent ;
|
||||
* - pas de Bearer sur les endpoints publics d'auth ;
|
||||
* - sur une réponse 401 : purge du token (AuthService.clearToken) + navigation /login.
|
||||
*/
|
||||
describe('authInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let clearTokenSpy: jasmine.Spy;
|
||||
let navigateSpy: jasmine.Spy;
|
||||
let tokenCourant: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenCourant = 'jwt-abc';
|
||||
clearTokenSpy = jasmine.createSpy('clearToken');
|
||||
navigateSpy = jasmine.createSpy('navigate');
|
||||
|
||||
const authServiceMock: Partial<AuthService> = {
|
||||
getToken: () => tokenCourant,
|
||||
clearToken: clearTokenSpy as unknown as AuthService['clearToken']
|
||||
};
|
||||
const routerMock: Partial<Router> = {
|
||||
navigate: navigateSpy as unknown as Router['navigate']
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{ provide: Router, useValue: routerMock }
|
||||
]
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('ajoute l en-tête Authorization Bearer sur un appel API lorsqu un token est présent', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer jwt-abc');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('n ajoute pas le Bearer sur un endpoint public d auth (/auth/login)', () => {
|
||||
const url = `${environment.apiUrl}/auth/login`;
|
||||
http.post(url, { email: 'a@b.fr', motDePasse: 'x' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush({ token: 't', expiration: '2030-01-01T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('n ajoute pas le Bearer lorsqu aucun token n est présent', () => {
|
||||
tokenCourant = null;
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('purge le token et redirige vers /login sur une réponse 401', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe({
|
||||
next: () => fail('la requête aurait dû échouer'),
|
||||
error: () => {
|
||||
/* l erreur est propagée : attendu */
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
req.flush({ message: 'non autorisé' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(clearTokenSpy).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('ne purge pas le token sur une erreur non-401', () => {
|
||||
const url = `${environment.apiUrl}/trajets`;
|
||||
http.get(url).subscribe({
|
||||
next: () => fail('la requête aurait dû échouer'),
|
||||
error: () => {
|
||||
/* attendu */
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(url);
|
||||
req.flush({ message: 'erreur serveur' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(clearTokenSpy).not.toHaveBeenCalled();
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* Fragments de chemin correspondant aux endpoints publics d'authentification.
|
||||
* Aucune requête vers ces endpoints ne doit porter l'en-tête `Authorization`
|
||||
* (l'utilisateur n'est pas encore authentifié à ce stade).
|
||||
*/
|
||||
const ENDPOINTS_AUTH_PUBLICS = [
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/forgot-password',
|
||||
'/auth/reset-password',
|
||||
'/auth/compte-existe'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Intercepteur HTTP fonctionnel d'authentification (Exigence 4.7) :
|
||||
* - ajoute l'en-tête `Authorization: Bearer <token>` aux requêtes vers l'API
|
||||
* backend (`environment.apiUrl`) lorsqu'un token est présent, sauf pour les
|
||||
* endpoints publics d'auth et les appels externes (ex. BAN) ;
|
||||
* - sur une réponse en erreur 401, purge le token et redirige vers `/login`,
|
||||
* puis propage l'erreur.
|
||||
*
|
||||
* Il s'enregistre AVANT le `timeoutInterceptor` afin d'injecter le Bearer avant
|
||||
* le contrôle de timeout.
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const cibleApi = req.url.startsWith(environment.apiUrl);
|
||||
const estEndpointPublic = ENDPOINTS_AUTH_PUBLICS.some((chemin) => req.url.includes(chemin));
|
||||
const token = authService.getToken();
|
||||
|
||||
// On n'ajoute le Bearer qu'aux appels vers notre API backend, hors endpoints
|
||||
// publics d'auth, et uniquement lorsqu'un token est disponible. Les appels
|
||||
// externes (BAN, etc.) restent inchangés.
|
||||
const requete =
|
||||
cibleApi && !estEndpointPublic && token
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next(requete).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse && error.status === 401) {
|
||||
// Jeton absent, expiré ou invalide : purge sans double navigation puis
|
||||
// redirection explicite vers l'écran de connexion.
|
||||
authService.clearToken();
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Modèles Authentification — miroir de AuthDtos.cs (backend).
|
||||
|
||||
/** Identifiants de connexion — miroir de LoginDto. */
|
||||
export interface LoginDto {
|
||||
email: string;
|
||||
motDePasse: string;
|
||||
}
|
||||
|
||||
/** Données d'inscription — miroir de RegisterDto. */
|
||||
export interface RegisterDto {
|
||||
email: string;
|
||||
motDePasse: string;
|
||||
}
|
||||
|
||||
/** Résultat d'une connexion réussie — miroir de LoginResultDto. */
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
expiration: string;
|
||||
}
|
||||
|
||||
/** Réinitialisation du mot de passe — miroir de ResetPasswordDto. */
|
||||
export interface ResetPasswordDto {
|
||||
token: string;
|
||||
nouveauMotDePasse: string;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './vehicule.model';
|
||||
export * from './profil.model';
|
||||
export * from './bareme.model';
|
||||
export * from './adresse.model';
|
||||
export * from './auth.model';
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting
|
||||
} from '@angular/common/http/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginResult } from '../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Tests unitaires du service d'authentification.
|
||||
*
|
||||
* Tâche 10.4 — Exigences 2.3 (persistance du token / session), 3.1 (déconnexion).
|
||||
* Vérifie le stockage du JWT dans localStorage (clé `auth_token`), le reflet via
|
||||
* le signal `estAuthentifie()`/`getToken()`, et la purge à la déconnexion.
|
||||
*/
|
||||
describe('AuthService', () => {
|
||||
const CLE_TOKEN = 'auth_token';
|
||||
const baseUrl = `${environment.apiUrl}/auth`;
|
||||
|
||||
let service: AuthService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
// Environnement propre : aucun token résiduel avant construction du service.
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([])
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(AuthService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('démarre non authentifié lorsque localStorage est vide', () => {
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('login() envoie un POST vers {apiUrl}/auth/login', () => {
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${baseUrl}/login`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ email: 'a@b.fr', motDePasse: 'motdepasse' });
|
||||
|
||||
req.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
});
|
||||
|
||||
it('login() stocke le token dans localStorage et passe estAuthentifie() à true', () => {
|
||||
let resultat: LoginResult | undefined;
|
||||
service
|
||||
.login({ email: 'a@b.fr', motDePasse: 'motdepasse' })
|
||||
.subscribe((r) => (resultat = r));
|
||||
|
||||
const req = httpMock.expectOne(`${baseUrl}/login`);
|
||||
req.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
|
||||
expect(resultat?.token).toBe('jwt-123');
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBe('jwt-123');
|
||||
expect(service.getToken()).toBe('jwt-123');
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
});
|
||||
|
||||
it('logout() supprime le token de localStorage et repasse estAuthentifie() à false', () => {
|
||||
// On authentifie d'abord via login pour partir d'un état connecté réel.
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
httpMock
|
||||
.expectOne(`${baseUrl}/login`)
|
||||
.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
|
||||
service.logout();
|
||||
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBeNull();
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('clearToken() purge le token sans naviguer', () => {
|
||||
service.login({ email: 'a@b.fr', motDePasse: 'motdepasse' }).subscribe();
|
||||
httpMock
|
||||
.expectOne(`${baseUrl}/login`)
|
||||
.flush({ token: 'jwt-123', expiration: '2030-01-01T00:00:00Z' } as LoginResult);
|
||||
expect(service.estAuthentifie()).toBeTrue();
|
||||
|
||||
service.clearToken();
|
||||
|
||||
expect(localStorage.getItem(CLE_TOKEN)).toBeNull();
|
||||
expect(service.getToken()).toBeNull();
|
||||
expect(service.estAuthentifie()).toBeFalse();
|
||||
});
|
||||
|
||||
it('getToken() reflète le contenu de localStorage à l initialisation', () => {
|
||||
// Simule un token déjà présent au démarrage puis reconstruit le service.
|
||||
localStorage.setItem(CLE_TOKEN, 'token-persistant');
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([])
|
||||
]
|
||||
});
|
||||
|
||||
const serviceReinit = TestBed.inject(AuthService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
expect(serviceReinit.getToken()).toBe('token-persistant');
|
||||
expect(serviceReinit.estAuthentifie()).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { LoginDto, LoginResult, RegisterDto, ResetPasswordDto } from '../models';
|
||||
|
||||
/** Clé de stockage du JWT dans localStorage. */
|
||||
const CLE_TOKEN = 'auth_token';
|
||||
|
||||
/**
|
||||
* Service d'authentification.
|
||||
*
|
||||
* Expose l'état d'authentification via des signals (contrainte zoneless).
|
||||
* Le JWT est conservé dans localStorage pour une session persistante, et le
|
||||
* signal `_token` reflète en permanence l'état courant.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private readonly baseUrl = `${environment.apiUrl}/auth`;
|
||||
|
||||
/** Token courant (initialisé depuis localStorage au démarrage). */
|
||||
private readonly _token = signal<string | null>(this.lireTokenStocke());
|
||||
|
||||
/** Vrai lorsqu'un token est présent. */
|
||||
readonly estAuthentifie = computed(() => this._token() !== null);
|
||||
|
||||
/** Connexion (POST /auth/login) : stocke le token retourné sur succès. */
|
||||
login(dto: LoginDto): Observable<LoginResult> {
|
||||
return this.http
|
||||
.post<LoginResult>(`${this.baseUrl}/login`, dto)
|
||||
.pipe(tap((resultat) => this.enregistrerToken(resultat.token)));
|
||||
}
|
||||
|
||||
/** Inscription (POST /auth/register). */
|
||||
register(dto: RegisterDto): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/register`, dto);
|
||||
}
|
||||
|
||||
/** Demande de réinitialisation du mot de passe (POST /auth/forgot-password). */
|
||||
forgotPassword(email: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/forgot-password`, { email });
|
||||
}
|
||||
|
||||
/** Réinitialisation du mot de passe (POST /auth/reset-password). */
|
||||
resetPassword(dto: ResetPasswordDto): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/reset-password`, dto);
|
||||
}
|
||||
|
||||
/** Indique si un compte existe déjà (GET /auth/compte-existe). */
|
||||
compteExiste(): Observable<{ existe: boolean }> {
|
||||
return this.http.get<{ existe: boolean }>(`${this.baseUrl}/compte-existe`);
|
||||
}
|
||||
|
||||
/** Retourne le token courant, ou null si non authentifié. */
|
||||
getToken(): string | null {
|
||||
return this._token();
|
||||
}
|
||||
|
||||
/** Déconnexion : purge le token et redirige vers l'écran de connexion. */
|
||||
logout(): void {
|
||||
this.supprimerToken();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge le token sans naviguer.
|
||||
* Utilisé par l'`authInterceptor` sur une réponse 401 : l'intercepteur gère
|
||||
* lui-même la redirection vers `/login` (évite une double navigation).
|
||||
*/
|
||||
clearToken(): void {
|
||||
this.supprimerToken();
|
||||
}
|
||||
|
||||
/** Lecture initiale du token depuis localStorage. */
|
||||
private lireTokenStocke(): string | null {
|
||||
return localStorage.getItem(CLE_TOKEN);
|
||||
}
|
||||
|
||||
/** Écriture du token dans localStorage et mise à jour du signal. */
|
||||
private enregistrerToken(token: string): void {
|
||||
localStorage.setItem(CLE_TOKEN, token);
|
||||
this._token.set(token);
|
||||
}
|
||||
|
||||
/** Suppression du token dans localStorage et mise à jour du signal. */
|
||||
private supprimerToken(): void {
|
||||
localStorage.removeItem(CLE_TOKEN);
|
||||
this._token.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<section class="forgot-password">
|
||||
<h1>Mot de passe oublié</h1>
|
||||
|
||||
@if (envoye()) {
|
||||
<p class="message-generique" role="status">{{ messageGenerique }}</p>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
} @else {
|
||||
<p class="intro">
|
||||
Saisissez l'adresse email associée à votre compte. Si elle correspond à un
|
||||
compte, un lien de réinitialisation vous sera envoyé.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="demander()" novalidate>
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Envoi…' : 'Envoyer le lien' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,86 @@
|
||||
.forgot-password {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 1rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.message-generique {
|
||||
color: #1b5e20;
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
|
||||
/** Message générique affiché quel que soit le résultat (Exigence 6.6). */
|
||||
const MESSAGE_GENERIQUE =
|
||||
'Si un compte correspond à cette adresse, un lien de réinitialisation a été envoyé.';
|
||||
|
||||
/**
|
||||
* Écran de demande de réinitialisation du mot de passe (`/forgot-password`).
|
||||
*
|
||||
* Formulaire réactif (email obligatoire et syntaxiquement valide). À la
|
||||
* soumission, `AuthService.forgotPassword` est appelé ; dans les DEUX cas
|
||||
* (succès comme erreur), le composant bascule vers un message GÉNÉRIQUE
|
||||
* identique et masque le formulaire, afin de ne jamais révéler si l'adresse
|
||||
* correspond à un compte existant (principe de non-divulgation, Exigence 6.6).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `envoye`).
|
||||
*
|
||||
* _Requirements: 6.6_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-forgot-password',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './forgot-password.component.html',
|
||||
styleUrl: './forgot-password.component.scss'
|
||||
})
|
||||
export class ForgotPasswordComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Message générique commun (succès et erreur) — utilisé par le template. */
|
||||
readonly messageGenerique = MESSAGE_GENERIQUE;
|
||||
|
||||
/** Vrai pendant l'appel (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/**
|
||||
* Bascule à `true` après l'appel, quel que soit le résultat. Masque le
|
||||
* formulaire et affiche le message générique (non-divulgation, Exigence 6.6).
|
||||
*/
|
||||
readonly envoye = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]]
|
||||
});
|
||||
}
|
||||
|
||||
/** Demande l'envoi d'un lien de réinitialisation pour l'email saisi. */
|
||||
demander(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const email = (this.form.getRawValue().email ?? '').trim();
|
||||
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.forgotPassword(email).subscribe({
|
||||
// Succès comme erreur produisent exactement le même résultat visible.
|
||||
next: () => this.terminer(),
|
||||
error: () => this.terminer()
|
||||
});
|
||||
}
|
||||
|
||||
/** Finalise l'appel : affiche le message générique quel que soit le résultat. */
|
||||
private terminer(): void {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.envoye.set(true);
|
||||
this.notification.info(this.messageGenerique);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<section class="login">
|
||||
<h1>Connexion</h1>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="seConnecter()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="motDePasse">Mot de passe</label>
|
||||
<input
|
||||
id="motDePasse"
|
||||
type="password"
|
||||
formControlName="motDePasse"
|
||||
autocomplete="current-password"
|
||||
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
|
||||
aria-describedby="motDePasse-erreur"
|
||||
/>
|
||||
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
|
||||
<p id="motDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('motDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('motDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/forgot-password">Mot de passe oublié ?</a>
|
||||
@if (!compteExiste()) {
|
||||
<a routerLink="/register">Créer un compte</a>
|
||||
}
|
||||
</nav>
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.login {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { LoginDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Écran de connexion (`/login`) — Etape de session de l'authentification.
|
||||
*
|
||||
* Formulaire réactif (email + mot de passe). Sur succès, `AuthService.login`
|
||||
* stocke le JWT puis le composant navigue vers `/consultation` ; le routing et
|
||||
* la `profilConfigureGuard` redirigeront vers l'onboarding si le profil n'est
|
||||
* pas encore configuré. Sur 401, un message générique « Identifiants invalides. »
|
||||
* est affiché (Exigence 2.2).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`, `compteExiste`).
|
||||
*
|
||||
* _Requirements: 2.1, 2.3_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './login.component.html',
|
||||
styleUrl: './login.component.scss'
|
||||
})
|
||||
export class LoginComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Vrai pendant l'appel de connexion (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
/**
|
||||
* Indique si un compte existe déjà. Le lien d'inscription n'est affiché que
|
||||
* lorsqu'aucun compte n'existe (application mono-utilisateur).
|
||||
*/
|
||||
readonly compteExiste = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
motDePasse: ['', [Validators.required, Validators.minLength(8)]]
|
||||
});
|
||||
|
||||
this.authService.compteExiste().subscribe({
|
||||
next: (resultat) => this.compteExiste.set(resultat.existe),
|
||||
// En cas d'échec, on conserve la valeur par défaut (masque l'inscription).
|
||||
error: () => this.compteExiste.set(true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Tente la connexion avec les identifiants saisis. */
|
||||
seConnecter(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: LoginDto = {
|
||||
email: (valeur.email ?? '').trim(),
|
||||
motDePasse: valeur.motDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.login(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
// La cible protégée oriente ensuite vers l'onboarding si nécessaire.
|
||||
this.router.navigate(['/consultation']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
if (err.status === 401) {
|
||||
const message = 'Identifiants invalides.';
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
} else {
|
||||
const message = 'La connexion a échoué. Veuillez réessayer.';
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<section class="login">
|
||||
<h1>Créer un compte</h1>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="sInscrire()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="email">Adresse email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
formControlName="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
[attr.aria-invalid]="form.get('email')?.invalid && form.get('email')?.touched"
|
||||
aria-describedby="email-erreur"
|
||||
/>
|
||||
@if (form.get('email')?.touched && form.get('email')?.invalid) {
|
||||
<p id="email-erreur" class="erreur" role="alert">
|
||||
@if (form.get('email')?.hasError('required')) {
|
||||
L'adresse email est obligatoire.
|
||||
} @else if (form.get('email')?.hasError('email')) {
|
||||
L'adresse email n'est pas valide.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="motDePasse">Mot de passe</label>
|
||||
<input
|
||||
id="motDePasse"
|
||||
type="password"
|
||||
formControlName="motDePasse"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="form.get('motDePasse')?.invalid && form.get('motDePasse')?.touched"
|
||||
aria-describedby="motDePasse-erreur"
|
||||
/>
|
||||
@if (form.get('motDePasse')?.touched && form.get('motDePasse')?.invalid) {
|
||||
<p id="motDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('motDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('motDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="confirmationMotDePasse">Confirmation du mot de passe</label>
|
||||
<input
|
||||
id="confirmationMotDePasse"
|
||||
type="password"
|
||||
formControlName="confirmationMotDePasse"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="
|
||||
(form.get('confirmationMotDePasse')?.invalid || form.hasError('motsDePasseDifferents')) &&
|
||||
form.get('confirmationMotDePasse')?.touched
|
||||
"
|
||||
aria-describedby="confirmation-erreur"
|
||||
/>
|
||||
@if (form.get('confirmationMotDePasse')?.touched) {
|
||||
@if (form.get('confirmationMotDePasse')?.hasError('required')) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
La confirmation du mot de passe est obligatoire.
|
||||
</p>
|
||||
} @else if (form.hasError('motsDePasseDifferents')) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
Les mots de passe ne correspondent pas.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Création…' : 'Créer le compte' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Déjà un compte ? Se connecter</a>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.login {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { RegisterDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Validateur de groupe : vérifie l'égalité du mot de passe et de sa confirmation.
|
||||
*
|
||||
* Applique une erreur `motsDePasseDifferents` sur le groupe lorsque les deux
|
||||
* valeurs diffèrent. La confirmation vide n'est pas signalée ici (le validateur
|
||||
* `required` du contrôle s'en charge).
|
||||
*/
|
||||
function motsDePasseEgauxValidator(groupe: AbstractControl): ValidationErrors | null {
|
||||
const motDePasse = groupe.get('motDePasse')?.value ?? '';
|
||||
const confirmation = groupe.get('confirmationMotDePasse')?.value ?? '';
|
||||
if (confirmation.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return motDePasse === confirmation ? null : { motsDePasseDifferents: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran d'inscription (`/register`) — Etape 1 de l'onboarding.
|
||||
*
|
||||
* Formulaire réactif (email + mot de passe + confirmation), avec un validateur
|
||||
* de groupe garantissant l'égalité des deux mots de passe. Sur succès, une
|
||||
* notification invite l'utilisateur à se connecter et le composant redirige
|
||||
* vers `/login` (Exigences 1.6, 9.2).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`).
|
||||
*
|
||||
* _Requirements: 1.6, 9.1, 9.2_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './register.component.html',
|
||||
styleUrl: './register.component.scss'
|
||||
})
|
||||
export class RegisterComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Vrai pendant l'appel d'inscription (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group(
|
||||
{
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
motDePasse: ['', [Validators.required, Validators.minLength(8)]],
|
||||
confirmationMotDePasse: ['', [Validators.required]]
|
||||
},
|
||||
{ validators: motsDePasseEgauxValidator }
|
||||
);
|
||||
}
|
||||
|
||||
/** Tente l'inscription avec les informations saisies. */
|
||||
sInscrire(): void {
|
||||
if (this.form.invalid || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: RegisterDto = {
|
||||
email: (valeur.email ?? '').trim(),
|
||||
motDePasse: valeur.motDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.register(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.notification.success('Compte créé. Connectez-vous.');
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
let message: string;
|
||||
if (err.status === 409) {
|
||||
message = 'Un compte existe déjà.';
|
||||
} else if (err.status === 400) {
|
||||
message = "Les informations saisies ne sont pas valides.";
|
||||
} else {
|
||||
message = "L'inscription a échoué. Veuillez réessayer.";
|
||||
}
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<section class="reset-password">
|
||||
<h1>Réinitialiser le mot de passe</h1>
|
||||
|
||||
@if (token() === null) {
|
||||
<p class="erreur-globale" role="alert">
|
||||
Le lien de réinitialisation est incomplet ou invalide.
|
||||
Veuillez redemander un lien de réinitialisation.
|
||||
</p>
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/forgot-password">Redemander un lien</a>
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="reinitialiser()" novalidate>
|
||||
@if (erreur()) {
|
||||
<p class="erreur-globale" role="alert">{{ erreur() }}</p>
|
||||
}
|
||||
|
||||
<div class="champ">
|
||||
<label for="nouveauMotDePasse">Nouveau mot de passe</label>
|
||||
<input
|
||||
id="nouveauMotDePasse"
|
||||
type="password"
|
||||
formControlName="nouveauMotDePasse"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="
|
||||
form.get('nouveauMotDePasse')?.invalid && form.get('nouveauMotDePasse')?.touched
|
||||
"
|
||||
aria-describedby="nouveauMotDePasse-erreur"
|
||||
/>
|
||||
@if (form.get('nouveauMotDePasse')?.touched && form.get('nouveauMotDePasse')?.invalid) {
|
||||
<p id="nouveauMotDePasse-erreur" class="erreur" role="alert">
|
||||
@if (form.get('nouveauMotDePasse')?.hasError('required')) {
|
||||
Le mot de passe est obligatoire.
|
||||
} @else if (form.get('nouveauMotDePasse')?.hasError('minlength')) {
|
||||
Le mot de passe doit comporter au moins 8 caractères.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label for="confirmation">Confirmer le mot de passe</label>
|
||||
<input
|
||||
id="confirmation"
|
||||
type="password"
|
||||
formControlName="confirmation"
|
||||
autocomplete="new-password"
|
||||
[attr.aria-invalid]="
|
||||
(form.get('confirmation')?.touched &&
|
||||
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents')))
|
||||
"
|
||||
aria-describedby="confirmation-erreur"
|
||||
/>
|
||||
@if (
|
||||
form.get('confirmation')?.touched &&
|
||||
(form.get('confirmation')?.invalid || form.hasError('motsDePasseDifferents'))
|
||||
) {
|
||||
<p id="confirmation-erreur" class="erreur" role="alert">
|
||||
@if (form.get('confirmation')?.hasError('required')) {
|
||||
La confirmation est obligatoire.
|
||||
} @else if (form.hasError('motsDePasseDifferents')) {
|
||||
Les mots de passe ne correspondent pas.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-principal" [disabled]="soumissionEnCours()">
|
||||
{{ soumissionEnCours() ? 'Réinitialisation…' : 'Réinitialiser' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="liens" aria-label="Actions du compte">
|
||||
<a routerLink="/login">Retour à la connexion</a>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,81 @@
|
||||
.reset-password {
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.champ label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.champ input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.champ input[aria-invalid='true'] {
|
||||
border-color: #c62828;
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: #c62828;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.erreur-globale {
|
||||
color: #c62828;
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c6c2;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-principal {
|
||||
padding: 0.55rem 1.25rem;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border: 1px solid #1565c0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-principal:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.liens a {
|
||||
color: #1565c0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ActivatedRoute, convertToParamMap, ParamMap } from '@angular/router';
|
||||
|
||||
import { ResetPasswordComponent } from './reset-password.component';
|
||||
|
||||
/**
|
||||
* Tests unitaires du composant de réinitialisation du mot de passe.
|
||||
*
|
||||
* Tâche 11.5 — Exigence 7.6 : le composant lit le paramètre de requête `token`
|
||||
* depuis l'URL (`ActivatedRoute.snapshot.queryParamMap`) et l'expose via le
|
||||
* signal `token()`. Absence de paramètre => `token()` vaut null.
|
||||
*
|
||||
* Contrainte zoneless : le signal est lu par appel (`component.token()`).
|
||||
*/
|
||||
describe('ResetPasswordComponent', () => {
|
||||
function configurer(queryParamMap: ParamMap): ResetPasswordComponent {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ResetPasswordComponent],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { queryParamMap } }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return TestBed.createComponent(ResetPasswordComponent).componentInstance;
|
||||
}
|
||||
|
||||
it('lit et expose le token présent dans les query params (?token=abc)', () => {
|
||||
const component = configurer(convertToParamMap({ token: 'abc' }));
|
||||
expect(component.token()).toBe('abc');
|
||||
});
|
||||
|
||||
it('expose token()===null lorsqu aucun token n est fourni', () => {
|
||||
const component = configurer(convertToParamMap({}));
|
||||
expect(component.token()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ResetPasswordDto } from '../../../core/models';
|
||||
|
||||
/**
|
||||
* Validateur de groupe : vérifie l'égalité entre `nouveauMotDePasse` et
|
||||
* `confirmation`. Pose l'erreur `motsDePasseDifferents` sur le groupe lorsque
|
||||
* les deux valeurs diffèrent.
|
||||
*/
|
||||
function egaliteMotsDePasse(control: AbstractControl): ValidationErrors | null {
|
||||
const nouveau = control.get('nouveauMotDePasse')?.value ?? '';
|
||||
const confirmation = control.get('confirmation')?.value ?? '';
|
||||
return nouveau === confirmation ? null : { motsDePasseDifferents: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écran de réinitialisation du mot de passe (`/reset-password`).
|
||||
*
|
||||
* Lit le paramètre de requête `token` depuis l'URL (via `ActivatedRoute`) et le
|
||||
* conserve dans un signal. Si le token est absent, un message invite à
|
||||
* redemander un lien. Le formulaire saisit le nouveau mot de passe (>= 8
|
||||
* caractères) et sa confirmation (validateur d'égalité). Sur succès, une
|
||||
* notification est émise puis le composant navigue vers `/login` (Exigence 7.6).
|
||||
*
|
||||
* Contrainte zoneless : tout état async est exposé via des signals
|
||||
* (`soumissionEnCours`, `erreur`, `token`).
|
||||
*
|
||||
* _Requirements: 7.6_
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-reset-password',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
templateUrl: './reset-password.component.html',
|
||||
styleUrl: './reset-password.component.scss'
|
||||
})
|
||||
export class ResetPasswordComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly notification = inject(NotificationService);
|
||||
|
||||
/** Token de réinitialisation lu depuis l'URL (`?token=...`), ou null si absent. */
|
||||
readonly token = signal<string | null>(
|
||||
this.route.snapshot.queryParamMap.get('token')
|
||||
);
|
||||
|
||||
/** Vrai pendant l'appel de réinitialisation (désactive le bouton). */
|
||||
readonly soumissionEnCours = signal(false);
|
||||
|
||||
/** Message d'erreur courant, ou null si aucune erreur. */
|
||||
readonly erreur = signal<string | null>(null);
|
||||
|
||||
readonly form: FormGroup = this.fb.group(
|
||||
{
|
||||
nouveauMotDePasse: ['', [Validators.required, Validators.minLength(8)]],
|
||||
confirmation: ['', [Validators.required]]
|
||||
},
|
||||
{ validators: egaliteMotsDePasse }
|
||||
);
|
||||
|
||||
/** Tente la réinitialisation avec le nouveau mot de passe saisi. */
|
||||
reinitialiser(): void {
|
||||
const token = this.token();
|
||||
if (this.form.invalid || token === null || this.soumissionEnCours()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const valeur = this.form.getRawValue();
|
||||
const dto: ResetPasswordDto = {
|
||||
token,
|
||||
nouveauMotDePasse: valeur.nouveauMotDePasse ?? ''
|
||||
};
|
||||
|
||||
this.erreur.set(null);
|
||||
this.soumissionEnCours.set(true);
|
||||
this.authService.resetPassword(dto).subscribe({
|
||||
next: () => {
|
||||
this.soumissionEnCours.set(false);
|
||||
this.notification.success('Votre mot de passe a été réinitialisé.');
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.soumissionEnCours.set(false);
|
||||
let message: string;
|
||||
if (err.status === 400) {
|
||||
// ProblemDetails : le backend fournit un message précis
|
||||
// (lien expiré / lien invalide) via `detail` le cas échéant.
|
||||
message =
|
||||
err.error?.detail ??
|
||||
'Le lien de réinitialisation est invalide ou a expiré.';
|
||||
} else {
|
||||
message = 'La réinitialisation a échoué. Veuillez réessayer.';
|
||||
}
|
||||
this.erreur.set(message);
|
||||
this.notification.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user