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:
2026-09-14 09:07:08 +02:00
parent 0e6a97ca61
commit dd36bbc425
66 changed files with 6282 additions and 27 deletions
@@ -0,0 +1,13 @@
namespace SchotzIadKmTracker.Api.Configuration;
/// <summary>Options de configuration de l'authentification (réinitialisation de mot de passe).</summary>
public class AuthOptions
{
public const string SectionName = "Auth";
/// <summary>Durée de validité d'un jeton de réinitialisation en minutes.</summary>
public int ResetTokenLifetimeMinutes { get; set; } = 60;
/// <summary>URL de base du frontend, utilisée pour construire le lien de réinitialisation.</summary>
public string FrontendBaseUrl { get; set; } = "http://localhost:8080";
}
@@ -0,0 +1,19 @@
namespace SchotzIadKmTracker.Api.Configuration;
/// <summary>Options de configuration des jetons JWT d'authentification.</summary>
public class JwtOptions
{
public const string SectionName = "Jwt";
/// <summary>Clé secrète utilisée pour signer les jetons (HS256).</summary>
public string SecretKey { get; set; } = string.Empty;
/// <summary>Émetteur (issuer) des jetons.</summary>
public string Issuer { get; set; } = "SchotzIadKmTracker";
/// <summary>Audience attendue des jetons.</summary>
public string Audience { get; set; } = "SchotzIadKmTracker";
/// <summary>Durée de validité d'un jeton en minutes (720 = 12 h).</summary>
public int ExpirationMinutes { get; set; } = 720;
}
@@ -0,0 +1,22 @@
namespace SchotzIadKmTracker.Api.Configuration;
/// <summary>Options de configuration du serveur SMTP pour l'envoi d'emails.</summary>
public class SmtpOptions
{
public const string SectionName = "Smtp";
/// <summary>Hôte du serveur SMTP (vide = envoi désactivé, repli sur la journalisation).</summary>
public string Host { get; set; } = string.Empty;
/// <summary>Port du serveur SMTP.</summary>
public int Port { get; set; } = 587;
/// <summary>Nom d'utilisateur pour l'authentification SMTP.</summary>
public string User { get; set; } = string.Empty;
/// <summary>Mot de passe pour l'authentification SMTP.</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Adresse email expéditrice.</summary>
public string From { get; set; } = string.Empty;
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SchotzIadKmTracker.Api.Models.DTOs;
using SchotzIadKmTracker.Api.Services;
using SchotzIadKmTracker.Api.Services.Exceptions;
namespace SchotzIadKmTracker.Api.Controllers;
/// <summary>
/// Endpoints publics d'authentification de l'application mono-utilisateur :
/// inscription, connexion, demande et application de la réinitialisation du
/// mot de passe, ainsi que la vérification de l'existence du compte.
/// Toutes les routes sont anonymes (<see cref="AllowAnonymousAttribute"/>) : elles
/// restent accessibles même une fois l'autorisation globale activée (Exigence 9.1).
/// Les erreurs sont renvoyées au format <c>ProblemDetails</c>, comme les autres controllers.
/// </summary>
[ApiController]
[Route("api/auth")]
[AllowAnonymous]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
/// <summary>
/// Inscrit le compte unique de l'application. Les erreurs de validation
/// (email invalide, mot de passe trop court) sont renvoyées automatiquement
/// en 400 par FluentValidation (Exigences 1.3, 1.4). Un compte déjà existant
/// est rejeté en 409 (Exigence 1.2).
/// </summary>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Register(
[FromBody] RegisterDto dto,
CancellationToken cancellationToken)
{
try
{
await _authService.RegisterAsync(dto, cancellationToken);
return Ok();
}
catch (CompteDejaExistantException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status409Conflict);
}
}
/// <summary>
/// Authentifie le compte et retourne le jeton JWT accompagné de sa date
/// d'expiration. En cas d'identifiants invalides, renvoie un 401 avec un
/// message générique afin de ne pas distinguer email inconnu et mot de passe
/// erroné (Exigences 2.1, 2.2).
/// </summary>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<LoginResultDto>> Login(
[FromBody] LoginDto dto,
CancellationToken cancellationToken)
{
try
{
var resultat = await _authService.LoginAsync(dto, cancellationToken);
return Ok(resultat);
}
catch (IdentifiantsInvalidesException)
{
return Problem(detail: "Identifiants invalides.", statusCode: StatusCodes.Status401Unauthorized);
}
}
/// <summary>
/// Traite une demande de réinitialisation de mot de passe. Par principe de
/// non-divulgation, renvoie toujours 200 avec la même réponse, que l'email
/// corresponde ou non à un compte (Exigence 6.6). La logique de non-divulgation
/// est portée par le service ; les erreurs techniques réelles remontent.
/// </summary>
[HttpPost("forgot-password")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ForgotPassword(
[FromBody] ForgotPasswordDto dto,
CancellationToken cancellationToken)
{
await _authService.ForgotPasswordAsync(dto, cancellationToken);
return Ok();
}
/// <summary>
/// Réinitialise le mot de passe à partir d'un jeton de réinitialisation.
/// Un jeton expiré (Exigence 7.3) ou invalide/déjà utilisé (Exigence 7.4)
/// est rejeté en 400. Les erreurs de validation du nouveau mot de passe sont
/// renvoyées automatiquement en 400 par FluentValidation (Exigence 7.5).
/// </summary>
[HttpPost("reset-password")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ResetPassword(
[FromBody] ResetPasswordDto dto,
CancellationToken cancellationToken)
{
try
{
await _authService.ResetPasswordAsync(dto, cancellationToken);
return Ok();
}
catch (LienResetExpireException)
{
return Problem(detail: "Le lien de réinitialisation a expiré.", statusCode: StatusCodes.Status400BadRequest);
}
catch (LienResetInvalideException)
{
return Problem(detail: "Le lien de réinitialisation est invalide.", statusCode: StatusCodes.Status400BadRequest);
}
}
/// <summary>
/// Indique si un compte existe déjà, afin d'orienter le frontend entre
/// inscription et connexion (Exigences 1.2, 9.1).
/// </summary>
[HttpGet("compte-existe")]
[ProducesResponseType(typeof(CompteExisteDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CompteExisteDto>> CompteExiste(CancellationToken cancellationToken)
{
return Ok(new CompteExisteDto(await _authService.CompteExisteAsync(cancellationToken)));
}
}
@@ -20,6 +20,8 @@ public class AppDbContext : DbContext
public DbSet<AdresseNommee> AdressesNommees => Set<AdresseNommee>();
public DbSet<TrancheBareme> TranchesBareme => Set<TrancheBareme>();
public DbSet<MontantRegle> MontantsRegles => Set<MontantRegle>();
public DbSet<Compte> Comptes => Set<Compte>();
public DbSet<ResetPasswordToken> ResetPasswordTokens => Set<ResetPasswordToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -110,5 +112,22 @@ public class AppDbContext : DbContext
.HasForeignKey(m => m.VehiculeId)
.OnDelete(DeleteBehavior.Restrict);
});
// --- Comptes (singleton Id = 1) ---
modelBuilder.Entity<Compte>(entity =>
{
entity.ToTable("Comptes");
entity.HasKey(c => c.Id);
entity.Property(c => c.Id).ValueGeneratedNever();
entity.HasIndex(c => c.Email, "UX_Comptes_Email").IsUnique();
});
// --- ResetPasswordTokens (jetons de réinitialisation à usage unique) ---
modelBuilder.Entity<ResetPasswordToken>(entity =>
{
entity.ToTable("ResetPasswordTokens");
entity.HasIndex(t => t.TokenHash, "UX_ResetTokens_TokenHash").IsUnique();
entity.HasIndex(t => t.Expiration, "IX_ResetTokens_Expiration");
});
}
}
@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using SchotzIadKmTracker.Api.Data;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260914064431_AjoutAuthentification")]
partial class AjoutAuthentification
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("DerniereUtilisation")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<double>("Latitude")
.HasColumnType("REAL");
b.Property<double>("Longitude")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("Adresse")
.IsUnique();
b.ToTable("AdresseCache", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.AdresseNommee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Adresse")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("DenominationNormalisee")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("GeocodageReussi")
.HasColumnType("INTEGER");
b.Property<double?>("Latitude")
.HasColumnType("REAL");
b.Property<double?>("Longitude")
.HasColumnType("REAL");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "DenominationNormalisee" }, "UX_AdresseNommee_Denomination")
.IsUnique();
b.ToTable("AdressesNommees", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("MotDePasseHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
.IsUnique();
b.ToTable("Comptes", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<int>("Mois")
.HasColumnType("INTEGER");
b.Property<decimal>("Montant")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VehiculeId");
b.HasIndex(new[] { "Annee", "Mois", "VehiculeId" }, "UX_MontantsRegles_Periode_Vehicule")
.IsUnique();
b.ToTable("MontantsRegles", null, t =>
{
t.HasCheckConstraint("CK_MontantsRegles_Mois", "\"Mois\" BETWEEN 1 AND 12");
t.HasCheckConstraint("CK_MontantsRegles_Montant", "\"Montant\" >= 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Profil", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<string>("AdresseDomicile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("DomicileGeocode")
.HasColumnType("INTEGER");
b.Property<double?>("DomicileLatitude")
.HasColumnType("REAL");
b.Property<double?>("DomicileLongitude")
.HasColumnType("REAL");
b.Property<string>("Matricule")
.HasMaxLength(8)
.HasColumnType("TEXT");
b.Property<string>("Nom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Prenom")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Id" }, "UX_Profil_Singleton")
.IsUnique();
b.ToTable("Profil", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("Expiration")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
.IsUnique();
b.ToTable("ResetPasswordTokens", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdresseArrivee")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AdresseDepart")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Commentaire")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<decimal?>("Distance")
.HasColumnType("TEXT");
b.Property<string>("GeometrieJson")
.HasColumnType("TEXT");
b.Property<decimal>("Position")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("VehiculeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Date" }, "IX_Trajets_Date");
b.HasIndex(new[] { "Date", "Position" }, "IX_Trajets_Date_Position");
b.HasIndex(new[] { "VehiculeId" }, "IX_Trajets_Vehicule");
b.ToTable("Trajets", null, t =>
{
t.HasCheckConstraint("CK_Trajets_Distance", "\"Distance\" IS NULL OR \"Distance\" > 0");
t.HasCheckConstraint("CK_Trajets_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.TrancheBareme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Annee")
.HasColumnType("INTEGER");
b.Property<decimal>("Base")
.HasColumnType("TEXT");
b.Property<decimal>("Coefficient")
.HasColumnType("TEXT");
b.Property<decimal?>("KmMax")
.HasColumnType("TEXT");
b.Property<decimal>("KmMin")
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "Annee", "PuissanceFiscale" }, "IX_TranchesBareme_Annee_PF");
b.ToTable("TranchesBareme", null, t =>
{
t.HasCheckConstraint("CK_TranchesBareme_Km", "\"KmMax\" IS NULL OR CAST(\"KmMin\" AS REAL) < CAST(\"KmMax\" AS REAL)");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("DateEffet")
.HasColumnType("TEXT");
b.Property<string>("Denomination")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Plaque")
.IsRequired()
.HasMaxLength(15)
.HasColumnType("TEXT");
b.Property<int>("PuissanceFiscale")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "DateEffet" }, "IX_Vehicules_DateEffet");
b.ToTable("Vehicules", null, t =>
{
t.HasCheckConstraint("CK_Vehicules_PuissanceFiscale", "\"PuissanceFiscale\" > 0");
});
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany()
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.HasOne("SchotzIadKmTracker.Api.Models.Entities.Vehicule", "Vehicule")
.WithMany("Trajets")
.HasForeignKey("VehiculeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vehicule");
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Vehicule", b =>
{
b.Navigation("Trajets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SchotzIadKmTracker.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AjoutAuthentification : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Comptes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false),
Email = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
MotDePasseHash = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Comptes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ResetPasswordTokens",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TokenHash = table.Column<string>(type: "TEXT", nullable: false),
Expiration = table.Column<DateTime>(type: "TEXT", nullable: false),
UsedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ResetPasswordTokens", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "UX_Comptes_Email",
table: "Comptes",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ResetTokens_Expiration",
table: "ResetPasswordTokens",
column: "Expiration");
migrationBuilder.CreateIndex(
name: "UX_ResetTokens_TokenHash",
table: "ResetPasswordTokens",
column: "TokenHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Comptes");
migrationBuilder.DropTable(
name: "ResetPasswordTokens");
}
}
}
@@ -92,6 +92,34 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
b.ToTable("AdressesNommees", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Compte", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("MotDePasseHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Email" }, "UX_Comptes_Email")
.IsUnique();
b.ToTable("Comptes", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.MontantRegle", b =>
{
b.Property<int>("Id")
@@ -169,6 +197,35 @@ namespace SchotzIadKmTracker.Api.Data.Migrations
b.ToTable("Profil", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.ResetPasswordToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("Expiration")
.HasColumnType("TEXT");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UsedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Expiration" }, "IX_ResetTokens_Expiration");
b.HasIndex(new[] { "TokenHash" }, "UX_ResetTokens_TokenHash")
.IsUnique();
b.ToTable("ResetPasswordTokens", (string)null);
});
modelBuilder.Entity("SchotzIadKmTracker.Api.Models.Entities.Trajet", b =>
{
b.Property<int>("Id")
@@ -0,0 +1,21 @@
namespace SchotzIadKmTracker.Api.Models.DTOs;
// DTOs d'authentification. Aucun n'expose le hash du mot de passe (Requirement 8.2).
// Inscription du compte unique (Requirement 1).
public record RegisterDto(string Email, string MotDePasse);
// Connexion (Requirement 2).
public record LoginDto(string Email, string MotDePasse);
// Réponse de connexion : jeton JWT et date d'expiration (Requirement 2.1, 2.4).
public record LoginResultDto(string Token, DateTime Expiration);
// Demande de réinitialisation de mot de passe (Requirement 6).
public record ForgotPasswordDto(string Email);
// Réinitialisation via jeton de reset (Requirement 7).
public record ResetPasswordDto(string Token, string NouveauMotDePasse);
// Indique si un compte existe déjà, pour orienter le frontend (Requirement 1.2, 9.1).
public record CompteExisteDto(bool Existe);
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace SchotzIadKmTracker.Api.Models.Entities;
/// <summary>Compte unique de l'application mono-utilisateur (singleton, comme le Profil).</summary>
public class Compte
{
/// <summary>Identifiant du compte. Valeur fixe (1) non générée : le compte est un singleton.</summary>
public int Id { get; set; } = 1;
/// <summary>Adresse email du compte, unique, utilisée comme identifiant de connexion (1–255 caractères).</summary>
[Required, MaxLength(255)]
public string Email { get; set; } = string.Empty;
/// <summary>Hachage salé du mot de passe (PBKDF2 via <c>PasswordHasher</c>). Jamais exposé ni journalisé (Exigence 8.2).</summary>
[Required]
public string MotDePasseHash { get; set; } = string.Empty;
/// <summary>Date de création du compte (UTC).</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Date de dernière modification du compte (UTC), mise à jour notamment lors d'une réinitialisation du mot de passe.</summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace SchotzIadKmTracker.Api.Models.Entities;
/// <summary>Jeton de réinitialisation de mot de passe à usage unique et à durée de vie limitée.</summary>
public class ResetPasswordToken
{
/// <summary>Identifiant technique du jeton.</summary>
public int Id { get; set; }
/// <summary>Hachage SHA-256 du jeton. Le jeton en clair n'est jamais persisté : seul celui transmis par email permet la réinitialisation.</summary>
[Required]
public string TokenHash { get; set; } = string.Empty;
/// <summary>Date d'expiration du jeton (UTC). Au-delà, le jeton est rejeté (Exigence 6.2, défaut création + 60 min).</summary>
public DateTime Expiration { get; set; }
/// <summary>Date d'utilisation du jeton (UTC). <c>null</c> tant que le jeton n'a pas servi ; renseignée à l'usage pour garantir l'usage unique (Exigence 7.2).</summary>
public DateTime? UsedAt { get; set; }
/// <summary>Date de création du jeton (UTC).</summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+54 -1
View File
@@ -1,9 +1,14 @@
using System.Text;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using SchotzIadKmTracker.Api.Data;
using SchotzIadKmTracker.Api.Models.Entities;
using SchotzIadKmTracker.Api.Validators;
using SchotzIadKmTracker.Api.Configuration;
@@ -20,6 +25,12 @@ builder.Services.Configure<BANOptions>(
builder.Configuration.GetSection(BANOptions.SectionName));
builder.Services.Configure<ORSOptions>(
builder.Configuration.GetSection(ORSOptions.SectionName));
builder.Services.Configure<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<AuthOptions>(
builder.Configuration.GetSection(AuthOptions.SectionName));
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection(SmtpOptions.SectionName));
// --- Client typé BAN (Base Adresses Nationale) ---
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
@@ -70,7 +81,15 @@ builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
// --- Services de base ---
builder.Services.AddControllers();
// Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié.
// AuthController porte [AllowAnonymous] ; /health (endpoint minimal) n'est pas couvert par les filtres MVC.
builder.Services.AddControllers(options =>
{
var policy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter(policy));
});
// --- Health checks (endpoint GET /health, utilisé par Docker Compose) ---
builder.Services.AddHealthChecks();
@@ -87,7 +106,39 @@ var connectionString = builder.Configuration.GetConnectionString("Default")
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(connectionString));
// --- Hachage des mots de passe (ASP.NET Core Identity PasswordHasher) ---
builder.Services.AddSingleton<IPasswordHasher<Compte>, PasswordHasher<Compte>>();
// --- Repositories & services métier ---
builder.Services.AddScoped<IJwtService, JwtService>();
// --- Sélection de l'implémentation Email selon la présence de config SMTP ---
var smtpOptions = builder.Configuration.GetSection(SmtpOptions.SectionName).Get<SmtpOptions>() ?? new SmtpOptions();
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
builder.Services.AddScoped<IEmailService, LoggingEmailService>(); // repli journalisation (Exigence 6.5)
else
builder.Services.AddScoped<IEmailService, SmtpEmailService>(); // MailKit (Exigence 6.4)
builder.Services.AddScoped<IAuthService, AuthService>();
// --- Authentification JWT Bearer ---
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true, // rejette les jetons expirés (Exigence 5.2)
ValidateIssuerSigningKey = true, // rejette les signatures invalides (Exigence 5.3)
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)),
ClockSkew = TimeSpan.FromSeconds(30) // tolérance d'horloge réduite
};
});
builder.Services.AddScoped<SchotzIadKmTracker.Api.Repositories.IVehiculeRepository,
SchotzIadKmTracker.Api.Repositories.VehiculeRepository>();
builder.Services.AddScoped<IVehiculeService, VehiculeService>();
@@ -152,6 +203,8 @@ app.UseHttpsRedirection();
app.UseCors(CorsPolicyName);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -17,6 +19,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageReference Include="QuestPDF" Version="2024.10.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,217 @@
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;
}
}
/// <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('/', '_');
}
}
@@ -0,0 +1,19 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lorsqu'une tentative d'inscription est effectuée alors qu'un compte existe déjà.
/// L'application étant mono-utilisateur, un seul compte peut être créé.
/// Convertie en réponse HTTP 409 (Conflict) par le controller (Exigence 1.2).
/// </summary>
public class CompteDejaExistantException : Exception
{
public CompteDejaExistantException(string message)
: base(message)
{
}
public CompteDejaExistantException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,19 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lorsque les identifiants de connexion sont invalides (email inconnu ou mot de passe erroné).
/// Le message est volontairement générique afin de ne pas distinguer les deux cas (Exigence 2.2).
/// Convertie en réponse HTTP 401 (Unauthorized) par le controller.
/// </summary>
public class IdentifiantsInvalidesException : Exception
{
public IdentifiantsInvalidesException(string message)
: base(message)
{
}
public IdentifiantsInvalidesException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,18 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lorsqu'un jeton de réinitialisation a expiré (date d'expiration dépassée).
/// Convertie en réponse HTTP 400 (Bad Request) par le controller (Exigence 7.3).
/// </summary>
public class LienResetExpireException : Exception
{
public LienResetExpireException(string message)
: base(message)
{
}
public LienResetExpireException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,18 @@
namespace SchotzIadKmTracker.Api.Services.Exceptions;
/// <summary>
/// Levée lorsqu'un jeton de réinitialisation est inconnu ou a déjà été utilisé (usage unique).
/// Convertie en réponse HTTP 400 (Bad Request) par le controller (Exigences 7.2, 7.4).
/// </summary>
public class LienResetInvalideException : Exception
{
public LienResetInvalideException(string message)
: base(message)
{
}
public LienResetInvalideException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,52 @@
using SchotzIadKmTracker.Api.Models.DTOs;
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Logique métier d'authentification de l'application mono-utilisateur :
/// existence du compte, inscription, connexion, demande et application de la
/// réinitialisation du mot de passe.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Indique si un compte existe déjà, afin d'orienter le frontend (inscription vs connexion).
/// </summary>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
/// <returns><c>true</c> s'il existe au moins un compte, <c>false</c> sinon (Exigences 1.2, 9.1).</returns>
Task<bool> CompteExisteAsync(CancellationToken ct);
/// <summary>
/// Inscrit le compte unique. Lève <see cref="Exceptions.CompteDejaExistantException"/>
/// si un compte existe déjà (Exigence 1).
/// </summary>
/// <param name="dto">Email et mot de passe fournis à l'inscription.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
Task RegisterAsync(RegisterDto dto, CancellationToken ct);
/// <summary>
/// Authentifie le compte et émet un jeton JWT. Lève
/// <see cref="Exceptions.IdentifiantsInvalidesException"/> si les identifiants sont invalides (Exigence 2).
/// </summary>
/// <param name="dto">Identifiants de connexion (email et mot de passe).</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
/// <returns>Le jeton JWT émis et sa date d'expiration.</returns>
Task<LoginResultDto> LoginAsync(LoginDto dto, CancellationToken ct);
/// <summary>
/// Traite une demande de réinitialisation de mot de passe. Par principe de non-divulgation,
/// se termine toujours sans exception, que l'email corresponde ou non à un compte (Exigence 6.6).
/// </summary>
/// <param name="dto">Email pour lequel demander la réinitialisation.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
Task ForgotPasswordAsync(ForgotPasswordDto dto, CancellationToken ct);
/// <summary>
/// Réinitialise le mot de passe à partir d'un jeton de réinitialisation. Lève
/// <see cref="Exceptions.LienResetInvalideException"/> si le jeton est inconnu ou déjà utilisé,
/// <see cref="Exceptions.LienResetExpireException"/> s'il a expiré (Exigence 7).
/// </summary>
/// <param name="dto">Jeton de réinitialisation et nouveau mot de passe.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
Task ResetPasswordAsync(ResetPasswordDto dto, CancellationToken ct);
}
@@ -0,0 +1,18 @@
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Service d'envoi d'emails de l'application. L'implémentation concrète
/// (SMTP via MailKit ou repli par journalisation) est sélectionnée en DI
/// dans <c>Program.cs</c> selon la présence d'une configuration SMTP.
/// Le service appelant ignore l'implémentation retenue (transparence).
/// </summary>
public interface IEmailService
{
/// <summary>
/// Envoie au destinataire le lien de réinitialisation de mot de passe.
/// </summary>
/// <param name="destinataire">Adresse email du destinataire.</param>
/// <param name="lienReset">Lien de réinitialisation à transmettre au destinataire.</param>
/// <param name="ct">Jeton d'annulation de l'opération.</param>
Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct);
}
@@ -0,0 +1,24 @@
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Service d'émission des jetons JWT d'authentification.
/// </summary>
public interface IJwtService
{
/// <summary>
/// Émet un Jeton_JWT signé en HS256 pour le compte fourni.
/// </summary>
/// <remarks>
/// Le jeton porte les claims <c>sub</c> (identifiant du compte), <c>email</c> et un
/// identifiant unique <c>jti</c>. Sa date d'expiration vaut l'instant courant plus
/// <c>JwtOptions.ExpirationMinutes</c> (720 minutes / 12 h par défaut). Aucune donnée
/// sensible n'est incluse (jamais le hachage du mot de passe).
/// Exigences 2.1 (émission du jeton à la connexion), 2.4 (date d'expiration associée),
/// 5.4 (expiration à 12 h par défaut).
/// </remarks>
/// <param name="compte">Le compte pour lequel émettre le jeton.</param>
/// <returns>Le Jeton_JWT sérialisé (chaîne compacte).</returns>
string GenererJeton(Compte compte);
}
@@ -0,0 +1,57 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SchotzIadKmTracker.Api.Configuration;
using SchotzIadKmTracker.Api.Models.Entities;
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Implémentation de <see cref="IJwtService"/> émettant des jetons JWT signés en HMAC-SHA256
/// à partir des options <see cref="JwtOptions"/>.
/// </summary>
public class JwtService : IJwtService
{
private readonly JwtOptions _options;
/// <summary>
/// Initialise le service à partir des options JWT liées à la configuration.
/// </summary>
/// <param name="options">Options JWT (clé secrète, émetteur, audience, durée d'expiration).</param>
public JwtService(IOptions<JwtOptions> options)
{
_options = options.Value;
}
/// <inheritdoc />
public string GenererJeton(Compte compte)
{
// Claims émis : sub = identifiant du compte, email = adresse du compte,
// jti = identifiant unique du jeton. Aucune donnée sensible (jamais le hash du mot de passe).
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, compte.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, compte.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
// Signature symétrique HMAC-SHA256 à partir de la clé secrète configurée.
var cleSignature = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
var identifiants = new SigningCredentials(cleSignature, SecurityAlgorithms.HmacSha256);
// Expiration = instant courant + durée configurée (720 min / 12 h par défaut).
var expiration = DateTime.UtcNow.AddMinutes(_options.ExpirationMinutes);
var jeton = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiration,
signingCredentials: identifiants);
return new JwtSecurityTokenHandler().WriteToken(jeton);
}
}
@@ -0,0 +1,26 @@
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Implémentation de repli de <see cref="IEmailService"/> qui n'envoie aucun
/// email mais journalise le lien de réinitialisation via <see cref="ILogger"/>
/// au niveau <c>Information</c>. Utilisée lorsqu'aucun serveur SMTP n'est
/// configuré (<c>Smtp:Host</c> vide), notamment en développement.
/// </summary>
public class LoggingEmailService : IEmailService
{
private readonly ILogger<LoggingEmailService> _logger;
public LoggingEmailService(ILogger<LoggingEmailService> logger)
{
_logger = logger;
}
/// <inheritdoc />
public Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct)
{
_logger.LogInformation(
"Lien de réinitialisation pour {Destinataire} : {LienReset}",
destinataire, lienReset);
return Task.CompletedTask;
}
}
@@ -0,0 +1,69 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using SchotzIadKmTracker.Api.Configuration;
namespace SchotzIadKmTracker.Api.Services;
/// <summary>
/// Implémentation de <see cref="IEmailService"/> reposant sur MailKit pour
/// l'envoi d'emails via un serveur SMTP. La configuration (hôte, port,
/// identifiants, expéditeur) provient de <see cref="SmtpOptions"/>. Cette
/// implémentation est enregistrée en DI lorsque <c>Smtp:Host</c> est renseigné.
/// </summary>
public class SmtpEmailService : IEmailService
{
private readonly SmtpOptions _options;
private readonly ILogger<SmtpEmailService> _logger;
public SmtpEmailService(IOptions<SmtpOptions> options, ILogger<SmtpEmailService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task EnvoyerLienResetAsync(string destinataire, string lienReset, CancellationToken ct)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(_options.From));
message.To.Add(MailboxAddress.Parse(destinataire));
message.Subject = "Réinitialisation de votre mot de passe";
var corps = new BodyBuilder
{
TextBody =
"Bonjour,\r\n\r\n" +
"Vous avez demandé la réinitialisation de votre mot de passe.\r\n" +
"Cliquez sur le lien suivant pour définir un nouveau mot de passe :\r\n\r\n" +
lienReset + "\r\n\r\n" +
"Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.",
HtmlBody =
"<p>Bonjour,</p>" +
"<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>" +
"<p>Cliquez sur le lien suivant pour définir un nouveau mot de passe :</p>" +
$"<p><a href=\"{lienReset}\">{lienReset}</a></p>" +
"<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>"
};
message.Body = corps.ToMessageBody();
using var client = new SmtpClient();
_logger.LogInformation(
"Envoi de l'email de réinitialisation à {Destinataire} via {Host}:{Port}.",
destinataire, _options.Host, _options.Port);
await client.ConnectAsync(_options.Host, _options.Port, SecureSocketOptions.StartTls, ct);
if (!string.IsNullOrEmpty(_options.User))
{
await client.AuthenticateAsync(_options.User, _options.Password, ct);
}
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
_logger.LogInformation(
"Email de réinitialisation envoyé à {Destinataire}.", destinataire);
}
}
@@ -0,0 +1,58 @@
using FluentValidation;
using SchotzIadKmTracker.Api.Models.DTOs;
namespace SchotzIadKmTracker.Api.Validators;
// Validation de l'inscription (Requirements 1.3, 1.4, 8.2).
public class RegisterDtoValidator : AbstractValidator<RegisterDto>
{
public RegisterDtoValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
.EmailAddress().WithMessage("Adresse email invalide.");
RuleFor(x => x.MotDePasse)
.NotEmpty().WithMessage("Le mot de passe est obligatoire.")
.MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères.");
}
}
// Validation de la connexion (Requirements 1.4, 2.2).
public class LoginDtoValidator : AbstractValidator<LoginDto>
{
public LoginDtoValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
.EmailAddress().WithMessage("Adresse email invalide.");
RuleFor(x => x.MotDePasse)
.NotEmpty().WithMessage("Le mot de passe est obligatoire.");
}
}
// Validation de la demande de réinitialisation (Requirements 1.4, 6).
public class ForgotPasswordDtoValidator : AbstractValidator<ForgotPasswordDto>
{
public ForgotPasswordDtoValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("L'adresse email est obligatoire.")
.EmailAddress().WithMessage("Adresse email invalide.");
}
}
// Validation de la réinitialisation du mot de passe (Requirements 7.4, 7.5).
public class ResetPasswordDtoValidator : AbstractValidator<ResetPasswordDto>
{
public ResetPasswordDtoValidator()
{
RuleFor(x => x.Token)
.NotEmpty().WithMessage("Le jeton de réinitialisation est obligatoire.");
RuleFor(x => x.NouveauMotDePasse)
.NotEmpty().WithMessage("Le mot de passe est obligatoire.")
.MinimumLength(8).WithMessage("Le mot de passe doit comporter au moins 8 caractères.");
}
}