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; /// public class AuthService : IAuthService { private readonly AppDbContext _context; private readonly IPasswordHasher _hasher; private readonly IJwtService _jwt; private readonly IEmailService _email; private readonly AuthOptions _authOptions; private readonly JwtOptions _jwtOptions; private readonly ILogger _logger; public AuthService( AppDbContext context, IPasswordHasher hasher, IJwtService jwt, IEmailService email, IOptions authOptions, IOptions jwtOptions, ILogger logger) { _context = context; _hasher = hasher; _jwt = jwt; _email = email; _authOptions = authOptions.Value; _jwtOptions = jwtOptions.Value; _logger = logger; } /// public async Task CompteExisteAsync(CancellationToken ct) { // Vrai s'il existe au moins un compte (Exigences 1.2, 9.1). return await _context.Comptes.AnyAsync(ct); } /// 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); } /// public async Task 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); } /// 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); } /// 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; } } /// /// Génère un jeton aléatoire cryptographiquement sûr (32 octets) encodé en base64url. /// private static string GenererTokenAleatoire() { var octets = RandomNumberGenerator.GetBytes(32); return Base64UrlEncode(octets); } /// /// Calcule le hachage SHA-256 d'un jeton et le retourne en base64. /// private static string HacherToken(string token) { var octets = SHA256.HashData(Encoding.UTF8.GetBytes(token)); return Convert.ToBase64String(octets); } /// /// Encode des octets en base64url (sans remplissage), adapté à une transmission en query string. /// private static string Base64UrlEncode(byte[] octets) { return Convert.ToBase64String(octets) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } }