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,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();
}
}