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
25 changes: 25 additions & 0 deletions src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,30 @@ public async Task<IActionResult> Register([FromBody] RegisterRequest request, Ca

return CreatedAtAction(nameof(Register), new { id = result.Value!.Id }, result.Value);
}

/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
/// <param name="request">The login credentials.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The JWT token and user information.</returns>
/// <response code="200">Login successful.</response>
/// <response code="401">Invalid credentials.</response>
/// <response code="403">Account suspended.</response>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
var result = await _authService.LoginAsync(request, cancellationToken);

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

return Ok(result.Value);
}
}
}
2 changes: 1 addition & 1 deletion src/backend/src/FantasyRealm.Api/FantasyRealm.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
Expand Down
26 changes: 26 additions & 0 deletions src/backend/src/FantasyRealm.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
using System.Text;
using FantasyRealm.Infrastructure;
using FantasyRealm.Infrastructure.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

namespace FantasyRealm.Api
{
Expand Down Expand Up @@ -31,6 +35,27 @@ private static void Main(string[] args)
// Infrastructure services (Database, Email, Auth)
builder.Services.AddInfrastructure(builder.Configuration);

// JWT Authentication configuration
var jwtSettings = builder.Configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>()!;
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
};
});

var app = builder.Build();

// Configure the HTTP request pipeline
Expand All @@ -45,6 +70,7 @@ private static void Main(string[] args)

app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Expand Down
6 changes: 6 additions & 0 deletions src/backend/src/FantasyRealm.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,11 @@
"Password": "",
"FromAddress": "noreply@fantasy-realm.com",
"FromName": "FantasyRealm"
},
"Jwt": {
"Secret": "CHANGE_ME_IN_PRODUCTION_THIS_IS_A_DEV_SECRET_KEY_MIN_32_CHARS",
"Issuer": "FantasyRealm",
"Audience": "FantasyRealmUsers",
"ExpirationHours": 24
}
}
16 changes: 16 additions & 0 deletions src/backend/src/FantasyRealm.Application/DTOs/LoginRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;

namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Request payload for user login.
/// </summary>
public sealed record LoginRequest(
[Required(ErrorMessage = "L'email est requis.")]
[EmailAddress(ErrorMessage = "Le format de l'email est invalide.")]
string Email,

[Required(ErrorMessage = "Le mot de passe est requis.")]
string Password
);
}
12 changes: 12 additions & 0 deletions src/backend/src/FantasyRealm.Application/DTOs/LoginResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Response payload for successful login.
/// </summary>
public sealed record LoginResponse(
string Token,
DateTime ExpiresAt,
UserInfo User,
bool MustChangePassword
);
}
12 changes: 12 additions & 0 deletions src/backend/src/FantasyRealm.Application/DTOs/UserInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace FantasyRealm.Application.DTOs
{
/// <summary>
/// Basic user information returned in authentication responses.
/// </summary>
public sealed record UserInfo(
int Id,
string Email,
string Pseudo,
string Role
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,13 @@ public interface IAuthService
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing the registered user info or an error.</returns>
Task<Result<RegisterResponse>> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
/// <param name="request">The login request containing credentials.</param>
/// <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);
}
}
24 changes: 24 additions & 0 deletions src/backend/src/FantasyRealm.Application/Interfaces/IJwtService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace FantasyRealm.Application.Interfaces
{
/// <summary>
/// Service interface for JWT token operations.
/// </summary>
public interface IJwtService
{
/// <summary>
/// Generates a JWT token for the specified user.
/// </summary>
/// <param name="userId">The user's unique identifier.</param>
/// <param name="email">The user's email address.</param>
/// <param name="pseudo">The user's display name.</param>
/// <param name="role">The user's role.</param>
/// <returns>A signed JWT token string.</returns>
string GenerateToken(int userId, string email, string pseudo, string role);

/// <summary>
/// Gets the expiration date for a newly generated token.
/// </summary>
/// <returns>The UTC datetime when the token will expire.</returns>
DateTime GetExpirationDate();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,13 @@ public interface IUserRepository
/// Retrieves a role by its label.
/// </summary>
Task<Role?> GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves a user by email address, including their role.
/// </summary>
/// <param name="email">The email address to search for (case-insensitive).</param>
/// <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);
}
}
45 changes: 45 additions & 0 deletions src/backend/src/FantasyRealm.Application/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,26 @@ namespace FantasyRealm.Application.Services
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 readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
private readonly IEmailService _emailService;
private readonly IJwtService _jwtService;
private readonly ILogger<AuthService> _logger;

public AuthService(
IUserRepository userRepository,
IPasswordHasher passwordHasher,
IEmailService emailService,
IJwtService jwtService,
ILogger<AuthService> logger)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
_emailService = emailService;
_jwtService = jwtService;
_logger = logger;
}

Expand Down Expand Up @@ -97,5 +102,45 @@ public async Task<Result<RegisterResponse>> RegisterAsync(RegisterRequest reques
createdUser.CreatedAt
));
}

/// <inheritdoc />
public async Task<Result<LoginResponse>> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default)
{
var normalizedEmail = request.Email.ToLowerInvariant().Trim();

var user = await _userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken);

if (user is null)
{
_logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail);
return Result<LoginResponse>.Failure(InvalidCredentialsMessage, 401);
}

if (!_passwordHasher.Verify(request.Password, user.PasswordHash))
{
_logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id);
return Result<LoginResponse>.Failure(InvalidCredentialsMessage, 401);
}

if (user.IsSuspended)
{
_logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id);
return Result<LoginResponse>.Failure(AccountSuspendedMessage, 403);
}

var token = _jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label);
var expiresAt = _jwtService.GetExpirationDate();

_logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email);

var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label);

return Result<LoginResponse>.Success(new LoginResponse(
token,
expiresAt,
userInfo,
user.MustChangePassword
));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddSingleton<ISmtpClientFactory, SmtpClientFactory>();
services.AddScoped<IEmailService, SmtpEmailService>();

services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddSingleton<IJwtService, JwtService>();

services.AddScoped<IUserRepository, UserRepository>();
services.AddSingleton<IPasswordHasher, Argon2PasswordHasher>();
services.AddScoped<IAuthService, AuthService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.*" />
</ItemGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,14 @@ public async Task<User> CreateAsync(User user, CancellationToken cancellationTok
return await _context.Roles
.FirstOrDefaultAsync(r => r.Label == label, cancellationToken);
}

/// <inheritdoc />
public async Task<User?> GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default)
{
var normalizedEmail = email.ToLowerInvariant().Trim();
return await _context.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken);
}
}
}
54 changes: 54 additions & 0 deletions src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FantasyRealm.Application.Interfaces;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace FantasyRealm.Infrastructure.Security
{
/// <summary>
/// Service implementation for JWT token generation.
/// </summary>
public sealed class JwtService : IJwtService
{
private readonly JwtSettings _settings;

public JwtService(IOptions<JwtSettings> settings)
{
_settings = settings.Value;
}

/// <inheritdoc />
public string GenerateToken(int userId, string email, string pseudo, string role)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim("pseudo", pseudo),
new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: GetExpirationDate(),
signingCredentials: credentials
);

return new JwtSecurityTokenHandler().WriteToken(token);
}

/// <inheritdoc />
public DateTime GetExpirationDate()
{
return DateTime.UtcNow.AddHours(_settings.ExpirationHours);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace FantasyRealm.Infrastructure.Security
{
/// <summary>
/// Configuration settings for JWT token generation and validation.
/// </summary>
public sealed class JwtSettings
{
/// <summary>
/// The configuration section name in appsettings.json.
/// </summary>
public const string SectionName = "Jwt";

/// <summary>
/// The secret key used to sign JWT tokens. Must be at least 32 characters.
/// </summary>
public string Secret { get; init; } = string.Empty;

/// <summary>
/// The issuer claim for the JWT token.
/// </summary>
public string Issuer { get; init; } = string.Empty;

/// <summary>
/// The audience claim for the JWT token.
/// </summary>
public string Audience { get; init; } = string.Empty;

/// <summary>
/// The token expiration time in hours.
/// </summary>
public int ExpirationHours { get; init; } = 24;
}
}
Loading