mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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.
259 lines
11 KiB
C#
259 lines
11 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net;
|
|
using System.Security.Claims;
|
|
|
|
namespace SeaHavenIndustries.Tests;
|
|
|
|
/// <summary>
|
|
/// A sign-in token must stop working once the account's password is reset or changed,
|
|
/// or once the account is deactivated or deleted. Every case goes through the real API
|
|
/// host: sign in over HTTP, change the account through its endpoint, then call an
|
|
/// authorized endpoint with the old and the new token.
|
|
/// </summary>
|
|
public sealed class SessionRevocationTests
|
|
{
|
|
private const string SessionStampClaim = "session_stamp";
|
|
private const string NewPassword = "Quiet-Tide-77!";
|
|
|
|
[Fact]
|
|
public async Task A_token_issued_before_a_password_reset_is_refused_and_the_new_sign_in_works()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("sam@example.com");
|
|
var before = await host.SignInAsync("sam@example.com");
|
|
await AssertAcceptedAsync(host, before);
|
|
|
|
await host.ResetPasswordAsync("sam@example.com", NewPassword);
|
|
|
|
await AssertRefusedAsync(host, before);
|
|
var after = await host.SignInAsync("sam@example.com", NewPassword);
|
|
await AssertAcceptedAsync(host, after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_token_issued_before_a_password_change_is_refused_and_the_new_sign_in_works()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("sam@example.com");
|
|
var before = await host.SignInAsync("sam@example.com");
|
|
var other = await host.SignInAsync("sam@example.com");
|
|
await AssertAcceptedAsync(host, other);
|
|
|
|
var change = new
|
|
{
|
|
currentpassword = SessionTestHost.Password,
|
|
newpassword = NewPassword,
|
|
confirmpassword = NewPassword
|
|
};
|
|
using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, change))
|
|
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
|
|
|
|
await AssertRefusedAsync(host, before);
|
|
await AssertRefusedAsync(host, other);
|
|
var after = await host.SignInAsync("sam@example.com", NewPassword);
|
|
await AssertAcceptedAsync(host, after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_deactivated_member_is_refused_and_the_old_token_stays_refused_after_reactivation()
|
|
{
|
|
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 AssertAcceptedAsync(host, before);
|
|
|
|
await UpdateMemberAsync(host, admin, member.Id, isActive: false);
|
|
await AssertRefusedAsync(host, before);
|
|
|
|
await UpdateMemberAsync(host, admin, member.Id, isActive: true);
|
|
await AssertRefusedAsync(host, before);
|
|
var after = await host.SignInAsync("sam@example.com");
|
|
await AssertAcceptedAsync(host, after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_token_for_an_account_its_owner_deleted_is_refused()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("sam@example.com");
|
|
var before = await host.SignInAsync("sam@example.com");
|
|
|
|
using (var deleted = await host.SendAsync(HttpMethod.Post, "api/User/DeleteCurrentUser", before))
|
|
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
|
|
|
await AssertRefusedAsync(host, before);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_token_for_an_account_an_admin_deleted_is_refused()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("admin@example.com", "Admin");
|
|
var member = await host.AddUserAsync("sam@example.com");
|
|
var admin = await host.SignInAsync("admin@example.com");
|
|
var before = await host.SignInAsync("sam@example.com");
|
|
await AssertAcceptedAsync(host, before);
|
|
|
|
using (var deleted = await host.SendAsync(HttpMethod.Delete, "api/User/DeleteUser", admin, new { id = member.Id }))
|
|
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
|
|
|
await AssertRefusedAsync(host, before);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_correctly_signed_token_with_a_forged_session_stamp_is_refused()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("sam@example.com");
|
|
var issued = await host.SignInAsync("sam@example.com");
|
|
await AssertAcceptedAsync(host, issued);
|
|
|
|
var forged = SessionTestHost.Resign(issued, claims =>
|
|
{
|
|
claims.RemoveAll(claim => claim.Type == SessionStampClaim);
|
|
claims.Add(new Claim(SessionStampClaim, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
|
});
|
|
var resignedUnchanged = SessionTestHost.Resign(issued, _ => { });
|
|
|
|
await AssertAcceptedAsync(host, resignedUnchanged);
|
|
await AssertRefusedAsync(host, forged);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_correctly_signed_token_without_a_session_stamp_is_refused()
|
|
{
|
|
// The shape of every token issued before this check shipped.
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
await host.AddUserAsync("sam@example.com");
|
|
var issued = await host.SignInAsync("sam@example.com");
|
|
|
|
var stampless = SessionTestHost.Resign(issued, claims => claims.RemoveAll(claim => claim.Type == SessionStampClaim));
|
|
|
|
await AssertRefusedAsync(host, stampless);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_refused_token_gets_the_same_401_as_no_token_and_nothing_sensitive_is_logged()
|
|
{
|
|
await using var host = await SessionTestHost.StartAsync();
|
|
var user = await host.AddUserAsync("sam@example.com");
|
|
var before = await host.SignInAsync("sam@example.com");
|
|
var stampClaim = new JwtSecurityTokenHandler().ReadJwtToken(before).Claims
|
|
.SingleOrDefault(claim => claim.Type == SessionStampClaim)?.Value;
|
|
var oldStamp = await host.StoredSecurityStampAsync(user.Id);
|
|
|
|
await host.ResetPasswordAsync("sam@example.com", NewPassword);
|
|
|
|
using var refused = await host.ProfileAsync(before);
|
|
using var anonymous = await host.ProfileAsync(null);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
|
Assert.Equal(await anonymous.Content.ReadAsStringAsync(), await refused.Content.ReadAsStringAsync());
|
|
Assert.Empty(await refused.Content.ReadAsStringAsync());
|
|
|
|
var newStamp = await host.StoredSecurityStampAsync(user.Id);
|
|
var secrets = new[] { before, oldStamp, newStamp, stampClaim, "sam@example.com" }
|
|
.Where(secret => !string.IsNullOrEmpty(secret))
|
|
.ToList();
|
|
var leaks = host.Logged.Entries
|
|
.Where(entry => secrets.Any(secret => entry.Contains(secret!, StringComparison.OrdinalIgnoreCase)))
|
|
.ToList();
|
|
Assert.Empty(leaks);
|
|
}
|
|
|
|
[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,
|
|
color = "#0D9488",
|
|
email = "sam@example.com",
|
|
phone = "555-0100",
|
|
serviceAreas = Array.Empty<string>(),
|
|
isActive
|
|
});
|
|
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
|
|
}
|
|
|
|
private static async Task AssertAcceptedAsync(SessionTestHost host, string token)
|
|
{
|
|
using var response = await host.ProfileAsync(token);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
private static async Task AssertRefusedAsync(SessionTestHost host, string token)
|
|
{
|
|
using var response = await host.ProfileAsync(token);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
}
|