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>
|
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
||||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
AppUser? findResult = null,
|
AppUser? findResult = null,
|
||||||
bool passwordOk = true,
|
bool passwordOk = true,
|
||||||
IList<string>? roles = null)
|
IList<string>? roles = null,
|
||||||
|
IdentityResult? changePasswordResult = null)
|
||||||
{
|
{
|
||||||
var store = new Mock<IUserStore<AppUser>>();
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||||
@@ -53,6 +54,9 @@ public class AuthServiceTests
|
|||||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.ChangePasswordAsync(
|
||||||
|
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||||
|
|
||||||
return mgr;
|
return mgr;
|
||||||
}
|
}
|
||||||
@@ -266,4 +270,85 @@ public class AuthServiceTests
|
|||||||
var token = db.RefreshTokens.Single();
|
var token = db.RefreshTokens.Single();
|
||||||
Assert.NotNull(token.RevokedAt);
|
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
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
@@ -30,6 +31,20 @@ public interface IAuthService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task LogoutAsync(string rawRefreshToken);
|
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>
|
/// <summary>
|
||||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||||
|
|||||||
Reference in New Issue
Block a user