From 42778ec34bb68c464759e918f9ba36215a0f1f0b Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Sun, 14 Jun 2026 16:40:01 +0200 Subject: [PATCH] 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 --- GermanApp/Domain/Entities/User.cs | 1 + .../Data/SeedData/SeedDataExtension.cs | 1 + GermanApp/Infrastructure/Services/AuthService.cs | 15 ++++++++++----- .../Presentation/Controllers/AuthController.cs | 2 ++ GermanApp/Program.cs | 2 +- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/GermanApp/Domain/Entities/User.cs b/GermanApp/Domain/Entities/User.cs index 94a9f9a..c7ecbd0 100644 --- a/GermanApp/Domain/Entities/User.cs +++ b/GermanApp/Domain/Entities/User.cs @@ -31,6 +31,7 @@ public class User Email = email.ToLowerInvariant(), PasswordHash = passwordHash, CurrentLevel = "A1", + Role = "User", // Explicitly set default role Streak = 0, TotalPoints = 0, CreatedAt = DateTime.UtcNow diff --git a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs index a17ec20..ab337db 100644 --- a/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs +++ b/GermanApp/Infrastructure/Data/SeedData/SeedDataExtension.cs @@ -42,6 +42,7 @@ public static class SeedDataExtension "admin@deutschlernen.com", "$2a$11$N9qo8uLOickgx2ZMRZoMy.Mrq9Hq4yVJyX2sX7z3J0J9z6J9Z2K4a" // bcrypt hash of "Admin@123!" ); + adminUser.AssignAdminRole(); // Set role to Admin adminUser.UpdateLevel("C1"); dbContext.Users.Add(adminUser); diff --git a/GermanApp/Infrastructure/Services/AuthService.cs b/GermanApp/Infrastructure/Services/AuthService.cs index 80b24b6..58fa997 100644 --- a/GermanApp/Infrastructure/Services/AuthService.cs +++ b/GermanApp/Infrastructure/Services/AuthService.cs @@ -49,11 +49,12 @@ public class AuthService : IAuthService /// public async Task 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)) 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"); // Hash password and create user @@ -89,7 +90,10 @@ public class AuthService : IAuthService /// public async Task 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) throw new UnauthorizedAccessException("Invalid email or password"); @@ -241,11 +245,12 @@ public class AuthService : IAuthService if (await _dbContext.Users.AnyAsync(u => u.Role == "Admin")) 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)) 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"); // Hash password and create user diff --git a/GermanApp/Presentation/Controllers/AuthController.cs b/GermanApp/Presentation/Controllers/AuthController.cs index b8acdd8..265cbf2 100644 --- a/GermanApp/Presentation/Controllers/AuthController.cs +++ b/GermanApp/Presentation/Controllers/AuthController.cs @@ -27,6 +27,7 @@ public class AuthController : ControllerBase /// Registration data /// Authentication response with JWT token [HttpPost("register")] + [AllowAnonymous] [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.BadRequest)] public async Task Register([FromBody] RegisterDto registerDto) @@ -52,6 +53,7 @@ public class AuthController : ControllerBase /// Login data /// Authentication response with JWT token [HttpPost("login")] + [AllowAnonymous] [ProducesResponseType(typeof(AuthResponse), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(string), (int)HttpStatusCode.Unauthorized)] public async Task Login([FromBody] LoginDto loginDto) diff --git a/GermanApp/Program.cs b/GermanApp/Program.cs index 0148876..aa912a1 100644 --- a/GermanApp/Program.cs +++ b/GermanApp/Program.cs @@ -78,7 +78,7 @@ try ValidIssuer = jwtIssuer, ValidAudience = jwtAudience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), - ClockSkew = TimeSpan.Zero + ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes of clock difference }; });