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:
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) du service d'authentification,
|
||||
/// couvrant les Correctness Properties P5–P14 de la conception.
|
||||
///
|
||||
/// Les tests de flux s'exécutent sur un <see cref="AuthTestHarness"/> avec une base SQLite
|
||||
/// en mémoire recréée à chaque cas (isolation), un vrai PasswordHasher / JwtService et un
|
||||
/// IEmailService mocké.
|
||||
/// </summary>
|
||||
public class AuthServiceProperties
|
||||
{
|
||||
// --- Générateurs contraints à l'espace d'entrée pertinent ------------------
|
||||
|
||||
/// <summary>Emails syntaxiquement valides (locale@domaine.tld).</summary>
|
||||
private static Arbitrary<string> EmailArb()
|
||||
{
|
||||
var lettre = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789".ToCharArray());
|
||||
Gen<string> partie(int min, int max) =>
|
||||
from n in Gen.Choose(min, max)
|
||||
from cs in Gen.ArrayOf(n, lettre)
|
||||
select new string(cs);
|
||||
|
||||
var tld = Gen.Elements("com", "fr", "org", "net", "io");
|
||||
|
||||
var email =
|
||||
from local in partie(1, 10)
|
||||
from domaine in partie(1, 10)
|
||||
from t in tld
|
||||
select $"{local}@{domaine}.{t}";
|
||||
|
||||
return Arb.From(email);
|
||||
}
|
||||
|
||||
/// <summary>Mots de passe d'au moins 8 caractères (imprimables ASCII).</summary>
|
||||
private static Arbitrary<string> MotDePasseValideArb()
|
||||
{
|
||||
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%".ToCharArray());
|
||||
var g =
|
||||
from n in Gen.Choose(8, 24)
|
||||
from cs in Gen.ArrayOf(n, carac)
|
||||
select new string(cs);
|
||||
return Arb.From(g);
|
||||
}
|
||||
|
||||
private static Property ForEmailEtMdp(Func<string, string, Task> corps)
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), (email, mdp) =>
|
||||
{
|
||||
corps(email, mdp).GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Feature: authentification, Property 5: L'inscription crée le compte lorsqu'aucun compte n'existe
|
||||
// Pour toute paire (email valide, mdp >= 8), base vide + RegisterAsync => exactement 1 compte avec cet email.
|
||||
// Validates: Requirements 1.1
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P5_Inscription_cree_le_compte_sur_base_vide()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
var comptes = await h.Context.Comptes.ToListAsync();
|
||||
comptes.Should().ContainSingle();
|
||||
comptes.Single().Email.Should().Be(email.Trim());
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 6: L'inscription est rejetée lorsqu'un compte existe déjà
|
||||
// Compte déjà présent + 2e RegisterAsync => CompteDejaExistantException, toujours 1 compte.
|
||||
// Validates: Requirements 1.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P6_Deuxieme_inscription_rejetee()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), EmailArb(), MotDePasseValideArb(),
|
||||
(email1, email2, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
|
||||
h.Service.RegisterAsync(new RegisterDto(email1, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
Func<Task> deuxieme = () =>
|
||||
h.Service.RegisterAsync(new RegisterDto(email2, mdp), CancellationToken.None);
|
||||
|
||||
deuxieme.Should().ThrowAsync<CompteDejaExistantException>().GetAwaiter().GetResult();
|
||||
|
||||
h.Context.Comptes.Count().Should().Be(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 8: La connexion émet un jeton sur identifiants corrects et rejette les invalides
|
||||
// Bons identifiants => LoginResultDto avec token non vide validable ;
|
||||
// mauvais mdp ou email inconnu => IdentifiantsInvalidesException.
|
||||
// Validates: Requirements 2.1, 2.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P8_Login_emet_jeton_ou_rejette()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
// Bons identifiants => token validable.
|
||||
var resultat = await h.Service.LoginAsync(new LoginDto(email, mdp), CancellationToken.None);
|
||||
resultat.Token.Should().NotBeNullOrWhiteSpace();
|
||||
JwtTestUtils.EstValide(resultat.Token, AuthTestHarness.SecretKey).Should().BeTrue();
|
||||
|
||||
// Mauvais mot de passe => rejet.
|
||||
Func<Task> mauvaisMdp = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, mdp + "X"), CancellationToken.None);
|
||||
await mauvaisMdp.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
|
||||
// Email inconnu => rejet.
|
||||
Func<Task> emailInconnu = () =>
|
||||
h.Service.LoginAsync(new LoginDto("inconnu-" + email, mdp), CancellationToken.None);
|
||||
await emailInconnu.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 9: Non-divulgation lors de la demande de réinitialisation
|
||||
// ForgotPasswordAsync ne lève jamais ; email inconnu => aucun token créé et email NON appelé ;
|
||||
// email du compte => exactement un token créé et EnvoyerLienResetAsync appelé une fois.
|
||||
// Validates: Requirements 6.6
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P9_Non_divulgation_forgot_password()
|
||||
{
|
||||
return ForEmailEtMdp(async (email, mdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None);
|
||||
|
||||
// Email inconnu : ne lève pas, ne crée aucun token, n'appelle pas l'email.
|
||||
Func<Task> inconnu = () =>
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto("autre-" + email), CancellationToken.None);
|
||||
await inconnu.Should().NotThrowAsync();
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(0);
|
||||
h.NbAppelsEmail.Should().Be(0);
|
||||
|
||||
// Email du compte : ne lève pas, crée exactement un token, appelle l'email une fois.
|
||||
Func<Task> connu = () =>
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None);
|
||||
await connu.Should().NotThrowAsync();
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(1);
|
||||
h.NbAppelsEmail.Should().Be(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 10: Expiration du jeton de reset paramétrée
|
||||
// Pour toute ResetTokenLifetimeMinutes > 0, le token créé a Expiration ≈ CreatedAt + minutes et UsedAt == null.
|
||||
// Validates: Requirements 6.1, 6.2, 6.3
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P10_Expiration_reset_parametree()
|
||||
{
|
||||
var minutesArb = Arb.From(Gen.Choose(1, 100_000));
|
||||
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), minutesArb, (email, mdp, minutes) =>
|
||||
{
|
||||
using var h = new AuthTestHarness(resetTokenLifetimeMinutes: minutes);
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.UsedAt.Should().BeNull();
|
||||
|
||||
var attendu = token.CreatedAt.AddMinutes(minutes);
|
||||
token.Expiration.Should().BeCloseTo(attendu, TimeSpan.FromSeconds(5));
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 11: Une réinitialisation valide remplace le mot de passe
|
||||
// Après ForgotPassword (token capturé via le lien mocké), ResetPasswordAsync(token, nouveauMdp>=8)
|
||||
// => LoginAsync réussit avec le nouveau mdp et échoue avec l'ancien.
|
||||
// Validates: Requirements 7.1
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P11_Reset_valide_remplace_mdp()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, ancienMdp, nouveauMdp) =>
|
||||
{
|
||||
// On veut deux mots de passe distincts pour tester l'ancien vs le nouveau.
|
||||
if (ancienMdp == nouveauMdp)
|
||||
{
|
||||
nouveauMdp += "Z9";
|
||||
}
|
||||
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, ancienMdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Login OK avec le nouveau mot de passe.
|
||||
Func<Task> nouveau = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, nouveauMdp), CancellationToken.None);
|
||||
nouveau.Should().NotThrowAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Login KO avec l'ancien mot de passe.
|
||||
Func<Task> ancien = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, ancienMdp), CancellationToken.None);
|
||||
ancien.Should().ThrowAsync<IdentifiantsInvalidesException>().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 12: Un jeton de reset est à usage unique
|
||||
// Un token utilisé une fois => 2e ResetPasswordAsync avec le même token lève LienResetInvalideException ; UsedAt renseigné.
|
||||
// Validates: Requirements 7.2, 7.4
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P12_Reset_usage_unique()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, mdp, nouveau) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
// Premier usage : succès.
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// UsedAt renseigné.
|
||||
h.Context.ResetPasswordTokens.Single().UsedAt.Should().NotBeNull();
|
||||
|
||||
// Deuxième usage du même token : rejeté (avant toute prise en compte du mot de passe).
|
||||
Func<Task> deuxieme = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None);
|
||||
deuxieme.Should().ThrowAsync<LienResetInvalideException>().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 13: Un jeton de reset expiré ou inconnu est rejeté
|
||||
// Token expiré (Expiration forcée dans le passé) => LienResetExpireException ;
|
||||
// token inconnu => LienResetInvalideException ; le mdp du compte reste inchangé.
|
||||
// Validates: Requirements 7.3, 7.4
|
||||
[Property(MaxTest = 100)]
|
||||
public Property P13_Reset_expire_ou_inconnu_rejete()
|
||||
{
|
||||
return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(),
|
||||
(email, mdp, nouveauMdp) =>
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
// --- Token inconnu de la base ---
|
||||
Func<Task> inconnu = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto("token-inexistant", nouveauMdp), CancellationToken.None);
|
||||
inconnu.Should().ThrowAsync<LienResetInvalideException>().GetAwaiter().GetResult();
|
||||
|
||||
// --- Token expiré : on génère un token via forgot puis on force Expiration dans le passé ---
|
||||
h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.Expiration = DateTime.UtcNow.AddMinutes(-1);
|
||||
h.Context.SaveChanges();
|
||||
|
||||
Func<Task> expire = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None);
|
||||
expire.Should().ThrowAsync<LienResetExpireException>().GetAwaiter().GetResult();
|
||||
|
||||
// Le mot de passe du compte reste inchangé dans les deux cas.
|
||||
h.Context.Entry(h.Context.Comptes.Single()).Reload();
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user