feat(auth): add ChangePasswordAsync with other-session revocation and audit
This commit is contained in:
@@ -32,9 +32,10 @@ public class AuthServiceTests
|
||||
|
||||
/// <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)
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null,
|
||||
IdentityResult? changePasswordResult = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||
@@ -53,6 +54,9 @@ public class AuthServiceTests
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.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<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,50 @@ public class AuthService : IAuthService
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Change password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<IdentityResult> 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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
@@ -30,6 +31,20 @@ public interface IAuthService
|
||||
/// </summary>
|
||||
Task LogoutAsync(string rawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the password for an already-authenticated user. Verifies the current
|
||||
/// password and enforces the configured Identity password policy via
|
||||
/// <c>UserManager.ChangePasswordAsync</c>. On success, revokes the user's other
|
||||
/// active refresh tokens (keeping the one matching <paramref name="currentRawRefreshToken"/>)
|
||||
/// and writes a security audit entry. Returns the <see cref="IdentityResult"/> so the
|
||||
/// caller can surface failures; never throws on a bad password.
|
||||
/// </summary>
|
||||
Task<IdentityResult> ChangePasswordAsync(
|
||||
string userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
string? currentRawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||
|
||||
Reference in New Issue
Block a user