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:
2026-09-14 09:07:08 +02:00
parent 0e6a97ca61
commit dd36bbc425
66 changed files with 6282 additions and 27 deletions
@@ -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)));
}
}