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