Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,32 @@ public async Task<IActionResult> Login([FromBody] LoginRequest request, Cancella

return Ok(result.Value);
}

/// <summary>
/// Initiates a password reset by generating and sending a temporary password.
/// </summary>
/// <param name="request">The email and pseudo for identity verification.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Success message.</returns>
/// <response code="200">Password reset email sent successfully.</response>
/// <response code="400">Invalid request data.</response>
/// <response code="403">Account suspended.</response>
/// <response code="404">No account matches the provided email and pseudo.</response>
[HttpPost("forgot-password")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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." });
}
}
}
26 changes: 26 additions & 0 deletions src/backend/src/FantasyRealm.Application/Common/Unit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace FantasyRealm.Application.Common
{
/// <summary>
/// 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.
/// </summary>
public readonly struct Unit : IEquatable<Unit>
{
/// <summary>
/// Gets the single instance of Unit.
/// </summary>
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() => "()";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;

namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Request payload for password reset functionality.
/// Requires both email and pseudo for identity verification.
/// </summary>
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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,13 @@ public interface IAuthService
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing the login response with token or an error.</returns>
Task<Result<LoginResponse>> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Initiates a password reset by generating a temporary password and sending it via email.
/// </summary>
/// <param name="request">The forgot password request containing email and pseudo.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or an error.</returns>
Task<Result<Unit>> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ public interface IEmailService
/// <returns>A task representing the asynchronous operation.</returns>
Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default);

/// <summary>
/// Sends an email containing a temporary password.
/// </summary>
/// <param name="toEmail">The recipient's email address.</param>
/// <param name="pseudo">The user's display name.</param>
/// <param name="temporaryPassword">The generated temporary password.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default);

/// <summary>
/// Sends a notification when a character has been approved by an employee.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace FantasyRealm.Application.Interfaces
{
/// <summary>
/// Service interface for generating secure passwords.
/// </summary>
public interface IPasswordGenerator
{
/// <summary>
/// Generates a cryptographically secure random password that meets CNIL requirements.
/// </summary>
/// <param name="length">The desired password length (minimum 12).</param>
/// <returns>A secure password containing uppercase, lowercase, digits, and special characters.</returns>
string GenerateSecurePassword(int length = 16);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,23 @@ public interface IUserRepository
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The user with their role, or null if not found.</returns>
Task<User?> GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves a user by email and pseudo combination, including their role.
/// Used for password reset verification.
/// </summary>
/// <param name="email">The email address to search for (case-insensitive).</param>
/// <param name="pseudo">The pseudo to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The user with their role, or null if not found.</returns>
Task<User?> GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default);

/// <summary>
/// Updates an existing user in the database.
/// </summary>
/// <param name="user">The user entity with updated values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated user entity.</returns>
Task<User> UpdateAsync(User user, CancellationToken cancellationToken = default);
}
}
48 changes: 48 additions & 0 deletions src/backend/src/FantasyRealm.Application/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,26 @@ 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<AuthService> _logger;

public AuthService(
IUserRepository userRepository,
IPasswordHasher passwordHasher,
IPasswordGenerator passwordGenerator,
IEmailService emailService,
IJwtService jwtService,
ILogger<AuthService> logger)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
_passwordGenerator = passwordGenerator;
_emailService = emailService;
_jwtService = jwtService;
_logger = logger;
Expand Down Expand Up @@ -142,5 +146,49 @@ public async Task<Result<LoginResponse>> LoginAsync(LoginRequest request, Cancel
user.MustChangePassword
));
}

/// <inheritdoc />
public async Task<Result<Unit>> 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<Unit>.Failure(UserNotFoundMessage, 404);
}

if (user.IsSuspended)
{
_logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id);
return Result<Unit>.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<Unit>.Success(Unit.Value);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi

services.AddScoped<IUserRepository, UserRepository>();
services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>();
services.AddSingleton<IPasswordGenerator, SecurePasswordGenerator>();
services.AddScoped<IAuthService, AuthService>();

return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

/// <summary>
/// Generates a welcome email template for new users.
Expand Down Expand Up @@ -125,6 +125,64 @@ Nous avons reçu une demande de réinitialisation de votre mot de passe.
");
}

/// <summary>
/// Generates a temporary password email template.
/// </summary>
/// <param name="pseudo">The user's display name.</param>
/// <param name="temporaryPassword">The generated temporary password.</param>
/// <returns>The HTML email body.</returns>
public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword)
{
return WrapInLayout(
"Nouveau mot de passe temporaire",
$@"
<div style=""text-align: center; margin-bottom: 30px;"">
<div style=""font-size: 48px; margin-bottom: 10px;"">🔐</div>
<h1 style=""color: {GoldPrimary}; font-size: 24px; margin: 0; font-weight: 700;"">
Nouveau mot de passe temporaire
</h1>
</div>

<div style=""background: {CardBg}; border: 1px solid {CardBorder}; border-radius: 12px; padding: 25px; margin-bottom: 25px;"">
<p style=""color: {TextLight}; margin: 0 0 15px 0; font-size: 16px;"">
Bonjour <strong style=""color: {GoldPrimary};"">{Encode(pseudo)}</strong>,
</p>
<p style=""color: {TextLight}; margin: 0 0 20px 0; font-size: 16px; line-height: 1.7;"">
Suite à votre demande, voici votre nouveau mot de passe temporaire :
</p>
</div>

<div style=""background: rgba(212, 175, 55, 0.15); border: 2px solid {GoldPrimary}; border-radius: 12px; padding: 20px; margin-bottom: 25px; text-align: center;"">
<p style=""color: {TextMuted}; margin: 0 0 10px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 1px;"">
Votre mot de passe temporaire
</p>
<p style=""color: {GoldLight}; margin: 0; font-size: 24px; font-family: monospace; font-weight: 700; letter-spacing: 2px;"">
{Encode(temporaryPassword)}
</p>
</div>

<div style=""background: rgba(239, 68, 68, 0.1); border: 1px solid #7F1D1D; border-radius: 8px; padding: 20px; margin-bottom: 25px;"">
<p style=""color: #FCA5A5; margin: 0 0 8px 0; font-weight: 600; font-size: 14px;"">
⚠️ Important
</p>
<p style=""color: {TextLight}; margin: 0; font-size: 14px; line-height: 1.6;"">
Vous devrez <strong>changer ce mot de passe</strong> lors de votre prochaine connexion.
Ce mot de passe est temporaire et ne devrait pas être réutilisé.
</p>
</div>

<div style=""text-align: center; margin: 30px 0;"">
{GetPrimaryButton("Se connecter", $"{BaseUrl}/login", "🔑")}
</div>

<div style=""background: rgba(212, 175, 55, 0.1); border: 1px solid {GoldDark}; border-radius: 8px; padding: 15px; margin-top: 25px;"">
<p style=""color: {TextMuted}; margin: 0; font-size: 13px; line-height: 1.6;"">
🛡️ Si vous n'êtes pas à l'origine de cette demande, veuillez contacter notre support immédiatement.
</p>
</div>
");
}

/// <summary>
/// Generates a character approved email template.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}

/// <inheritdoc />
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);
}

/// <inheritdoc />
public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

namespace FantasyRealm.Infrastructure.Persistence.Converters
{
/// <summary>
/// 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.
/// </summary>
public sealed class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
{
public UtcDateTimeConverter()
: base(
v => v.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(v, DateTimeKind.Utc)
: v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
{
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,6 +23,14 @@ public class FantasyRealmDbContext(DbContextOptions<FantasyRealmDbContext> optio

public DbSet<Comment> Comments { get; set; } = null!;

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);

configurationBuilder.Properties<DateTime>()
.HaveConversion<UtcDateTimeConverter>();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
Expand Down
Loading