using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using ROLAC.API.Data; using ROLAC.API.DTOs.Auth; using ROLAC.API.Entities; using ROLAC.API.Entities.Logging; using ROLAC.API.Services.Logging; namespace ROLAC.API.Services; public class AuthService : IAuthService { private readonly UserManager _userManager; private readonly ITokenService _tokenService; private readonly AppDbContext _db; private readonly IPermissionService _permissions; private readonly IAuditLogger _audit; private readonly int _refreshTokenExpiryDays; public AuthService( UserManager userManager, ITokenService tokenService, AppDbContext db, IPermissionService permissions, IAuditLogger audit, IConfiguration config) { _userManager = userManager; _tokenService = tokenService; _db = db; _permissions = permissions; _audit = audit; _refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30"); } // ------------------------------------------------------------------------- // Login // ------------------------------------------------------------------------- public async Task<(LoginResponse Response, string RawRefreshToken)> LoginAsync( LoginRequest request, string? ipAddress = null, string? deviceInfo = null) { var user = await _userManager.FindByEmailAsync(request.Email); if (user is null) { AuditLoginFailed(request.Email, "Unknown email", ipAddress); throw new UnauthorizedAccessException("Invalid credentials."); } if (!await _userManager.CheckPasswordAsync(user, request.Password)) { AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id); throw new UnauthorizedAccessException("Invalid credentials."); } if (!user.IsActive) { AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id); throw new UnauthorizedAccessException("Account is inactive."); } var roles = await _userManager.GetRolesAsync(user); var accessToken = _tokenService.GenerateAccessToken(user, roles); var rawRefresh = _tokenService.GenerateRefreshToken(); var tokenHash = _tokenService.HashToken(rawRefresh); _db.RefreshTokens.Add(new RefreshToken { UserId = user.Id, TokenHash = tokenHash, ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenExpiryDays), CreatedAt = DateTime.UtcNow, IpAddress = ipAddress, DeviceInfo = deviceInfo, }); user.LastLoginAt = DateTime.UtcNow; await _userManager.UpdateAsync(user); await _db.SaveChangesAsync(); _audit.Write( AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information, entityName: nameof(AppUser), entityId: user.Id, summary: $"Login succeeded: {user.Email}", userId: user.Id, userEmail: user.Email, ipAddress: ipAddress); return (await BuildResponseAsync(accessToken, user, roles), rawRefresh); } private void AuditLoginFailed(string email, string reason, string? ipAddress, string? userId = null) => _audit.Write( AuditActions.LoginFailed, AuditCategories.Security, LogLevelEnum.Warning, entityName: nameof(AppUser), entityId: userId, summary: $"Login failed ({reason}): {email}", userId: userId, userEmail: email, ipAddress: ipAddress); // ------------------------------------------------------------------------- // Refresh // ------------------------------------------------------------------------- public async Task<(LoginResponse Response, string RawRefreshToken)> RefreshAsync( string rawRefreshToken, string? ipAddress = null) { var hash = _tokenService.HashToken(rawRefreshToken); var token = await _db.RefreshTokens .FirstOrDefaultAsync(rt => rt.TokenHash == hash); if (token is null || !token.IsActive) throw new UnauthorizedAccessException("Invalid or expired refresh token."); var user = await _userManager.FindByIdAsync(token.UserId); if (user is null) throw new UnauthorizedAccessException("User not found."); var roles = await _userManager.GetRolesAsync(user); var newAccess = _tokenService.GenerateAccessToken(user, roles); var newRaw = _tokenService.GenerateRefreshToken(); var newHash = _tokenService.HashToken(newRaw); // Rotate: mark old token replaced+revoked, create new token token.RevokedAt = DateTime.UtcNow; token.ReplacedByHash = newHash; _db.RefreshTokens.Add(new RefreshToken { UserId = user.Id, TokenHash = newHash, ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenExpiryDays), CreatedAt = DateTime.UtcNow, IpAddress = ipAddress, DeviceInfo = token.DeviceInfo, }); await _db.SaveChangesAsync(); return (await BuildResponseAsync(newAccess, user, roles), newRaw); } // ------------------------------------------------------------------------- // Logout // ------------------------------------------------------------------------- public async Task LogoutAsync(string rawRefreshToken) { var hash = _tokenService.HashToken(rawRefreshToken); var token = await _db.RefreshTokens .FirstOrDefaultAsync(rt => rt.TokenHash == hash); if (token is not null && token.IsActive) { token.RevokedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); _audit.Write( AuditActions.Logout, AuditCategories.Security, LogLevelEnum.Information, entityName: nameof(AppUser), entityId: token.UserId, summary: "Logout", userId: token.UserId); } } // ------------------------------------------------------------------------- // Change password // ------------------------------------------------------------------------- public async Task ChangePasswordAsync( string userId, string currentPassword, string newPassword, string? currentRawRefreshToken) { var user = await _userManager.FindByIdAsync(userId); if (user is null) return IdentityResult.Failed(new IdentityError { Code = "UserNotFound", Description = "User not found.", }); var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword); if (!result.Succeeded) return result; // Revoke the user's other active sessions; keep the current one alive. var currentHash = currentRawRefreshToken is null ? null : _tokenService.HashToken(currentRawRefreshToken); var otherTokens = await _db.RefreshTokens .Where(rt => rt.UserId == userId && rt.RevokedAt == null && (currentHash == null || rt.TokenHash != currentHash)) .ToListAsync(); foreach (var token in otherTokens) token.RevokedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); _audit.Write( AuditActions.PasswordChanged, AuditCategories.Security, LogLevelEnum.Information, entityName: nameof(AppUser), entityId: user.Id, summary: $"Password changed: {user.Email}", userId: user.Id, userEmail: user.Email); return result; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- private async Task BuildResponseAsync( string accessToken, AppUser user, IList roles) => new() { AccessToken = accessToken, ExpiresIn = 15 * 60, User = await BuildUserInfoAsync(user, roles), }; /// Builds UserInfo including the effective permission map. Reused by /me. public async Task BuildUserInfoAsync(AppUser user, IList roles) => new() { Id = user.Id, Email = user.Email!, Roles = roles, LanguagePreference = user.LanguagePreference, Permissions = await _permissions.GetEffectivePermissionsAsync(roles), MemberInfo = await BuildMemberInfoAsync(user), }; /// /// Loads the linked member's display fields, or null when the account has no /// MemberId or its member record was soft-deleted (excluded by query filter). /// private async Task BuildMemberInfoAsync(AppUser user) { if (user.MemberId is not int memberId) return null; return await _db.Members .Where(member => member.Id == memberId) .Select(member => new MemberInfo { Id = member.Id, NickName = member.NickName, FirstName_en = member.FirstName_en, LastName_en = member.LastName_en, FirstName_zh = member.FirstName_zh, LastName_zh = member.LastName_zh, }) .FirstOrDefaultAsync(); } }