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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ MONGO_DB=fantasyrealm_logs
# pgAdmin Configuration
PGADMIN_EMAIL=contact@fantasy-realm.com
PGADMIN_PASSWORD=__PGADMIN_PASSWORD__

# Frontend URL (used for email links)
FRONTEND_URL=https://fantasy-realm.com
1 change: 1 addition & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ services:
ConnectionStrings__PostgreSQL: "Host=postgres;Port=5432;Database=${POSTGRES_DB:-fantasyrealm};Username=${POSTGRES_USER:-fantasyrealm};Password=${POSTGRES_PASSWORD}"
ConnectionStrings__MongoDB: "mongodb://${MONGO_USER:-fantasyrealm}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB:-fantasyrealm_logs}?authSource=admin"
Email__Password: ${EMAIL_PASSWORD}
Email__BaseUrl: ${FRONTEND_URL:-http://localhost:5173}
depends_on:
postgres:
condition: service_healthy
Expand Down
52 changes: 41 additions & 11 deletions src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using FantasyRealm.Application.DTOs;
using FantasyRealm.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace FantasyRealm.Api.Controllers
Expand All @@ -9,15 +11,8 @@ namespace FantasyRealm.Api.Controllers
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
public sealed class AuthController(IAuthService authService) : ControllerBase
{
private readonly IAuthService _authService;

public AuthController(IAuthService authService)
{
_authService = authService;
}

/// <summary>
/// Registers a new user account.
/// </summary>
Expand All @@ -33,7 +28,7 @@ public AuthController(IAuthService authService)
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
var result = await _authService.RegisterAsync(request, cancellationToken);
var result = await authService.RegisterAsync(request, cancellationToken);

if (result.IsFailure)
{
Expand All @@ -58,7 +53,7 @@ public async Task<IActionResult> Register([FromBody] RegisterRequest request, Ca
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var result = await _authService.LoginAsync(request, cancellationToken);
var result = await authService.LoginAsync(request, cancellationToken);

if (result.IsFailure)
{
Expand All @@ -85,7 +80,7 @@ public async Task<IActionResult> Login([FromBody] LoginRequest request, Cancella
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken cancellationToken)
{
var result = await _authService.ForgotPasswordAsync(request, cancellationToken);
var result = await authService.ForgotPasswordAsync(request, cancellationToken);

if (result.IsFailure)
{
Expand All @@ -94,5 +89,40 @@ public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest

return Ok(new { message = "Un nouveau mot de passe a été envoyé à votre adresse email." });
}

/// <summary>
/// Changes the password for the authenticated user.
/// </summary>
/// <param name="request">The current and new password details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A new JWT token upon successful password change.</returns>
/// <response code="200">Password changed successfully.</response>
/// <response code="400">Invalid request data or password validation failed.</response>
/// <response code="401">Not authenticated or current password is incorrect.</response>
/// <response code="403">Account suspended.</response>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
var userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;

if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
{
return Unauthorized(new { message = "Token invalide." });
}

var result = await authService.ChangePasswordAsync(userId, request, cancellationToken);

if (result.IsFailure)
{
return StatusCode(result.ErrorCode ?? 400, new { message = result.Error });
}

return Ok(result.Value);
}
}
}
1 change: 1 addition & 0 deletions src/backend/src/FantasyRealm.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private static void Main(string[] args)
})
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
Expand Down
3 changes: 2 additions & 1 deletion src/backend/src/FantasyRealm.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"Username": "noreply@fantasy-realm.com",
"Password": "",
"FromAddress": "noreply@fantasy-realm.com",
"FromName": "FantasyRealm"
"FromName": "FantasyRealm",
"BaseUrl": "http://localhost:5173"
},
"Jwt": {
"Secret": "CHANGE_ME_IN_PRODUCTION_THIS_IS_A_DEV_SECRET_KEY_MIN_32_CHARS",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;

namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Request payload for changing user password.
/// </summary>
public sealed record ChangePasswordRequest(
[Required(ErrorMessage = "Le mot de passe actuel est requis.")]
string CurrentPassword,

[Required(ErrorMessage = "Le nouveau mot de passe est requis.")]
string NewPassword,

[Required(ErrorMessage = "La confirmation du mot de passe est requise.")]
string ConfirmNewPassword
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Response payload for successful password change.
/// </summary>
public sealed record ChangePasswordResponse(
string Token,
DateTime ExpiresAt,
UserInfo User
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@ public interface IAuthService
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or an error.</returns>
Task<Result<Unit>> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Changes the password for an authenticated user.
/// </summary>
/// <param name="userId">The ID of the authenticated user.</param>
/// <param name="request">The change password request containing current and new passwords.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing the new token or an error.</returns>
Task<Result<ChangePasswordResponse>> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public interface IUserRepository
/// <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 ID, including their role.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The user with their role, or null if not found.</returns>
Task<User?> GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves a user by email and pseudo combination, including their role.
/// Used for password reset verification.
Expand Down
Loading