Files
MadJax ee47d04b90 Navigation utilisateur : avatar, paramétrage, maintenance, mot de passe
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.
2026-09-22 09:54:25 +02:00

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