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,258 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class AuthServiceTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Factory helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static AppDbContext BuildDb() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
private static IConfiguration BuildConfig() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||
#pragma warning disable CS8625
|
||||
var mgr = new Mock<UserManager<AppUser>>(
|
||||
store.Object, null, null, null, null, null, null, null, null);
|
||||
#pragma warning restore CS8625
|
||||
|
||||
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(findResult);
|
||||
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(findResult);
|
||||
mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(passwordOk);
|
||||
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
return mgr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ITokenService mock: GenerateAccessToken → "access-token",
|
||||
/// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
|
||||
/// </summary>
|
||||
private static Mock<ITokenService> BuildTokenService()
|
||||
{
|
||||
var svc = new Mock<ITokenService>();
|
||||
svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
|
||||
.Returns("access-token");
|
||||
svc.Setup(t => t.GenerateRefreshToken())
|
||||
.Returns("raw-refresh");
|
||||
svc.Setup(t => t.HashToken(It.IsAny<string>()))
|
||||
.Returns<string>(s => $"hash:{s}");
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static AuthService BuildSut(
|
||||
Mock<UserManager<AppUser>> umMock,
|
||||
Mock<ITokenService> tsMock,
|
||||
AppDbContext db)
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Login tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user, roles: new[] { "member" });
|
||||
var ts = BuildTokenService();
|
||||
var db = BuildDb();
|
||||
var sut = BuildSut(um, ts, db);
|
||||
|
||||
var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||
|
||||
Assert.Equal("access-token", response.AccessToken);
|
||||
Assert.Equal(900, response.ExpiresIn);
|
||||
Assert.Equal("u1", response.User.Id);
|
||||
Assert.Equal("a@b.com", response.User.Email);
|
||||
Assert.Contains("member", response.User.Roles);
|
||||
Assert.Equal("raw-refresh", raw);
|
||||
|
||||
// Persisted in DB
|
||||
var stored = db.RefreshTokens.Single();
|
||||
Assert.Equal("hash:raw-refresh", stored.TokenHash);
|
||||
Assert.Equal("u1", stored.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_UnknownEmail_ThrowsUnauthorized()
|
||||
{
|
||||
var um = BuildUserManager(findResult: null);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WrongPassword_ThrowsUnauthorized()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user, passwordOk: false);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_InactiveAccount_ThrowsUnauthorized()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false };
|
||||
var um = BuildUserManager(findResult: user, passwordOk: true);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_Success_UpdatesLastLoginAt()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||
|
||||
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Refresh tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_ValidToken_RotatesRefreshToken()
|
||||
{
|
||||
const string rawOld = "old-raw";
|
||||
const string rawNew = "raw-refresh"; // what BuildTokenService returns
|
||||
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var db = BuildDb();
|
||||
|
||||
// Seed an active token
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:old-raw",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sut = BuildSut(um, ts, db);
|
||||
var (response, newRaw) = await sut.RefreshAsync(rawOld);
|
||||
|
||||
Assert.Equal("access-token", response.AccessToken);
|
||||
Assert.Equal(rawNew, newRaw);
|
||||
|
||||
// Old token revoked
|
||||
var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw");
|
||||
Assert.NotNull(old.RevokedAt);
|
||||
Assert.Equal("hash:raw-refresh", old.ReplacedByHash);
|
||||
|
||||
// New token stored
|
||||
var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh");
|
||||
Assert.Null(newTok.RevokedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_UnknownToken_ThrowsUnauthorized()
|
||||
{
|
||||
var um = BuildUserManager();
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => sut.RefreshAsync("no-such-token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_ExpiredToken_ThrowsUnauthorized()
|
||||
{
|
||||
var db = BuildDb();
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:expired-raw",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(-1), // already expired
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-31),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var um = BuildUserManager();
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, db);
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => sut.RefreshAsync("expired-raw"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Logout tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Logout_ValidToken_RevokesRefreshToken()
|
||||
{
|
||||
const string raw = "my-raw-token";
|
||||
|
||||
var db = BuildDb();
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:my-raw-token",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var um = BuildUserManager();
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, db);
|
||||
|
||||
await sut.LogoutAsync(raw);
|
||||
|
||||
var token = db.RefreshTokens.Single();
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates credentials and returns a new access token plus the raw refresh token
|
||||
/// that must be stored in an HttpOnly cookie by the caller.
|
||||
/// Throws <see cref="UnauthorizedAccessException"/> on any auth failure.
|
||||
/// </summary>
|
||||
Task<(LoginResponse Response, string RawRefreshToken)> LoginAsync(
|
||||
LoginRequest request,
|
||||
string? ipAddress = null,
|
||||
string? deviceInfo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a raw refresh token, revokes it, and issues a new token pair (rotation).
|
||||
/// Throws <see cref="UnauthorizedAccessException"/> if the token is not found,
|
||||
/// expired, or already revoked.
|
||||
/// </summary>
|
||||
Task<(LoginResponse Response, string RawRefreshToken)> RefreshAsync(
|
||||
string rawRefreshToken,
|
||||
string? ipAddress = null);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes the refresh token identified by its raw value.
|
||||
/// Silently succeeds if the token is not found.
|
||||
/// </summary>
|
||||
Task LogoutAsync(string rawRefreshToken);
|
||||
}
|
||||
Reference in New Issue
Block a user