Files
ROLAC/API/ROLAC.API/Services/AuthService.cs
T
Chris Chen 9db8b34181 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>
2026-05-26 17:38:56 -07:00

146 lines
5.3 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;
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,
},
};
}