a79d8282dc
- 15 Controller-Klassen ersetzen Minimal APIs in Program.cs - Repository Pattern mit Interfaces + Implementierungen (Project, Task, Activity, User) - AuthService verwendet jetzt IUserRepository statt direktem DbContext-Zugriff - SecurityHeadersMiddleware als eigenständige Middleware-Klasse - PathSecurityHelper als gemeinsamer Helper für Pfadvalidierung - DTOs in eigenem Namespace Nexus.Api.DTOs - EF-Entities in Nexus.Api.Data (vorher Nexus.Api.Domain) - Program.cs auf DI-Registrierung + Middleware reduziert - Alle 43 Endpoints unverändert erhalten - Build + 3/3 Tests erfolgreich
229 lines
8.3 KiB
C#
229 lines
8.3 KiB
C#
using Microsoft.IdentityModel.Tokens;
|
|
using Nexus.Api.DTOs;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Repositories;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
public interface IAuthService
|
|
{
|
|
Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default);
|
|
Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default);
|
|
Task RevokeAsync(string refreshToken, CancellationToken ct = default);
|
|
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
|
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
|
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
|
}
|
|
|
|
public sealed record AuthSession(
|
|
string AccessToken,
|
|
string RefreshToken,
|
|
DateTimeOffset ExpiresAt,
|
|
UserInfo User);
|
|
|
|
public sealed class AuthService : IAuthService
|
|
{
|
|
private readonly IUserRepository _users;
|
|
private readonly IConfiguration _config;
|
|
private readonly ILogger<AuthService> _logger;
|
|
|
|
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
|
{
|
|
_users = users;
|
|
_config = config;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AuthSession?> LoginAsync(LoginRequest request, CancellationToken ct = default)
|
|
{
|
|
var normalizedEmail = NormalizeEmail(request.Email);
|
|
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
|
|
|
|
if (user is null || !PasswordSecurity.Verify(request.Password, user.PasswordHash, out var needsUpgrade))
|
|
{
|
|
_logger.LogWarning("Rejected login attempt");
|
|
return null;
|
|
}
|
|
|
|
if (needsUpgrade) user.PasswordHash = PasswordSecurity.Hash(request.Password);
|
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
|
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
|
}
|
|
|
|
public async Task<AuthSession?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(refreshToken)) return null;
|
|
|
|
var tokenHash = HashToken(refreshToken);
|
|
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
|
|
|
if (token is null) return null;
|
|
|
|
if (token.RevokedAt is not null)
|
|
{
|
|
await RevokeFamilyAsync(token.FamilyId, ct);
|
|
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
|
return null;
|
|
}
|
|
|
|
if (token.ExpiresAt <= DateTimeOffset.UtcNow) return null;
|
|
|
|
return await CreateSessionAsync(token.User, token.FamilyId, token, ct);
|
|
}
|
|
|
|
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
|
|
|
var tokenHash = HashToken(refreshToken);
|
|
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
|
if (token is null || token.RevokedAt is not null) return;
|
|
|
|
token.RevokedAt = DateTimeOffset.UtcNow;
|
|
token.ConcurrencyStamp = Guid.NewGuid();
|
|
await _users.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
|
=> Task.Run(async () =>
|
|
{
|
|
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
|
// For read-only access, we call it but the result shouldn't be mutated
|
|
return await _users.GetByIdAsync(userId, ct);
|
|
}, ct);
|
|
|
|
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
|
{
|
|
var user = await _users.GetByIdAsync(userId, ct);
|
|
if (user is null) return null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.DisplayName))
|
|
{
|
|
user.DisplayName = request.DisplayName.Trim();
|
|
}
|
|
|
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await _users.UpdateAsync(user, ct);
|
|
return user;
|
|
}
|
|
|
|
public async Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default)
|
|
{
|
|
var user = await _users.GetByIdAsync(userId, ct);
|
|
if (user is null) return false;
|
|
|
|
if (!PasswordSecurity.Verify(request.CurrentPassword, user.PasswordHash, out _))
|
|
return false;
|
|
|
|
user.PasswordHash = PasswordSecurity.Hash(request.NewPassword);
|
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await _users.UpdateAsync(user, ct);
|
|
return true;
|
|
}
|
|
|
|
private async Task<AuthSession?> CreateSessionAsync(
|
|
NexusUser user,
|
|
Guid familyId,
|
|
RefreshToken? replacedToken,
|
|
CancellationToken ct)
|
|
{
|
|
var accessExpiresAt = DateTimeOffset.UtcNow.AddMinutes(GetAccessTokenExpirationMinutes());
|
|
var rawRefreshToken = GenerateRefreshToken();
|
|
var refreshTokenHash = HashToken(rawRefreshToken);
|
|
|
|
if (replacedToken is not null)
|
|
{
|
|
replacedToken.RevokedAt = DateTimeOffset.UtcNow;
|
|
replacedToken.ReplacedByTokenHash = refreshTokenHash;
|
|
replacedToken.ConcurrencyStamp = Guid.NewGuid();
|
|
await _users.UpdateRefreshTokenAsync(replacedToken, ct);
|
|
}
|
|
|
|
await _users.AddRefreshTokenAsync(new RefreshToken
|
|
{
|
|
UserId = user.Id,
|
|
TokenHash = refreshTokenHash,
|
|
FamilyId = familyId,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(GetRefreshTokenExpirationDays())
|
|
}, ct);
|
|
|
|
return new AuthSession(
|
|
GenerateAccessToken(user, accessExpiresAt),
|
|
rawRefreshToken,
|
|
accessExpiresAt,
|
|
ToUserInfo(user));
|
|
}
|
|
|
|
private string GenerateAccessToken(NexusUser user, DateTimeOffset expiresAt)
|
|
{
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetRequiredConfig("Jwt:Key")));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
var claims = new[]
|
|
{
|
|
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
|
new Claim(JwtRegisteredClaimNames.Email, user.Email),
|
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
new Claim(ClaimTypes.Role, user.Role),
|
|
new Claim("display_name", user.DisplayName)
|
|
};
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: GetRequiredConfig("Jwt:Issuer"),
|
|
audience: GetRequiredConfig("Jwt:Audience"),
|
|
claims: claims,
|
|
notBefore: DateTime.UtcNow,
|
|
expires: expiresAt.UtcDateTime,
|
|
signingCredentials: credentials);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
|
{
|
|
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
|
var now = DateTimeOffset.UtcNow;
|
|
foreach (var token in activeTokens)
|
|
{
|
|
token.RevokedAt = now;
|
|
token.ConcurrencyStamp = Guid.NewGuid();
|
|
}
|
|
|
|
await _users.SaveChangesAsync(ct);
|
|
}
|
|
|
|
private static string GenerateRefreshToken()
|
|
{
|
|
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
|
return value.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|
|
|
|
private static string HashToken(string token)
|
|
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
|
|
|
public static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
|
|
|
|
private static UserInfo ToUserInfo(NexusUser user) => new()
|
|
{
|
|
Id = user.Id,
|
|
Email = user.Email,
|
|
DisplayName = user.DisplayName,
|
|
Role = user.Role
|
|
};
|
|
|
|
private string GetRequiredConfig(string key)
|
|
=> _config[key] ?? throw new InvalidOperationException($"Missing required configuration: {key}");
|
|
|
|
private int GetAccessTokenExpirationMinutes()
|
|
=> _config.GetValue<int?>("Jwt:AccessTokenExpirationMinutes") ?? 15;
|
|
|
|
private int GetRefreshTokenExpirationDays()
|
|
=> _config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
|
|
}
|