Réorganisation complète de la navigation suite au retour d'usage, plus changement de mot de passe et affichage/masquage des mots de passe saisis. Backend : - Nouvel endpoint POST /api/auth/change-password (ChangePasswordDto, MotDePasseActuelInvalideException, validateur, AuthService.ChangePasswordAsync). Exige l'ancien mot de passe, distinct de la réinitialisation par e-mail. - Endpoint placé dans SessionController (protégé par l'autorisation globale) et NON dans AuthController : AuthController porte [AllowAnonymous] au niveau classe, ce qui rend inopérant un [Authorize] posé sur une seule action (bug de sécurité détecté par test manuel — l'endpoint était accessible sans jeton — puis corrigé par ce déplacement). Frontend : - Page « Compte utilisateur » (ProfilComponent renommé) : section Maintenance extraite vers MaintenanceComponent (route /maintenance) ; nouvelle section de changement de mot de passe (ancien + nouveau + confirmation). - ParametrageComponent (route /parametrage) : menu latéral Compte / Véhicules / Barème + contenu en routes enfants, réutilisant les composants existants. Compte et Véhicules en authGuard seul ; Barème garde profilConfigureGuard. - Barre de navigation : Véhicules et Barème retirés (rejoignent Paramétrage) ; logo passé en format horizontal (icône + texte). - Avatar utilisateur (initiales) à droite de la barre : sous-menu au survol (identité, Paramétrage, Maintenance, Se déconnecter). Menu « Specs » converti en dropdown survolable (Exigences / Conception / Tâches). - Dropdowns : panneau collé au bouton (plus d'espace mort) pour que le survol reste continu jusqu'au panneau — un espace créait une fermeture prématurée. - Barre masquée sur les pages publiques (login/register/forgot/reset-password) même avec un jeton résiduel expiré/invalide (détection via Router events, pas seulement la présence du jeton). - PasswordInputComponent (shared) : bouton œil afficher/masquer, ControlValueAccessor, style aligné sur les champs texte classiques. Appliqué aux 8 champs mot de passe de l'application (login, register, reset-password, compte utilisateur). Specs : requirements.md (exigences 20, 21 + glossaire) et design.md (endpoints, DTO, composants) mis à jour. 54 tests backend au vert.
242 lines
8.9 KiB
C#
242 lines
8.9 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;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task ChangePasswordAsync(ChangePasswordDto dto, CancellationToken ct)
|
|
{
|
|
var compte = await _context.Comptes.FirstOrDefaultAsync(ct);
|
|
if (compte is null)
|
|
{
|
|
// Cas théorique (endpoint protégé, donc un compte existe forcément) : traité
|
|
// comme un mot de passe actuel invalide, sans distinction pour l'appelant.
|
|
throw new MotDePasseActuelInvalideException("Le mot de passe actuel est incorrect.");
|
|
}
|
|
|
|
var resultat = _hasher.VerifyHashedPassword(compte, compte.MotDePasseHash, dto.MotDePasseActuel);
|
|
if (resultat == PasswordVerificationResult.Failed)
|
|
{
|
|
throw new MotDePasseActuelInvalideException("Le mot de passe actuel est incorrect.");
|
|
}
|
|
|
|
// La longueur >= 8 du nouveau mot de passe est garantie par FluentValidation en amont.
|
|
compte.MotDePasseHash = _hasher.HashPassword(compte, dto.NouveauMotDePasse);
|
|
compte.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
/// <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('/', '_');
|
|
}
|
|
}
|