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 LoginAsync(LoginRequest request, CancellationToken ct = default); Task RefreshAsync(string refreshToken, CancellationToken ct = default); Task RevokeAsync(string refreshToken, CancellationToken ct = default); Task GetUserAsync(Guid userId, CancellationToken ct = default); Task UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default); Task ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default); Task AdminResetPasswordAsync(string email, string newPassword, string adminToken, 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 _logger; private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty; public AuthService(IUserRepository users, IConfiguration config, ILogger logger) { _users = users; _config = config; _logger = logger; } public async Task 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 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 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 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 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; } public async Task AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default) { // Validate admin token if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken)) { _logger.LogWarning("Admin password reset attempted without admin token or token not configured"); return false; } if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(adminToken), Encoding.UTF8.GetBytes(AdminResetToken))) { _logger.LogWarning("Invalid admin reset token provided"); return false; } if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword)) return false; if (newPassword.Length < 10) return false; var normalizedEmail = NormalizeEmail(email); var user = await _users.GetByEmailAsync(normalizedEmail, ct); if (user is null) { _logger.LogWarning("Admin password reset: user {Email} not found", email); return false; } user.PasswordHash = PasswordSecurity.Hash(newPassword); user.UpdatedAt = DateTimeOffset.UtcNow; await _users.UpdateAsync(user, ct); _logger.LogInformation("Admin password reset completed for {Email}", email); return true; } private async Task 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("Jwt:AccessTokenExpirationMinutes") ?? 15; private int GetRefreshTokenExpirationDays() => _config.GetValue("Jwt:RefreshTokenExpirationDays") ?? 7; }