- 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>
295 lines
11 KiB
C#
295 lines
11 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using GermanApp.Application.DTOs.Auth;
|
|
using GermanApp.Application.Interfaces;
|
|
using GermanApp.Domain.Entities;
|
|
using GermanApp.Infrastructure.Data.DbContext;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace GermanApp.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Authentication service implementation.
|
|
/// Part of the Infrastructure layer.
|
|
/// </summary>
|
|
public class AuthService : IAuthService
|
|
{
|
|
private readonly AppDbContext _dbContext;
|
|
private readonly IPasswordHasher<User> _passwordHasher;
|
|
private readonly IConfiguration _configuration;
|
|
|
|
public AuthService(
|
|
AppDbContext dbContext,
|
|
IPasswordHasher<User> passwordHasher,
|
|
IConfiguration configuration)
|
|
{
|
|
_dbContext = dbContext;
|
|
_passwordHasher = passwordHasher;
|
|
_configuration = configuration;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a cryptographically secure random token string.
|
|
/// </summary>
|
|
private static string GenerateRefreshTokenString(int length = 32)
|
|
{
|
|
var randomNumber = new byte[length];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(randomNumber);
|
|
return Convert.ToBase64String(randomNumber);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a new user.
|
|
/// </summary>
|
|
public async Task<AuthResponse> RegisterAsync(RegisterDto registerDto)
|
|
{
|
|
// 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");
|
|
|
|
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
|
|
var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty);
|
|
var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password);
|
|
user.ChangePassword(passwordHash);
|
|
|
|
_dbContext.Users.Add(user);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
// Generate JWT token
|
|
var token = GenerateJwtToken(user);
|
|
|
|
// Generate and store refresh token
|
|
var refreshTokenString = GenerateRefreshTokenString();
|
|
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
_dbContext.RefreshTokens.Add(refreshToken);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
return new AuthResponse
|
|
{
|
|
UserId = user.Id,
|
|
Username = user.Username,
|
|
Email = user.Email,
|
|
Token = token,
|
|
RefreshToken = refreshTokenString,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates a user and returns a JWT token.
|
|
/// </summary>
|
|
public async Task<AuthResponse> LoginAsync(LoginDto loginDto)
|
|
{
|
|
// 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");
|
|
|
|
// Verify password
|
|
var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, loginDto.Password);
|
|
if (result == PasswordVerificationResult.Failed)
|
|
throw new UnauthorizedAccessException("Invalid email or password");
|
|
|
|
// Revoke any existing refresh tokens for this user (optional: rotate tokens)
|
|
var existingRefreshTokens = await _dbContext.RefreshTokens
|
|
.Where(rt => rt.UserId == user.Id && rt.IsActive)
|
|
.ToListAsync();
|
|
|
|
foreach (var rt in existingRefreshTokens)
|
|
{
|
|
rt.Revoke();
|
|
}
|
|
|
|
// Generate JWT token
|
|
var token = GenerateJwtToken(user);
|
|
|
|
// Generate and store new refresh token
|
|
var refreshTokenString = GenerateRefreshTokenString();
|
|
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
_dbContext.RefreshTokens.Add(refreshToken);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
return new AuthResponse
|
|
{
|
|
UserId = user.Id,
|
|
Username = user.Username,
|
|
Email = user.Email,
|
|
Token = token,
|
|
RefreshToken = refreshTokenString,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current authenticated user.
|
|
/// </summary>
|
|
public async Task<User?> GetCurrentUserAsync(int userId)
|
|
{
|
|
return await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a JWT token for the given user.
|
|
/// </summary>
|
|
private string GenerateJwtToken(User user)
|
|
{
|
|
var securityKey = new SymmetricSecurityKey(
|
|
Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? "super-secret-key-at-least-32-characters"));
|
|
|
|
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
|
|
|
var claims = new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
|
new Claim(ClaimTypes.Name, user.Username),
|
|
new Claim(ClaimTypes.Email, user.Email),
|
|
new Claim(ClaimTypes.Role, user.Role) // Use user's actual role (User or Admin)
|
|
};
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: _configuration["Jwt:Issuer"] ?? "DeutschLernen",
|
|
audience: _configuration["Jwt:Audience"] ?? "DeutschLernen",
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddHours(24),
|
|
signingCredentials: credentials
|
|
);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes the access token using a refresh token.
|
|
/// Rotates the refresh token (generates a new one, revokes the old one).
|
|
/// </summary>
|
|
/// <param name="refreshToken">The refresh token</param>
|
|
/// <returns>New access token and refresh token</returns>
|
|
/// <exception cref="UnauthorizedAccessException">Thrown when refresh token is invalid</exception>
|
|
public async Task<RefreshTokenResponse> RefreshTokenAsync(string refreshToken)
|
|
{
|
|
// Find the refresh token in the database
|
|
var storedToken = await _dbContext.RefreshTokens
|
|
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
|
|
|
if (storedToken == null)
|
|
throw new UnauthorizedAccessException("Invalid refresh token");
|
|
|
|
if (!storedToken.IsValid())
|
|
throw new UnauthorizedAccessException("Invalid refresh token");
|
|
|
|
// Get the user associated with this refresh token
|
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == storedToken.UserId);
|
|
if (user == null)
|
|
throw new UnauthorizedAccessException("User not found for refresh token");
|
|
|
|
// Revoke the current refresh token
|
|
storedToken.Revoke();
|
|
|
|
// Generate new JWT access token
|
|
var newAccessToken = GenerateJwtToken(user);
|
|
|
|
// Generate new refresh token (rotate)
|
|
var newRefreshTokenString = GenerateRefreshTokenString();
|
|
var newRefreshToken = RefreshToken.Create(user.Id, newRefreshTokenString);
|
|
_dbContext.RefreshTokens.Add(newRefreshToken);
|
|
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
return new RefreshTokenResponse
|
|
{
|
|
Token = newAccessToken,
|
|
RefreshToken = newRefreshTokenString,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revokes a refresh token.
|
|
/// </summary>
|
|
/// <param name="refreshToken">The refresh token to revoke</param>
|
|
/// <exception cref="UnauthorizedAccessException">Thrown when refresh token is not found</exception>
|
|
public async Task RevokeRefreshTokenAsync(string refreshToken)
|
|
{
|
|
var storedToken = await _dbContext.RefreshTokens
|
|
.FirstOrDefaultAsync(rt => rt.Token == refreshToken);
|
|
|
|
if (storedToken == null)
|
|
throw new UnauthorizedAccessException("Refresh token not found");
|
|
|
|
storedToken.Revoke();
|
|
await _dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the first admin user (bootstrap).
|
|
/// This is a special method that creates an admin user without requiring authentication.
|
|
/// Only works if no admin user exists yet.
|
|
/// </summary>
|
|
/// <param name="registerDto">Admin user registration data</param>
|
|
/// <returns>Authentication response with token</returns>
|
|
public async Task<AuthResponse> CreateAdminUserAsync(RegisterDto registerDto)
|
|
{
|
|
// Check if any admin user already exists
|
|
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 (case-insensitive for email)
|
|
if (await _dbContext.Users.AnyAsync(u => u.Username == registerDto.Username))
|
|
throw new InvalidOperationException("Username already taken");
|
|
|
|
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
|
|
var user = User.Create(registerDto.Username, registerDto.Email.ToLowerInvariant(), string.Empty);
|
|
var passwordHash = _passwordHasher.HashPassword(user, registerDto.Password);
|
|
user.ChangePassword(passwordHash);
|
|
|
|
// Assign admin role
|
|
user.AssignAdminRole();
|
|
|
|
_dbContext.Users.Add(user);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
// Generate JWT token
|
|
var token = GenerateJwtToken(user);
|
|
|
|
// Generate and store refresh token
|
|
var refreshTokenString = GenerateRefreshTokenString();
|
|
var refreshToken = RefreshToken.Create(user.Id, refreshTokenString);
|
|
_dbContext.RefreshTokens.Add(refreshToken);
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
return new AuthResponse
|
|
{
|
|
UserId = user.Id,
|
|
Username = user.Username,
|
|
Email = user.Email,
|
|
Token = token,
|
|
RefreshToken = refreshTokenString,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(24)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if an admin user already exists.
|
|
/// </summary>
|
|
/// <returns>True if admin user exists, false otherwise</returns>
|
|
public async Task<bool> AdminUserExistsAsync()
|
|
{
|
|
return await _dbContext.Users.AnyAsync(u => u.Role == "Admin");
|
|
}
|
|
}
|