Files
SchotzIAD/backend/SchotzIadKmTracker.Tests/Auth/JwtServiceProperties.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

215 lines
9.5 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using FsCheck;
using FsCheck.Xunit;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SchotzIadKmTracker.Api.Configuration;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Services;
namespace SchotzIadKmTracker.Tests.Auth;
/// <summary>
/// Propriétés de correction du service JWT (feature authentification).
/// Couvre l'émission/validation des jetons (Property 2), le rejet des jetons expirés
/// (Property 3) et le rejet des signatures invalides (Property 4).
/// </summary>
public class JwtServiceProperties
{
// Clé de signature >= 32 octets requise pour HS256 (sinon SecurityTokenEncryptionKeySizeException).
private const string CleSecrete = "clef-secrete-de-test-suffisamment-longue-0123456789";
private const string Issuer = "SchotzIadKmTracker";
private const string Audience = "SchotzIadKmTracker";
/// <summary>Construit un JwtService réel avec la durée d'expiration fournie.</summary>
private static JwtService CreerService(int expirationMinutes)
{
var options = Options.Create(new JwtOptions
{
SecretKey = CleSecrete,
Issuer = Issuer,
Audience = Audience,
ExpirationMinutes = expirationMinutes
});
return new JwtService(options);
}
/// <summary>Paramètres de validation alignés sur ceux du pipeline (Program.cs).</summary>
private static TokenValidationParameters CreerParametresValidation(string cleSecrete = CleSecrete)
=> new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = Issuer,
ValidAudience = Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cleSecrete)),
ClockSkew = TimeSpan.FromSeconds(30)
};
/// <summary>Générateur d'ExpirationMinutes borné (1..100000) pour éviter les valeurs absurdes.</summary>
private static Arbitrary<int> ExpirationMinutesArb()
=> Gen.Choose(1, 100_000).ToArbitrary();
/// <summary>Générateur d'emails non vides (bornés) pour les comptes de test.</summary>
private static Gen<string> EmailGen()
=> from local in Gen.Elements("alice", "bob", "carol", "utilisateur", "compte.unique")
from domaine in Gen.Elements("example.com", "test.fr", "mail.org")
select $"{local}@{domaine}";
// Feature: authentification, Property 2: Pour toute valeur d'ExpirationMinutes positive et tout
// compte, le jeton émis est validable avec les paramètres de validation, préserve les claims
// sub et email, et son exp vaut iat + ExpirationMinutes (à la tolérance d'horloge près).
// Validates: Requirements 2.1, 2.4, 5.1, 5.4, 5.5
[Property(MaxTest = 100)]
public Property Le_jeton_emis_est_validable_et_preserve_les_claims()
{
var arb = Arb.From(
from expiration in ExpirationMinutesArb().Generator
from id in Gen.Choose(1, 1_000_000)
from email in EmailGen()
select (expiration, id, email));
return Prop.ForAll(arb, tuple =>
{
var (expirationMinutes, id, email) = tuple;
var service = CreerService(expirationMinutes);
var compte = new Compte { Id = id, Email = email };
var jeton = service.GenererJeton(compte);
// Ne pas remapper les noms de claims entrants (sub reste "sub", email reste "email").
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
handler.ValidateToken(jeton, CreerParametresValidation(), out var validated);
var jwt = (JwtSecurityToken)validated;
// Claims sub et email préservés (lecture sur le jeton validé, noms d'origine).
var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value;
var emailClaim = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Email)?.Value;
sub.Should().Be(id.ToString());
emailClaim.Should().Be(email);
// exp ≈ iat + ExpirationMinutes (tolérance de 5 secondes pour l'écart d'exécution).
var ecart = jwt.ValidTo - jwt.ValidFrom;
ecart.Should().BeCloseTo(TimeSpan.FromMinutes(expirationMinutes), TimeSpan.FromSeconds(5));
});
}
// Feature: authentification, Property 3: Pour tout jeton dont la date d'expiration est antérieure
// à l'instant courant, la validation échoue (SecurityTokenExpiredException).
// Validates: Requirements 4.3, 5.2
[Property(MaxTest = 100)]
public Property Un_jeton_expire_est_rejete()
{
// On fabrique un jeton déjà expiré via le handler avec la même clé.
// minutesEcoulees > ClockSkew (30 s) pour dépasser la tolérance d'horloge.
var arb = Gen.Choose(2, 10_000).ToArbitrary();
return Prop.ForAll(arb, minutesEcoulees =>
{
var maintenant = DateTime.UtcNow;
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(CleSecrete));
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
var jetonExpire = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, "1") },
notBefore: maintenant.AddMinutes(-minutesEcoulees - 1),
expires: maintenant.AddMinutes(-minutesEcoulees),
signingCredentials: credentials);
var jeton = new JwtSecurityTokenHandler().WriteToken(jetonExpire);
var handler = new JwtSecurityTokenHandler();
Action validation = () => handler.ValidateToken(jeton, CreerParametresValidation(), out _);
validation.Should().Throw<SecurityTokenExpiredException>();
});
}
// Feature: authentification, Property 4: Pour tout jeton signé avec une clé différente de la clé
// de validation, ou dont la charge utile a été altérée, la validation échoue.
// Validates: Requirements 5.3, 4.2
[Property(MaxTest = 100)]
public Property Une_signature_invalide_est_rejetee()
{
// Deux cas : (a) jeton signé avec une autre clé, (b) charge utile altérée.
var arb = Arb.From(
from id in Gen.Choose(1, 1_000_000)
from email in EmailGen()
from altererCharge in Gen.Elements(true, false)
select (id, email, altererCharge));
return Prop.ForAll(arb, tuple =>
{
var (id, email, altererCharge) = tuple;
var handler = new JwtSecurityTokenHandler();
var parametres = CreerParametresValidation();
string jeton;
if (altererCharge)
{
// Jeton valide dont on altère la charge utile (segment central).
var service = CreerService(720);
var valide = service.GenererJeton(new Compte { Id = id, Email = email });
var segments = valide.Split('.');
// Inverser deux caractères de la charge (payload) pour casser la signature.
var charge = segments[1].ToCharArray();
if (charge.Length >= 2)
{
(charge[0], charge[1]) = (charge[1], charge[0]);
}
else
{
charge = (segments[1] + "X").ToCharArray();
}
segments[1] = new string(charge);
jeton = string.Join('.', segments);
}
else
{
// Jeton signé avec une clé DIFFÉRENTE (>= 32 octets) de celle de validation.
const string autreCle = "AUTRE-clef-secrete-differente-mais-assez-longue-9876543210";
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(autreCle));
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
var maintenant = DateTime.UtcNow;
var jwt = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: new[]
{
new Claim(JwtRegisteredClaimNames.Sub, id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email)
},
notBefore: maintenant,
expires: maintenant.AddMinutes(720),
signingCredentials: credentials);
jeton = new JwtSecurityTokenHandler().WriteToken(jwt);
}
// La validation doit échouer : soit une exception de signature (SecurityTokenException,
// p. ex. SecurityTokenInvalidSignatureException / SecurityTokenSignatureKeyNotFoundException),
// soit un rejet au décodage lorsque la charge altérée n'est plus décodable (ArgumentException).
Exception? levee = null;
try
{
handler.ValidateToken(jeton, parametres, out _);
}
catch (Exception ex)
{
levee = ex;
}
levee.Should().NotBeNull("un jeton à signature invalide ou charge altérée doit être rejeté");
levee.Should().Match(e => e is SecurityTokenException || e is ArgumentException);
});
}
}