- Changed AuthService.GenerateJwtToken to use JWT standard claims: - JwtRegisteredClaimNames.Sub for user ID (instead of ClaimTypes.NameIdentifier) - JwtRegisteredClaimNames.Name for username - JwtRegisteredClaimNames.Email for email - JwtRegisteredClaimNames.UniqueName for additional username claim - Kept ClaimTypes.Role for role - Updated AuthController.GetCurrentUser to use JwtRegisteredClaimNames.Sub - Updated AdminController.DeleteUserAsync to use JwtRegisteredClaimNames.Sub This fixes the 401 error when calling /me after login. The issue was that tokens were being generated with ClaimTypes.NameIdentifier claim, but the JWT middleware doesn't automatically map this to a claim that can be found with User.FindFirst(). Using standard JWT claims (sub, name, email) ensures proper compatibility with ASP.NET Core's JWT authentication. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
298 lines
11 KiB
C#
298 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);
|
|
|
|
// Use JWT standard claims for better compatibility
|
|
// "sub" = subject (user identifier), "name" = username, "email" = email, "role" = user role
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), // JWT standard: sub for subject/user ID
|
|
new Claim(JwtRegisteredClaimNames.Name, user.Username),
|
|
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
|
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), // Additional: unique name
|
|
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");
|
|
}
|
|
}
|