mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +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.
138 lines
5.1 KiB
C#
138 lines
5.1 KiB
C#
using FluentAssertions;
|
|
using Moq;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.Implementation;
|
|
using Xunit;
|
|
|
|
namespace Api.SeaHavenIndustries.Tests;
|
|
|
|
public class SessionStampServiceTests
|
|
{
|
|
private const string UserId = "user-1";
|
|
|
|
private readonly Mock<IUserDataService> _users = new();
|
|
private readonly SteppedTimeProvider _time = new();
|
|
private readonly InMemorySessionStampCache _cache;
|
|
|
|
public SessionStampServiceTests()
|
|
{
|
|
_cache = new InMemorySessionStampCache(_time);
|
|
}
|
|
|
|
private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") =>
|
|
new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret }));
|
|
|
|
private void StoredStamp(string? stamp) =>
|
|
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>())).ReturnsAsync(stamp);
|
|
|
|
[Fact]
|
|
public void The_token_carries_a_keyed_hash_never_the_stamp_itself()
|
|
{
|
|
var value = NewService().ClaimValueFor("STAMP-ONE");
|
|
|
|
value.Should().NotContain("STAMP-ONE");
|
|
value.Should().Be(NewService().ClaimValueFor("STAMP-ONE"));
|
|
value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO"));
|
|
value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_matching_stamp_is_read_once_per_cache_lifetime()
|
|
{
|
|
StoredStamp("STAMP-ONE");
|
|
var service = NewService();
|
|
var issued = service.ClaimValueFor("STAMP-ONE");
|
|
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
(await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
|
|
_users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires()
|
|
{
|
|
StoredStamp("STAMP-ONE");
|
|
var service = NewService();
|
|
var issued = service.ClaimValueFor("STAMP-ONE");
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
|
|
StoredStamp("STAMP-TWO");
|
|
_time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1));
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
|
|
_time.Advance(TimeSpan.FromSeconds(1));
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stamp_changed_by_this_instance_is_enforced_at_once()
|
|
{
|
|
StoredStamp("STAMP-ONE");
|
|
var service = NewService();
|
|
var issued = service.ClaimValueFor("STAMP-ONE");
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
|
|
StoredStamp("STAMP-TWO");
|
|
service.Forget(UserId);
|
|
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_read_that_races_a_change_is_not_cached()
|
|
{
|
|
var service = NewService();
|
|
var issued = service.ClaimValueFor("STAMP-ONE");
|
|
var reads = 0;
|
|
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(() =>
|
|
{
|
|
// The first read returns the old stamp while this instance saves a new one.
|
|
if (reads++ == 0)
|
|
{
|
|
service.Forget(UserId);
|
|
return "STAMP-ONE";
|
|
}
|
|
|
|
return "STAMP-TWO";
|
|
});
|
|
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
|
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored)
|
|
{
|
|
StoredStamp(stored);
|
|
var service = NewService();
|
|
|
|
(await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse();
|
|
(await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue)
|
|
{
|
|
StoredStamp("STAMP-ONE");
|
|
|
|
(await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse();
|
|
|
|
_users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
private sealed class SteppedTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|