mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Tokens now carry a keyed hash of the account's security stamp, and every authenticated request compares it with the stored stamp (cached for 60 s, evicted in-process on change). A password reset or change, a deactivation and a deletion all rotate or remove the stamp, so tokens issued before them get 401. Tokens without the claim get 401 too.
191 lines
8 KiB
C#
191 lines
8 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);
|
|
|
|
using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, new
|
|
{
|
|
currentpassword = SessionTestHost.Password,
|
|
newpassword = NewPassword,
|
|
confirmpassword = NewPassword
|
|
}))
|
|
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);
|
|
}
|
|
|
|
private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive)
|
|
{
|
|
using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new
|
|
{
|
|
name = "Sam Lee",
|
|
role = "Scheduler",
|
|
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);
|
|
}
|
|
}
|