diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs index 1c147cb..af8f095 100644 --- a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -32,9 +32,10 @@ public class AuthServiceTests /// Creates a mock with sensible defaults. private static Mock> BuildUserManager( - AppUser? findResult = null, - bool passwordOk = true, - IList? roles = null) + AppUser? findResult = null, + bool passwordOk = true, + IList? roles = null, + IdentityResult? changePasswordResult = null) { var store = new Mock>(); // Remaining ctor params are all optional; Moq passes them via reflection. @@ -53,6 +54,9 @@ public class AuthServiceTests .ReturnsAsync(roles ?? new List { "member" }); mgr.Setup(m => m.UpdateAsync(It.IsAny())) .ReturnsAsync(IdentityResult.Success); + mgr.Setup(m => m.ChangePasswordAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(changePasswordResult ?? IdentityResult.Success); return mgr; } @@ -266,4 +270,85 @@ public class AuthServiceTests var token = db.RefreshTokens.Single(); Assert.NotNull(token.RevokedAt); } + + // ----------------------------------------------------------------------- + // Change password tests + // ----------------------------------------------------------------------- + + [Fact] + public async Task ChangePassword_ValidRequest_Succeeds() + { + 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()); + + var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null); + + Assert.True(result.Succeeded); + um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once); + } + + [Fact] + public async Task ChangePassword_UnknownUser_Fails() + { + var um = BuildUserManager(findResult: null); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null); + + Assert.False(result.Succeeded); + um.Verify(m => m.ChangePasswordAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; + var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." }); + var um = BuildUserManager(findResult: user, changePasswordResult: failed); + var ts = BuildTokenService(); + var sut = BuildSut(um, ts, BuildDb()); + + var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null); + + Assert.False(result.Succeeded); + } + + [Fact] + public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent() + { + var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true }; + var um = BuildUserManager(findResult: user); + var ts = BuildTokenService(); // HashToken(x) => "hash:{x}" + var db = BuildDb(); + + // Current session token (raw "current-raw" => "hash:current-raw") + db.RefreshTokens.Add(new RefreshToken + { + UserId = "u1", + TokenHash = "hash:current-raw", + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow.AddHours(-1), + }); + // Another active session on a different device + db.RefreshTokens.Add(new RefreshToken + { + UserId = "u1", + TokenHash = "hash:other-device", + ExpiresAt = DateTime.UtcNow.AddDays(30), + CreatedAt = DateTime.UtcNow.AddHours(-2), + }); + await db.SaveChangesAsync(); + + var sut = BuildSut(um, ts, db); + await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw"); + + var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw"); + var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device"); + Assert.Null(current.RevokedAt); // current session preserved + Assert.NotNull(other.RevokedAt); // other session revoked + } } diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index 39a1332..2155c75 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -159,6 +159,50 @@ public class AuthService : IAuthService } } + // ------------------------------------------------------------------------- + // Change password + // ------------------------------------------------------------------------- + + public async Task 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 + && 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 // ------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Services/IAuthService.cs b/API/ROLAC.API/Services/IAuthService.cs index 29b8e28..39f77ad 100644 --- a/API/ROLAC.API/Services/IAuthService.cs +++ b/API/ROLAC.API/Services/IAuthService.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Identity; using ROLAC.API.DTOs.Auth; using ROLAC.API.Entities; @@ -30,6 +31,20 @@ public interface IAuthService /// Task LogoutAsync(string rawRefreshToken); + /// + /// Changes the password for an already-authenticated user. Verifies the current + /// password and enforces the configured Identity password policy via + /// UserManager.ChangePasswordAsync. On success, revokes the user's other + /// active refresh tokens (keeping the one matching ) + /// and writes a security audit entry. Returns the so the + /// caller can surface failures; never throws on a bad password. + /// + Task ChangePasswordAsync( + string userId, + string currentPassword, + string newPassword, + string? currentRawRefreshToken); + /// /// Builds the UserInfo payload (identity, roles, and effective permissions) for an /// already-authenticated user. Used by GET /api/auth/me to refresh permissions