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

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

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

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

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

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

218 lines
7.8 KiB
C#

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