DeutschLernen/GermanApp/Infrastructure/Services/AuthService.cs
Lasse Rune Hansen ef72cbea18 fix(backend/auth): Fix JWT token claim mapping for user ID
- 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>
2026-06-14 17:38:27 +02:00

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");
}
}