Task 6: AuthService + 9 unit tests (16/16 pass)
- IAuthService: LoginAsync / RefreshAsync / LogoutAsync - AuthService: refresh-token rotation, hashed storage, LastLoginAt update - AuthServiceTests: 5 login + 3 refresh + 1 logout tests via Moq + EF InMemory Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly int _refreshTokenExpiryDays;
|
||||
|
||||
public AuthService(
|
||||
UserManager<AppUser> userManager,
|
||||
ITokenService tokenService,
|
||||
AppDbContext db,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_tokenService = tokenService;
|
||||
_db = db;
|
||||
_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)
|
||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||
|
||||
if (!await _userManager.CheckPasswordAsync(user, request.Password))
|
||||
throw new UnauthorizedAccessException("Invalid credentials.");
|
||||
|
||||
if (!user.IsActive)
|
||||
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();
|
||||
|
||||
return (BuildResponse(accessToken, user, roles), rawRefresh);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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 (BuildResponse(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();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static LoginResponse BuildResponse(
|
||||
string accessToken, AppUser user, IList<string> roles)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresIn = 15 * 60,
|
||||
User = new UserInfo
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email!,
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user