diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000..075db9d --- /dev/null +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options); + + private static IConfiguration BuildConfig() => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Jwt:RefreshTokenExpiryDays", "30" }, + }) + .Build(); + + /// Creates a mock with sensible defaults. + private static Mock> BuildUserManager( + AppUser? findResult = null, + bool passwordOk = true, + IList? roles = null) + { + var store = new Mock>(); + // Remaining ctor params are all optional; Moq passes them via reflection. +#pragma warning disable CS8625 + var mgr = new Mock>( + store.Object, null, null, null, null, null, null, null, null); +#pragma warning restore CS8625 + + mgr.Setup(m => m.FindByEmailAsync(It.IsAny())) + .ReturnsAsync(findResult); + mgr.Setup(m => m.FindByIdAsync(It.IsAny())) + .ReturnsAsync(findResult); + mgr.Setup(m => m.CheckPasswordAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(passwordOk); + mgr.Setup(m => m.GetRolesAsync(It.IsAny())) + .ReturnsAsync(roles ?? new List { "member" }); + mgr.Setup(m => m.UpdateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + + return mgr; + } + + /// + /// ITokenService mock: GenerateAccessToken → "access-token", + /// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}". + /// + private static Mock BuildTokenService() + { + var svc = new Mock(); + svc.Setup(t => t.GenerateAccessToken(It.IsAny(), It.IsAny>())) + .Returns("access-token"); + svc.Setup(t => t.GenerateRefreshToken()) + .Returns("raw-refresh"); + svc.Setup(t => t.HashToken(It.IsAny())) + .Returns(s => $"hash:{s}"); + return svc; + } + + private static AuthService BuildSut( + Mock> umMock, + Mock 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( + () => 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( + () => 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( + () => 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(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( + () => 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( + () => 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); + } +} diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs new file mode 100644 index 0000000..9edbfad --- /dev/null +++ b/API/ROLAC.API/Services/AuthService.cs @@ -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 _userManager; + private readonly ITokenService _tokenService; + private readonly AppDbContext _db; + private readonly int _refreshTokenExpiryDays; + + public AuthService( + UserManager 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 roles) + => new() + { + AccessToken = accessToken, + ExpiresIn = 15 * 60, + User = new UserInfo + { + Id = user.Id, + Email = user.Email!, + Roles = roles, + LanguagePreference = user.LanguagePreference, + }, + }; +} diff --git a/API/ROLAC.API/Services/IAuthService.cs b/API/ROLAC.API/Services/IAuthService.cs new file mode 100644 index 0000000..d9e3d7a --- /dev/null +++ b/API/ROLAC.API/Services/IAuthService.cs @@ -0,0 +1,31 @@ +using ROLAC.API.DTOs.Auth; + +namespace ROLAC.API.Services; + +public interface IAuthService +{ + /// + /// 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 on any auth failure. + /// + Task<(LoginResponse Response, string RawRefreshToken)> LoginAsync( + LoginRequest request, + string? ipAddress = null, + string? deviceInfo = null); + + /// + /// Validates a raw refresh token, revokes it, and issues a new token pair (rotation). + /// Throws if the token is not found, + /// expired, or already revoked. + /// + Task<(LoginResponse Response, string RawRefreshToken)> RefreshAsync( + string rawRefreshToken, + string? ipAddress = null); + + /// + /// Revokes the refresh token identified by its raw value. + /// Silently succeeds if the token is not found. + /// + Task LogoutAsync(string rawRefreshToken); +}