mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
fix(auth): end earlier sessions when a user's role or account changes
A token carries the user's roles and account, so a demoted admin kept admin claims until the token expired. The team member update and the admin user edit now rotate the security stamp and evict the cached value when the role, account or user name changes. Permission overrides are read per request and are not in the token.
This commit is contained in:
parent
987ec455f2
commit
498f49a2d8
5 changed files with 144 additions and 9 deletions
|
|
@ -26,7 +26,8 @@ public class UserServiceTests
|
|||
Mock<IUserDataService> userData,
|
||||
Mock<IEmailSender> email,
|
||||
out Mock<IUserRoleStore<ApplicationUser>> store,
|
||||
Mock<IAccountDataService>? accounts = null)
|
||||
Mock<IAccountDataService>? accounts = null,
|
||||
Mock<ISessionStampService>? sessions = null)
|
||||
{
|
||||
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
|
||||
store = s;
|
||||
|
|
@ -35,7 +36,7 @@ public class UserServiceTests
|
|||
userData.Object,
|
||||
(accounts ?? new Mock<IAccountDataService>()).Object,
|
||||
email.Object,
|
||||
Mock.Of<ISessionStampService>());
|
||||
(sessions ?? new Mock<ISessionStampService>()).Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -430,4 +431,42 @@ public class UserServiceTests
|
|||
&& password.Any(char.IsDigit)
|
||||
&& password.Any(character => !char.IsLetterOrDigit(character));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, "alice@example.com", "Dispatcher", true)]
|
||||
[InlineData(1, "alice@example.com", "Scheduler", true)]
|
||||
[InlineData(1, "alice.new@example.com", "Dispatcher", true)]
|
||||
[InlineData(1, "ALICE@example.com", "dispatcher", false)]
|
||||
public async Task EditUser_EndsEarlierSessionsOnlyWhenTheTokenClaimsChange(
|
||||
int accountId, string email, string role, bool endsSessions)
|
||||
{
|
||||
var existing = IdentityTestHelpers.User("u1", userName: "alice@example.com");
|
||||
existing.AccountId = 1;
|
||||
var stampBefore = existing.SecurityStamp;
|
||||
var userData = new Mock<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var accounts = new Mock<IAccountDataService>();
|
||||
accounts.Setup(a => a.ExistsAsync(It.IsAny<int>())).ReturnsAsync(true);
|
||||
var sessions = new Mock<ISessionStampService>();
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts, sessions);
|
||||
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "Dispatcher" });
|
||||
|
||||
var outcome = await service.EditUserAsync(
|
||||
new EditUserRequestDTO { Id = "u1", Name = "Alice", Email = email, Role = role, AccountId = accountId },
|
||||
Principal("Admin"),
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Success.Should().BeTrue();
|
||||
if (endsSessions)
|
||||
{
|
||||
existing.SecurityStamp.Should().NotBe(stampBefore);
|
||||
sessions.Verify(s => s.Forget("u1"), Times.Once);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.SecurityStamp.Should().Be(stampBefore);
|
||||
sessions.Verify(s => s.Forget(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,13 +234,14 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
|
||||
}
|
||||
|
||||
// A new stamp ends the member's earlier sessions, and keeps them ended if the
|
||||
// member is reactivated later.
|
||||
if (deactivated)
|
||||
// A new stamp ends the member's earlier sessions: a deactivated member's stay
|
||||
// ended if the member is reactivated later, and a member whose role changed
|
||||
// signs in again to get a token with the new role.
|
||||
if (deactivated || roleChanged)
|
||||
user.SecurityStamp = Guid.NewGuid().ToString("N");
|
||||
|
||||
await _userDataService.UpdateUserAsync(user, cancellationToken);
|
||||
if (activeChanged || emailChanged)
|
||||
if (activeChanged || emailChanged || roleChanged)
|
||||
_sessionStamps.Forget(user.Id);
|
||||
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
||||
if (roleChanged || request.PermissionOverrides is not null)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ namespace SeaHaven.Services.Implementation
|
|||
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.UserNotFound };
|
||||
|
||||
var existingRole = await _userManager.GetRolesAsync(exist1);
|
||||
var claimsChanged = exist1.AccountId != dto.AccountId
|
||||
|| existingRole == null
|
||||
|| existingRole.Count != 1
|
||||
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
|
||||
{
|
||||
|
|
@ -117,11 +121,15 @@ namespace SeaHaven.Services.Implementation
|
|||
exist1.Contact = model.Contact;
|
||||
exist1.PhoneNumber = model.PhoneNumber;
|
||||
exist1.AccountId = dto.AccountId;
|
||||
if (claimsChanged)
|
||||
exist1.SecurityStamp = Guid.NewGuid().ToString("N");
|
||||
|
||||
await _userDataService.UpdateUserAsync(exist1, cancellationToken);
|
||||
|
||||
await _userManager.AddToRoleAsync(exist1, dto.Role ?? "");
|
||||
await _userManager.UpdateAsync(exist1);
|
||||
if (claimsChanged)
|
||||
_sessionStamps.Forget(exist1.Id);
|
||||
return new AddUserOutcomeDTO { Success = true };
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +158,15 @@ namespace SeaHaven.Services.Implementation
|
|||
if (string.IsNullOrWhiteSpace(dto.Email))
|
||||
throw new ArgumentException("Email is required.", nameof(dto));
|
||||
|
||||
// The token carries the user name, roles and account, so a change to any of
|
||||
// them needs a fresh sign-in.
|
||||
var existingRole = await _userManager.GetRolesAsync(exist);
|
||||
var claimsChanged = exist.AccountId != dto.AccountId
|
||||
|| !string.Equals(exist.UserName, dto.Email, StringComparison.OrdinalIgnoreCase)
|
||||
|| existingRole == null
|
||||
|| existingRole.Count != 1
|
||||
|| !string.Equals(existingRole[0], dto.Role, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
exist.EmailConfirmed = true;
|
||||
exist.UserName = dto.Email;
|
||||
exist.Email = dto.Email;
|
||||
|
|
@ -160,14 +177,17 @@ namespace SeaHaven.Services.Implementation
|
|||
exist.PhoneNumber = dto.Role;
|
||||
exist.AccountId = dto.AccountId;
|
||||
|
||||
var existingRole = await _userManager.GetRolesAsync(exist);
|
||||
if (existingRole != null && existingRole.Any())
|
||||
{
|
||||
await _userManager.RemoveFromRolesAsync(exist, existingRole);
|
||||
}
|
||||
await _userManager.AddToRoleAsync(exist, dto.Role ?? "");
|
||||
|
||||
if (claimsChanged)
|
||||
exist.SecurityStamp = Guid.NewGuid().ToString("N");
|
||||
await _userDataService.UpdateUserAsync(exist, cancellationToken);
|
||||
if (claimsChanged)
|
||||
_sessionStamps.Forget(exist.Id);
|
||||
return new AddUserOutcomeDTO { Success = true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,12 +163,79 @@ public sealed class SessionRevocationTests
|
|||
Assert.Empty(leaks);
|
||||
}
|
||||
|
||||
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive)
|
||||
[Fact]
|
||||
public async Task A_member_demoted_on_the_team_page_is_refused_and_signs_in_again_with_the_new_role()
|
||||
{
|
||||
await using var host = await SessionTestHost.StartAsync();
|
||||
await host.AddUserAsync("admin@example.com", "Admin");
|
||||
var member = await host.AddUserAsync("sam@example.com", "Admin");
|
||||
await host.EnsureRoleAsync("Scheduler");
|
||||
var admin = await host.SignInAsync("admin@example.com");
|
||||
var before = await host.SignInAsync("sam@example.com");
|
||||
Assert.Equal(new[] { "Admin" }, RolesIn(before));
|
||||
await AssertAcceptedAsync(host, before);
|
||||
|
||||
await UpdateMemberAsync(host, admin, member.Id, isActive: true, role: "Scheduler");
|
||||
|
||||
await AssertRefusedAsync(host, before);
|
||||
var after = await host.SignInAsync("sam@example.com");
|
||||
Assert.Equal(new[] { "Scheduler" }, RolesIn(after));
|
||||
await AssertAcceptedAsync(host, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_user_whose_role_an_admin_edits_is_refused_and_signs_in_again_with_the_new_role()
|
||||
{
|
||||
await using var host = await SessionTestHost.StartAsync();
|
||||
await host.AddUserAsync("admin@example.com", "Admin");
|
||||
var member = await host.AddUserAsync("sam@example.com", "Admin");
|
||||
await host.EnsureRoleAsync("Dispatcher");
|
||||
var admin = await host.SignInAsync("admin@example.com");
|
||||
var before = await host.SignInAsync("sam@example.com");
|
||||
await AssertAcceptedAsync(host, before);
|
||||
|
||||
var edit = new
|
||||
{
|
||||
id = member.Id,
|
||||
name = "Sam",
|
||||
email = "sam@example.com",
|
||||
role = "Dispatcher"
|
||||
};
|
||||
using (var edited = await host.SendAsync(HttpMethod.Put, "api/User/EditUser", admin, edit))
|
||||
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
|
||||
|
||||
await AssertRefusedAsync(host, before);
|
||||
var after = await host.SignInAsync("sam@example.com");
|
||||
Assert.Equal(new[] { "Dispatcher" }, RolesIn(after));
|
||||
await AssertAcceptedAsync(host, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editing_a_member_without_changing_role_status_or_email_keeps_their_session()
|
||||
{
|
||||
await using var host = await SessionTestHost.StartAsync();
|
||||
await host.AddUserAsync("admin@example.com", "Admin");
|
||||
var member = await host.AddUserAsync("sam@example.com", "Scheduler");
|
||||
var admin = await host.SignInAsync("admin@example.com");
|
||||
var before = await host.SignInAsync("sam@example.com");
|
||||
|
||||
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
|
||||
|
||||
await AssertAcceptedAsync(host, before);
|
||||
}
|
||||
|
||||
private static string[] RolesIn(string token) =>
|
||||
new JwtSecurityTokenHandler().ReadJwtToken(token).Claims
|
||||
.Where(claim => claim.Type == ClaimTypes.Role)
|
||||
.Select(claim => claim.Value)
|
||||
.ToArray();
|
||||
|
||||
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive, string role = "Scheduler")
|
||||
{
|
||||
using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new
|
||||
{
|
||||
name = "Sam Lee",
|
||||
role = "Scheduler",
|
||||
role,
|
||||
color = "#0D9488",
|
||||
email = "sam@example.com",
|
||||
phone = "555-0100",
|
||||
|
|
|
|||
|
|
@ -139,6 +139,14 @@ internal sealed class SessionTestHost : IAsyncDisposable
|
|||
return user;
|
||||
}
|
||||
|
||||
public async Task EnsureRoleAsync(string role)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
if (!await roles.RoleExistsAsync(role))
|
||||
await roles.CreateAsync(new IdentityRole(role));
|
||||
}
|
||||
|
||||
public async Task<string> SignInAsync(string email, string password = Password)
|
||||
{
|
||||
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue