Merge branch 'feature/change-password'

This commit is contained in:
Chris Chen
2026-06-23 20:36:26 -07:00
21 changed files with 579 additions and 13 deletions
@@ -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;
}
@@ -308,4 +312,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
}
}
@@ -154,6 +154,38 @@ public class AuthController : ControllerBase
return NoContent();
}
// -------------------------------------------------------------------------
// POST /api/auth/change-password
// -------------------------------------------------------------------------
/// <summary>
/// Changes the current user's password. Requires the correct current password and a
/// new password meeting the configured policy. On success the user's *other* sessions
/// are revoked while the current session stays active.
/// </summary>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var currentRefresh = Request.Cookies[CookieName];
var result = await _authService.ChangePasswordAsync(
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
if (!result.Succeeded)
return BadRequest(new
{
message = string.Join(" ", result.Errors.Select(error => error.Description)),
});
return NoContent();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -16,7 +16,7 @@ public class MealAttendanceController : ControllerBase
[HttpGet("today")]
[AllowAnonymous]
public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet]
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class ChangePasswordRequest
{
[Required]
[MaxLength(128)]
public string CurrentPassword { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string NewPassword { get; set; } = null!;
}
+3 -2
View File
@@ -45,6 +45,7 @@ public static class AuditActions
public const string Logout = "Logout";
public const string LoginFailed = "LoginFailed";
public const string RoleChanged = "RoleChanged";
public const string PasswordChanged = "PasswordChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string CheckIssued = "CheckIssued";
@@ -55,8 +56,8 @@ public static class AuditActions
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
ExpenseApproved, StatementFinalized,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized,
];
}
+3 -3
View File
@@ -18,7 +18,7 @@ public class AttendanceHub : Hub
// Push the current counts to a client the moment it connects.
public override async Task OnConnectedAsync()
{
var counts = await _svc.GetOrCreateAsync(_svc.Today);
var counts = await _svc.GetOrCreateAsync(_svc.ServiceDay);
await Clients.Caller.SendAsync("ReceiveCounts", counts);
await base.OnConnectedAsync();
}
@@ -26,14 +26,14 @@ public class AttendanceHub : Hub
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
public async Task Increment(string category, int delta)
{
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
var counts = await _svc.IncrementAsync(_svc.ServiceDay, category, delta);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
public async Task SetCount(string category, int value)
{
var counts = await _svc.SetAsync(_svc.Today, category, value);
var counts = await _svc.SetAsync(_svc.ServiceDay, category, value);
await Clients.All.SendAsync("ReceiveCounts", counts);
}
}
+44
View File
@@ -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
&& (currentHash == 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
// -------------------------------------------------------------------------
+15
View File
@@ -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
@@ -5,7 +5,7 @@ namespace ROLAC.API.Services;
public interface IMealAttendanceService
{
/// <summary>Today's date in the server's local time zone (the church's "current Sunday").</summary>
DateOnly Today { get; }
DateOnly ServiceDay { get; }
/// <summary>Returns the counts for <paramref name="date"/>, creating a zeroed row if none exists.</summary>
Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date);
@@ -12,7 +12,14 @@ public class MealAttendanceService : IMealAttendanceService
public MealAttendanceService(AppDbContext db) => _db = db;
// Server local time is assumed to match the church's local day.
public DateOnly Today => DateOnly.FromDateTime(DateTime.Now);
public DateOnly ServiceDay
{
get
{
var today = DateOnly.FromDateTime(DateTime.Now);
return today.AddDays(-(int)today.DayOfWeek);
}
}
public async Task<AttendanceCountsDto> GetOrCreateAsync(DateOnly date)
{