diff --git a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs index b71771f..d9c5b66 100644 --- a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs +++ b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs @@ -67,5 +67,32 @@ public async Task Login([FromBody] LoginRequest request, Cancella return Ok(result.Value); } + + /// + /// Initiates a password reset by generating and sending a temporary password. + /// + /// The email and pseudo for identity verification. + /// Cancellation token. + /// Success message. + /// Password reset email sent successfully. + /// Invalid request data. + /// Account suspended. + /// No account matches the provided email and pseudo. + [HttpPost("forgot-password")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken cancellationToken) + { + var result = await _authService.ForgotPasswordAsync(request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 400, new { message = result.Error }); + } + + return Ok(new { message = "Un nouveau mot de passe a été envoyé à votre adresse email." }); + } } } diff --git a/src/backend/src/FantasyRealm.Application/Common/Unit.cs b/src/backend/src/FantasyRealm.Application/Common/Unit.cs new file mode 100644 index 0000000..8d3c5c7 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Common/Unit.cs @@ -0,0 +1,26 @@ +namespace FantasyRealm.Application.Common +{ + /// + /// Represents a void type, since void is not a valid type in C# for generics. + /// Used as the result type for operations that complete without returning a value. + /// + public readonly struct Unit : IEquatable + { + /// + /// Gets the single instance of Unit. + /// + public static readonly Unit Value = new(); + + public bool Equals(Unit other) => true; + + public override bool Equals(object? obj) => obj is Unit; + + public override int GetHashCode() => 0; + + public static bool operator ==(Unit left, Unit right) => true; + + public static bool operator !=(Unit left, Unit right) => false; + + public override string ToString() => "()"; + } +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs new file mode 100644 index 0000000..c21d505 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for password reset functionality. + /// Requires both email and pseudo for identity verification. + /// + public sealed record ForgotPasswordRequest( + [Required(ErrorMessage = "L'email est requis.")] + [EmailAddress(ErrorMessage = "Le format de l'email est invalide.")] + [MaxLength(255, ErrorMessage = "L'email ne peut pas dépasser 255 caractères.")] + string Email, + + [Required(ErrorMessage = "Le pseudo est requis.")] + [MinLength(3, ErrorMessage = "Le pseudo doit contenir au moins 3 caractères.")] + [MaxLength(30, ErrorMessage = "Le pseudo ne peut pas dépasser 30 caractères.")] + string Pseudo + ); +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs index dcda03f..1dd20c1 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs @@ -23,5 +23,13 @@ public interface IAuthService /// Cancellation token. /// A result containing the login response with token or an error. Task> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + + /// + /// Initiates a password reset by generating a temporary password and sending it via email. + /// + /// The forgot password request containing email and pseudo. + /// Cancellation token. + /// A result indicating success or an error. + Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default); } } diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs index c53e400..1757662 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs @@ -24,6 +24,16 @@ public interface IEmailService /// A task representing the asynchronous operation. Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default); + /// + /// Sends an email containing a temporary password. + /// + /// The recipient's email address. + /// The user's display name. + /// The generated temporary password. + /// A cancellation token. + /// A task representing the asynchronous operation. + Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default); + /// /// Sends a notification when a character has been approved by an employee. /// diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs new file mode 100644 index 0000000..f6f60dc --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs @@ -0,0 +1,15 @@ +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Service interface for generating secure passwords. + /// + public interface IPasswordGenerator + { + /// + /// Generates a cryptographically secure random password that meets CNIL requirements. + /// + /// The desired password length (minimum 12). + /// A secure password containing uppercase, lowercase, digits, and special characters. + string GenerateSecurePassword(int length = 16); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs index 5b10d5f..7ef2976 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs @@ -35,5 +35,23 @@ public interface IUserRepository /// Cancellation token. /// The user with their role, or null if not found. Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default); + + /// + /// Retrieves a user by email and pseudo combination, including their role. + /// Used for password reset verification. + /// + /// The email address to search for (case-insensitive). + /// The pseudo to match. + /// Cancellation token. + /// The user with their role, or null if not found. + Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default); + + /// + /// Updates an existing user in the database. + /// + /// The user entity with updated values. + /// Cancellation token. + /// The updated user entity. + Task UpdateAsync(User user, CancellationToken cancellationToken = default); } } diff --git a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs index f94476c..0c8cd42 100644 --- a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs +++ b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs @@ -15,9 +15,11 @@ public sealed class AuthService : IAuthService private const string DefaultRole = "User"; private const string InvalidCredentialsMessage = "Identifiants incorrects."; private const string AccountSuspendedMessage = "Votre compte a été suspendu."; + private const string UserNotFoundMessage = "Aucun compte ne correspond à ces informations."; private readonly IUserRepository _userRepository; private readonly IPasswordHasher _passwordHasher; + private readonly IPasswordGenerator _passwordGenerator; private readonly IEmailService _emailService; private readonly IJwtService _jwtService; private readonly ILogger _logger; @@ -25,12 +27,14 @@ public sealed class AuthService : IAuthService public AuthService( IUserRepository userRepository, IPasswordHasher passwordHasher, + IPasswordGenerator passwordGenerator, IEmailService emailService, IJwtService jwtService, ILogger logger) { _userRepository = userRepository; _passwordHasher = passwordHasher; + _passwordGenerator = passwordGenerator; _emailService = emailService; _jwtService = jwtService; _logger = logger; @@ -142,5 +146,49 @@ public async Task> LoginAsync(LoginRequest request, Cancel user.MustChangePassword )); } + + /// + public async Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default) + { + var normalizedEmail = request.Email.ToLowerInvariant().Trim(); + var normalizedPseudo = request.Pseudo.Trim(); + + var user = await _userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken); + + if (user is null) + { + _logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo); + return Result.Failure(UserNotFoundMessage, 404); + } + + if (user.IsSuspended) + { + _logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id); + return Result.Failure(AccountSuspendedMessage, 403); + } + + var temporaryPassword = _passwordGenerator.GenerateSecurePassword(); + user.PasswordHash = _passwordHasher.Hash(temporaryPassword); + user.MustChangePassword = true; + + await _userRepository.UpdateAsync(user, cancellationToken); + + _ = Task.Run(async () => + { + try + { + await _emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None); + _logger.LogInformation("Temporary password email sent to {Email}", user.Email); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email); + } + }, CancellationToken.None); + + _logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email); + + return Result.Success(Unit.Value); + } } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs index 166f1f0..5d14120 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs @@ -53,6 +53,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); return services; diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs index cb9f47f..66aae67 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs @@ -10,15 +10,15 @@ public static class EmailTemplates { private const string BaseUrl = "https://fantasy-realm.com"; - // Dark fantasy color palette - private const string DarkBg = "#0D0D0F"; - private const string CardBg = "#1A1A2E"; - private const string CardBorder = "#2D2D44"; - private const string GoldPrimary = "#D4AF37"; - private const string GoldLight = "#F4D03F"; - private const string GoldDark = "#B8860B"; - private const string TextLight = "#E8E6E3"; - private const string TextMuted = "#9CA3AF"; + // Dark fantasy color palette - aligned with official style guide v3.0 + private const string DarkBg = "#0D0D0F"; // --dark-900 + private const string CardBg = "#121110"; // --dark-800 + private const string CardBorder = "#18181B"; // --dark-700 + private const string GoldPrimary = "#F59E0B"; // --gold-500 + private const string GoldLight = "#FBBF24"; // --gold-400 + private const string GoldDark = "#D97706"; // --gold-600 + private const string TextLight = "#E8E4DE"; // --cream-200 (text-primary) + private const string TextMuted = "#A8A29E"; // --cream-400 (text-muted) /// /// Generates a welcome email template for new users. @@ -125,6 +125,64 @@ Nous avons reçu une demande de réinitialisation de votre mot de passe. "); } + /// + /// Generates a temporary password email template. + /// + /// The user's display name. + /// The generated temporary password. + /// The HTML email body. + public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword) + { + return WrapInLayout( + "Nouveau mot de passe temporaire", + $@" +
+
🔐
+

+ Nouveau mot de passe temporaire +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Suite à votre demande, voici votre nouveau mot de passe temporaire : +

+
+ +
+

+ Votre mot de passe temporaire +

+

+ {Encode(temporaryPassword)} +

+
+ +
+

+ ⚠️ Important +

+

+ Vous devrez changer ce mot de passe lors de votre prochaine connexion. + Ce mot de passe est temporaire et ne devrait pas être réutilisé. +

+
+ +
+ {GetPrimaryButton("Se connecter", $"{BaseUrl}/login", "🔑")} +
+ +
+

+ 🛡️ Si vous n'êtes pas à l'origine de cette demande, veuillez contacter notre support immédiatement. +

+
+ "); + } + /// /// Generates a character approved email template. /// diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs index 3f2b78b..07928be 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs @@ -48,6 +48,14 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str await SendEmailAsync(toEmail, subject, body, cancellationToken); } + /// + public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default) + { + var subject = "Votre nouveau mot de passe FantasyRealm"; + var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + await SendEmailAsync(toEmail, subject, body, cancellationToken); + } + /// public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default) { diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs new file mode 100644 index 0000000..fc8b4eb --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FantasyRealm.Infrastructure.Persistence.Converters +{ + /// + /// Converts DateTime values to and from UTC for PostgreSQL compatibility. + /// PostgreSQL with Npgsql requires DateTime values to have Kind=Utc for timestamp with time zone columns. + /// + public sealed class UtcDateTimeConverter : ValueConverter + { + public UtcDateTimeConverter() + : base( + v => v.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(v, DateTimeKind.Utc) + : v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)) + { + } + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs index df1d56d..cc1d952 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs @@ -1,6 +1,7 @@ using FantasyRealm.Domain.Entities; using FantasyRealm.Domain.Enums; using FantasyRealm.Infrastructure.Persistence.Conventions; +using FantasyRealm.Infrastructure.Persistence.Converters; using Microsoft.EntityFrameworkCore; namespace FantasyRealm.Infrastructure.Persistence @@ -22,6 +23,14 @@ public class FantasyRealmDbContext(DbContextOptions optio public DbSet Comments { get; set; } = null!; + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + + configurationBuilder.Properties() + .HaveConversion(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs index 507c3ce..8834183 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs @@ -56,5 +56,24 @@ public async Task CreateAsync(User user, CancellationToken cancellationTok .Include(u => u.Role) .FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken); } + + /// + public async Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default) + { + var normalizedEmail = email.ToLowerInvariant().Trim(); + var normalizedPseudo = pseudo.Trim(); + return await _context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Email == normalizedEmail && u.Pseudo == normalizedPseudo, cancellationToken); + } + + /// + public async Task UpdateAsync(User user, CancellationToken cancellationToken = default) + { + user.UpdatedAt = DateTime.UtcNow; + _context.Users.Update(user); + await _context.SaveChangesAsync(cancellationToken); + return user; + } } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs new file mode 100644 index 0000000..d020a4f --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using FantasyRealm.Application.Interfaces; + +namespace FantasyRealm.Infrastructure.Security +{ + /// + /// Generates cryptographically secure passwords meeting CNIL requirements. + /// + public sealed class SecurePasswordGenerator : IPasswordGenerator + { + private const string UppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string LowercaseChars = "abcdefghijklmnopqrstuvwxyz"; + private const string DigitChars = "0123456789"; + private const string SpecialChars = "@#$%^&*!?-_+="; + private const string AllChars = UppercaseChars + LowercaseChars + DigitChars + SpecialChars; + private const int MinimumLength = 12; + + /// + public string GenerateSecurePassword(int length = 16) + { + if (length < MinimumLength) + { + length = MinimumLength; + } + + var password = new char[length]; + var remainingPositions = Enumerable.Range(0, length).ToList(); + + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(UppercaseChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(LowercaseChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(DigitChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(SpecialChars); + + foreach (var position in remainingPositions) + { + password[position] = GetRandomChar(AllChars); + } + + return new string(password); + } + + private static char GetRandomChar(string characterSet) + { + return characterSet[RandomNumberGenerator.GetInt32(characterSet.Length)]; + } + + private static int TakeRandomPosition(List positions) + { + var index = RandomNumberGenerator.GetInt32(positions.Count); + var position = positions[index]; + positions.RemoveAt(index); + return position; + } + } +} diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs index 0754d2a..209da21 100644 --- a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs +++ b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs @@ -56,6 +56,43 @@ public void GetPasswordResetTemplate_UrlEncodesSpecialCharacters() Assert.DoesNotContain("token=abc+def", result); } + [Fact] + public void GetTemporaryPasswordTemplate_ContainsPseudo() + { + var pseudo = "TestPlayer"; + var temporaryPassword = "TempPass@123!"; + + var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + + Assert.Contains(pseudo, result); + Assert.Contains(temporaryPassword, result); + Assert.Contains("mot de passe temporaire", result.ToLower()); + } + + [Fact] + public void GetTemporaryPasswordTemplate_ContainsChangePasswordWarning() + { + var pseudo = "TestPlayer"; + var temporaryPassword = "TempPass@123!"; + + var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + + Assert.Contains("changer ce mot de passe", result.ToLower()); + Assert.Contains("prochaine connexion", result.ToLower()); + } + + [Fact] + public void GetTemporaryPasswordTemplate_EscapesHtmlCharacters() + { + var pseudo = ""; + var temporaryPassword = "TempPass@123!"; + + var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword); + + Assert.DoesNotContain("