Files
ROLAC/API/ROLAC.API/Services/AuthService.cs

230 lines
8.8 KiB
C#

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<AppUser> _userManager;
private readonly ITokenService _tokenService;
private readonly AppDbContext _db;
private readonly IPermissionService _permissions;
private readonly IAuditLogger _audit;
private readonly int _refreshTokenExpiryDays;
public AuthService(
UserManager<AppUser> 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<IdentityResult> 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<LoginResponse> BuildResponseAsync(
string accessToken, AppUser user, IList<string> roles)
=> new()
{
AccessToken = accessToken,
ExpiresIn = 15 * 60,
User = await BuildUserInfoAsync(user, roles),
};
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
=> new()
{
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
};
}