fix(auth): end earlier sessions when a password or account status changes

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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 19:08:33 -03:00
parent e993e1b5fc
commit b7be07411e
19 changed files with 863 additions and 10 deletions

View file

@ -25,7 +25,9 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
var jwtOptions = Microsoft.Extensions.Options.Options.Create(JwtOptions);
var sessionStamps = new SessionStampService(userData.Object, new InMemorySessionStampCache(TimeProvider.System), jwtOptions);
return new AuthenticationService(manager, jwtOptions, userData.Object, forget.Object, email.Object, sessionStamps);
}
private static JwtOptions JwtOptions => new()

View file

@ -132,7 +132,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
Mock.Of<IEmailSender>(),
Mock.Of<ISessionStampService>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
@ -208,7 +209,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
Mock.Of<IEmailSender>(),
Mock.Of<ISessionStampService>());
public async ValueTask DisposeAsync()
{

View file

@ -72,4 +72,19 @@ public class ServiceRegistrationTests
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
}
[Fact]
public void SessionStampCache_IsOneInstanceForTheWholeProcess()
{
// A scoped cache would never be hit, and a stamp change on one request would
// never evict the value another request cached.
using var provider = BuildConventionServices().BuildServiceProvider();
using var first = provider.CreateScope();
using var second = provider.CreateScope();
var cache = first.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>();
cache.Should().BeOfType<InMemorySessionStampCache>();
second.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>().Should().BeSameAs(cache);
}
}

View file

@ -0,0 +1,138 @@
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);
}
}

View file

@ -29,7 +29,8 @@ public sealed class TeamMemberServiceTests
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
@ -410,7 +411,8 @@ public sealed class TeamMemberServiceTests
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
Mock.Of<ITeamMemberInviteService>(),
Mock.Of<ISessionStampService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()

View file

@ -34,7 +34,8 @@ public class UserServiceTests
manager,
userData.Object,
(accounts ?? new Mock<IAccountDataService>()).Object,
email.Object);
email.Object,
Mock.Of<ISessionStampService>());
}
[Fact]

View file

@ -1,6 +1,9 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
@ -33,9 +36,32 @@ namespace Api.SeaHavenIndustries.Infrastructure
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = RejectEndedSessionAsync
};
});
return services;
}
/// <summary>
/// Refuses a correctly signed token whose session stamp no longer matches the
/// account: the password was reset or changed, or the account was deactivated or
/// deleted, after the token was issued. A token without a stamp is refused too.
/// </summary>
private static async Task RejectEndedSessionAsync(TokenValidatedContext context)
{
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var claimValue = context.Principal?.FindFirstValue(SeaHavenClaimTypes.SessionStamp);
var sessions = context.HttpContext.RequestServices.GetRequiredService<ISessionStampService>();
if (string.IsNullOrEmpty(userId)
|| !await sessions.IsCurrentAsync(userId, claimValue, context.HttpContext.RequestAborted))
{
// The handler logs this text; it names no account, token or stamp.
context.Fail("The session has ended.");
}
}
}
}

View file

@ -189,6 +189,15 @@ namespace SeaHaven.DataServices.Implementation
}
}
public async Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken)
{
return await _context.Users
.AsNoTracking()
.Where(user => user.Id == userId && user.IsDeleted != true)
.Select(user => user.SecurityStamp)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{

View file

@ -20,6 +20,9 @@ namespace SeaHaven.DataServices.Interfaces
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
/// <summary>The security stamp of an account that exists and is not deleted; otherwise null.</summary>
Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
}
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Reflection;
@ -15,6 +16,7 @@ namespace SeaHaven.Services.DependencyInjection
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<ISessionStampCache, InMemorySessionStampCache>();
services.Configure<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));

View file

@ -11,6 +11,12 @@ namespace SeaHaven.Services.Helpers
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
public const string OrgScopeAll = "all";
/// <summary>
/// A keyed hash of the account's security stamp at sign-in. Never the stamp
/// itself: the token is readable by whoever holds it.
/// </summary>
public const string SessionStamp = "session_stamp";
}
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>

View file

@ -21,18 +21,21 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IForgetPasswordDataService _forgetPasswordDataService;
private readonly IEmailSender _emailSender;
private readonly ISessionStampService _sessionStamps;
public AuthenticationService(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender)
IEmailSender emailSender,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
_userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender;
_sessionStamps = sessionStamps;
}
public async Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken)
@ -49,12 +52,22 @@ namespace SeaHaven.Services.Implementation
ArgumentNullException.ThrowIfNull(user);
cancellationToken.ThrowIfCancellationRequested();
// Every request checks the token's stamp against the account's, so an account
// without one would get a token that never works.
if (string.IsNullOrEmpty(user.SecurityStamp))
{
var stamped = await _userManager.UpdateSecurityStampAsync(user);
if (!stamped.Succeeded)
throw new InvalidOperationException("The account's security stamp could not be set.");
}
var userRoles = await _userManager.GetRolesAsync(user);
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.UserName ?? ""),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(SeaHavenClaimTypes.SessionStamp, _sessionStamps.ClaimValueFor(user.SecurityStamp!))
};
foreach (var userRole in userRoles)
{
@ -98,9 +111,13 @@ namespace SeaHaven.Services.Implementation
if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? ""))
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
// A changed password rotates the security stamp, which ends every earlier session.
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
if (result.Succeeded)
{
_sessionStamps.Forget(user.Id);
return ChangePasswordResult(ChangePasswordStatus.Succeeded);
}
return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result)
? ChangePasswordStatus.PasswordRejected
@ -165,10 +182,12 @@ namespace SeaHaven.Services.Implementation
return false;
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
// A reset rotates the security stamp, which ends every earlier session.
var result = await _userManager.ResetPasswordAsync(user, token, password);
if (!result.Succeeded)
return false;
_sessionStamps.Forget(user.Id);
await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken);
return true;
}

View file

@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SessionStampService : ISessionStampService
{
private readonly IUserDataService _userDataService;
private readonly ISessionStampCache _cache;
private readonly byte[] _key;
public SessionStampService(
IUserDataService userDataService,
ISessionStampCache cache,
IOptions<JwtOptions> jwtOptions)
{
_userDataService = userDataService;
_cache = cache;
_key = HKDF.DeriveKey(
HashAlgorithmName.SHA256,
Encoding.UTF8.GetBytes(jwtOptions.Value.Secret),
32,
info: Encoding.UTF8.GetBytes("session-stamp-v1"));
}
public string ClaimValueFor(string securityStamp)
{
ArgumentException.ThrowIfNullOrEmpty(securityStamp);
return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp)));
}
public async Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue))
return false;
if (!_cache.TryGet(userId, out var expected))
{
var generation = _cache.Generation;
var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken);
// A missing, deleted or stampless account has no current value: nothing matches it.
expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp);
_cache.Set(userId, expected, generation);
}
return expected != null && CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(claimValue));
}
public void Forget(string userId) => _cache.Remove(userId);
}
/// <summary>
/// Holds each expected value for <see cref="Lifetime"/>, so a change saved by another
/// instance is enforced here within that time; a change saved by this instance is
/// enforced at once through <see cref="Remove"/>.
/// </summary>
public sealed class InMemorySessionStampCache : ISessionStampCache
{
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
private long _generation;
public InMemorySessionStampCache(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public long Generation => Interlocked.Read(ref _generation);
public bool TryGet(string userId, out string? expected)
{
if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
{
expected = entry.Expected;
return true;
}
expected = null;
return false;
}
public void Set(string userId, string? expected, long generation)
{
var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime));
_entries[userId] = entry;
// A removal since the read may have raced it: drop the value rather than keep it.
if (Generation != generation)
_entries.TryRemove(new KeyValuePair<string, Entry>(userId, entry));
}
public void Remove(string userId)
{
Interlocked.Increment(ref _generation);
_entries.TryRemove(userId, out _);
}
private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt);
}
}

View file

@ -21,6 +21,7 @@ public sealed class TeamMemberService : ITeamMemberService
private readonly IUserDataService _userDataService;
private readonly ITeamPermissionService _permissionService;
private readonly ITeamMemberInviteService _inviteService;
private readonly ISessionStampService _sessionStamps;
public TeamMemberService(
UserManager<ApplicationUser> userManager,
@ -29,8 +30,10 @@ public sealed class TeamMemberService : ITeamMemberService
ITeamPermissionOverrideDataService permissionDataService,
IUserDataService userDataService,
ITeamPermissionService permissionService,
ITeamMemberInviteService inviteService)
ITeamMemberInviteService inviteService,
ISessionStampService sessionStamps)
{
_sessionStamps = sessionStamps;
_userManager = userManager;
_roleManager = roleManager;
_areaDataService = areaDataService;
@ -197,6 +200,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.PhoneNumber = candidate.Phone?.Trim();
user.Color = color;
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
var activeChanged = request.IsActive == (user.IsDeleted == true);
var deactivated = activeChanged && !request.IsActive;
user.IsDeleted = !request.IsActive;
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
@ -229,7 +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)
user.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(user, cancellationToken);
if (activeChanged || emailChanged)
_sessionStamps.Forget(user.Id);
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
if (roleChanged || request.PermissionOverrides is not null)
{

View file

@ -15,17 +15,20 @@ namespace SeaHaven.Services.Implementation
private readonly IUserDataService _userDataService;
private readonly IAccountDataService _accountDataService;
private readonly IEmailSender _emailSender;
private readonly ISessionStampService _sessionStamps;
public UserService(
UserManager<ApplicationUser> userManager,
IUserDataService userDataService,
IAccountDataService accountDataService,
IEmailSender emailSender)
IEmailSender emailSender,
ISessionStampService sessionStamps)
{
_userManager = userManager;
_userDataService = userDataService;
_accountDataService = accountDataService;
_emailSender = emailSender;
_sessionStamps = sessionStamps;
}
public async Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken)
@ -185,6 +188,7 @@ namespace SeaHaven.Services.Implementation
return new AddUserOutcomeDTO { Success = false, Error = UserMutationErrors.Forbidden };
await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken);
_sessionStamps.Forget(data.Id);
return new AddUserOutcomeDTO { Success = true };
}
@ -193,8 +197,11 @@ namespace SeaHaven.Services.Implementation
var exist = await _userDataService.GetForEditAsync(userId, cancellationToken);
if (exist != null && !await _userDataService.IsAccountOwnerAsync(exist.Id, cancellationToken))
{
// A new stamp keeps this account's earlier sessions ended even if it is restored.
exist.IsDeleted = true;
exist.SecurityStamp = Guid.NewGuid().ToString("N");
await _userDataService.UpdateUserAsync(exist, cancellationToken);
_sessionStamps.Forget(exist.Id);
}
}

View file

@ -0,0 +1,20 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// The process-wide cache of expected session stamp values, keyed by user id.
/// Registered as a singleton.
/// </summary>
public interface ISessionStampCache
{
/// <summary>Changes on every removal; a read that spans one is not cached.</summary>
long Generation { get; }
/// <summary>True with the cached value (null: the account matches nothing) while it is fresh.</summary>
bool TryGet(string userId, out string? expected);
/// <summary>Caches a value read at <paramref name="generation"/>, unless a removal has happened since.</summary>
void Set(string userId, string? expected, long generation);
void Remove(string userId);
}
}

View file

@ -0,0 +1,21 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Ties a sign-in token to the account's security stamp, so a password reset or
/// change, a deactivation, or a deletion ends every session issued before it.
/// </summary>
public interface ISessionStampService
{
/// <summary>The value a token carries for <paramref name="securityStamp"/>.</summary>
string ClaimValueFor(string securityStamp);
/// <summary>
/// True when <paramref name="claimValue"/> matches the stamp of an account that
/// exists and is not deleted. Stored stamps are cached briefly.
/// </summary>
Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken);
/// <summary>Drops the cached stamp; call after a change to the stamp or the account is saved.</summary>
void Forget(string userId);
}
}

View file

@ -0,0 +1,191 @@
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);
}
}

View file

@ -0,0 +1,268 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite
/// file database, with Identity and bearer authentication registered exactly as the
/// API host registers them. Email goes to an in-memory sender and every log line is
/// captured.
/// </summary>
internal sealed class SessionTestHost : IAsyncDisposable
{
public static readonly string JwtSecret = new('s', 64);
public const string Issuer = "issuer";
public const string Audience = "audience";
public const string Password = "Harbor-Light-42!";
private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
private readonly WebApplication _app;
private readonly string _databasePath;
private SessionTestHost(WebApplication app, string databasePath, HttpClient client)
{
_app = app;
_databasePath = databasePath;
Client = client;
}
public HttpClient Client { get; }
public CapturingEmailSender Sent { get; private set; } = null!;
public CapturingLoggerProvider Logged { get; private set; } = null!;
public static async Task<SessionTestHost> StartAsync()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["JWT:Secret"] = JwtSecret,
["JWT:ValidIssuer"] = Issuer,
["JWT:ValidAudience"] = Audience
});
var sent = new CapturingEmailSender();
var logged = new CapturingLoggerProvider();
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.Trace);
builder.Logging.AddProvider(logged);
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
new SqliteSessionDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
builder.Services.AddSeaHavenIdentity();
builder.Services.AddSingleton<IEmailSender>(sent);
builder.Services.AddDataServices();
builder.Services.AddBusinessServices(builder.Configuration);
builder.Services.AddSeaHavenJwtAuthentication(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(AuthenticationController).Assembly)
.ConfigureApplicationPartManager(manager =>
{
manager.FeatureProviders.Clear();
manager.FeatureProviders.Add(new SessionControllers());
});
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await using (var scope = app.Services.CreateAsyncScope())
await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().Database.EnsureCreatedAsync();
await app.StartAsync();
var address = app.Services.GetRequiredService<IServer>().Features
.Get<IServerAddressesFeature>()!.Addresses.Single();
var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) });
host.Sent = sent;
host.Logged = logged;
return host;
}
public async Task<ApplicationUser> AddUserAsync(string email, string? role = null)
{
await using var scope = _app.Services.CreateAsyncScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = email,
Email = email,
FirstName = "Sam",
LastName = "Lee",
EmailConfirmed = true,
UniqueName = "Active",
CreatedDate = DateTime.UtcNow
};
var created = await users.CreateAsync(user, Password);
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description)));
if (role != null)
{
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roles.RoleExistsAsync(role))
await roles.CreateAsync(new IdentityRole(role));
await users.AddToRoleAsync(user, role);
}
return user;
}
public async Task<string> SignInAsync(string email, string password = Password)
{
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return payload.RootElement.GetProperty("token").GetString()!;
}
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token, object? json = null)
{
var request = new HttpRequestMessage(method, path);
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (json != null)
request.Content = JsonContent.Create(json);
return Client.SendAsync(request);
}
/// <summary>An authorized read that only succeeds for a signed-in, accepted session.</summary>
public Task<HttpResponseMessage> ProfileAsync(string? token) =>
SendAsync(HttpMethod.Get, "api/User/UserProfile", token);
/// <summary>Runs Forgot Password end to end: request a code, read it from the email, reset.</summary>
public async Task ResetPasswordAsync(string email, string newPassword)
{
var before = Sent.Messages.Count;
using (var requested = await SendAsync(
HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email }))
Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode);
var deadline = DateTime.UtcNow.AddSeconds(10);
SentEmail? message;
while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null)
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("No password reset email was sent.");
await Task.Delay(10);
}
var code = ResetCode.Match(message.Body).Groups[1].Value;
using var reset = await SendAsync(
HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword });
Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode);
}
public async Task<string?> StoredSecurityStampAsync(string userId)
{
await using var scope = _app.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
.Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync();
}
/// <summary>
/// Re-signs <paramref name="token"/> with the host's real signing key after letting
/// <paramref name="edit"/> change its claims, so only the claims differ from a token
/// the API issued.
/// </summary>
public static string Resign(string token, Action<List<System.Security.Claims.Claim>> edit)
{
var original = new JwtSecurityTokenHandler().ReadJwtToken(token);
var claims = original.Claims
.Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat))
.ToList();
edit(claims);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
var resigned = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: original.ValidTo,
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return new JwtSecurityTokenHandler().WriteToken(resigned);
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
SqliteConnection.ClearAllPools();
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
{
if (File.Exists(path))
File.Delete(path);
}
}
private sealed class SessionControllers : ControllerFeatureProvider
{
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
typeInfo.AsType() == typeof(AuthenticationController)
|| typeInfo.AsType() == typeof(UserController)
|| typeInfo.AsType() == typeof(TeamMemberController);
}
private sealed class SqliteSessionDbContext : ApplicationDbContext
{
public SqliteSessionDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
{
// SQL Server filter syntax does not carry over; a filtered unique index
// without its filter would wrongly reject a second user.
if (index.GetFilter() is not null)
{
index.SetFilter(null);
index.IsUnique = false;
}
}
foreach (var property in builder.Model.GetEntityTypes()
.SelectMany(entity => entity.GetProperties())
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
{
property.ValueGenerated = ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
}