fix(backend/auth): Fix authentication bugs

- Fix case-insensitive email lookups in AuthService (RegisterAsync, LoginAsync, CreateAdminUserAsync)
- Fix User.Create factory method to explicitly set Role property (C# object initializer behavior)
- Assign Admin role to seeded admin user in SeedDataExtension
- Add [AllowAnonymous] to Register and Login endpoints in AuthController (defensive programming)
- Increase JWT ClockSkew to 5 minutes for clock difference tolerance

These fixes resolve issues where:
- Login fails with 401 due to case-sensitive email comparison
- User role is null instead of 'User' or 'Admin'
- JWT token validation too strict on clock synchronization

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Lasse Rune Hansen 2026-06-14 16:40:01 +02:00
parent 70af326ca7
commit 42778ec34b
5 changed files with 15 additions and 6 deletions

View file

@ -31,6 +31,7 @@ public class User
Email = email.ToLowerInvariant(), Email = email.ToLowerInvariant(),
PasswordHash = passwordHash, PasswordHash = passwordHash,
CurrentLevel = "A1", CurrentLevel = "A1",
Role = "User", // Explicitly set default role
Streak = 0, Streak = 0,
TotalPoints = 0, TotalPoints = 0,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow

View file

@ -42,6 +42,7 @@ public static class SeedDataExtension
"admin@deutschlernen.com", "admin@deutschlernen.com",
"$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!" "$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!"
); );
adminUser.AssignAdminRole(); // Set role to Admin
adminUser.UpdateLevel("C1"); adminUser.UpdateLevel("C1");
dbContext.Users.Add(adminUser); dbContext.Users.Add(adminUser);

View file

@ -49,11 +49,12 @@ public class AuthService : IAuthService
/// </summary> /// </summary>
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto) public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
{ {
// Check if username or email already exists // Check if username or email already exists (case-insensitive for email)
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username)) if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
throw new InvalidOperationException("Username already taken"); throw new InvalidOperationException("Username already taken");
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email)) var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
throw new InvalidOperationException("Email already in use"); throw new InvalidOperationException("Email already in use");
// Hash password and create user // Hash password and create user
@ -89,7 +90,10 @@ public class AuthService : IAuthService
/// </summary> /// </summary>
public async Task<AuthResponse> LoginAsync(LoginDto loginDto) public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
{ {
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == loginDto.Email); // Normalize email to lowercase for case-insensitive matching
// Emails are stored in lowercase in the database
var normalizedEmail = loginDto.Email?.ToLowerInvariant() ?? string.Empty;
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail);
if (user == null) if (user == null)
throw new UnauthorizedAccessException("Invalid email or password"); throw new UnauthorizedAccessException("Invalid email or password");
@ -241,11 +245,12 @@ public class AuthService : IAuthService
if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin")) if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin"))
throw new InvalidOperationException("Admin user already exists. Bootstrap endpoint can only be used once."); throw new InvalidOperationException("Admin user already exists. Bootstrap endpoint can only be used once.");
// Check if username or email already exists // Check if username or email already exists (case-insensitive for email)
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username)) if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
throw new InvalidOperationException("Username already taken"); throw new InvalidOperationException("Username already taken");
if (await _dbContext.Users.AnyAsync(u => u.Email == registerDto.Email)) var normalizedEmail = registerDto.Email?.ToLowerInvariant() ?? string.Empty;
if (await _dbContext.Users.AnyAsync(u => u.Email == normalizedEmail))
throw new InvalidOperationException("Email already in use"); throw new InvalidOperationException("Email already in use");
// Hash password and create user // Hash password and create user

View file

@ -27,6 +27,7 @@ public class AuthController : ControllerBase
/// <param name="registerDto">Registration data</param> /// <param name="registerDto">Registration data</param>
/// <returns>Authentication response with JWT token</returns> /// <returns>Authentication response with JWT token</returns>
[HttpPost("register")] [HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> Register([FromBody] RegisterDto registerDto) public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)
@ -52,6 +53,7 @@ public class AuthController : ControllerBase
/// <param name="loginDto">Login data</param> /// <param name="loginDto">Login data</param>
/// <returns>Authentication response with JWT token</returns> /// <returns>Authentication response with JWT token</returns>
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginDto loginDto) public async Task<IActionResult> Login([FromBody] LoginDto loginDto)

View file

@ -78,7 +78,7 @@ try
ValidIssuer = jwtIssuer, ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience, ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.Zero ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes of clock difference
}; };
}); });