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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user