From e993e1b5fcfd53deb5cafe1086304640c5a80640 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 18:54:10 -0300 Subject: [PATCH 1/4] refactor(auth): compose bearer authentication through one registration --- .../JwtAuthenticationRegistration.cs | 41 +++++++++++++++++++ Api.SeaHavenIndustries/Program.cs | 25 +---------- 2 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs diff --git a/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs new file mode 100644 index 0000000..a69bbf1 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs @@ -0,0 +1,41 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public static class JwtAuthenticationRegistration + { + /// + /// Registers bearer-token authentication as the default scheme. Program.cs and the + /// behavior tests both compose authentication through this method so the token + /// rules under test are the rules that run. + /// + public static IServiceCollection AddSeaHavenJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidAudience = configuration["JWT:ValidAudience"], + ValidIssuer = configuration["JWT:ValidIssuer"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + configuration["JWT:Secret"] + ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) + }; + }); + + return services; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 0151543..99cfe30 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -5,15 +5,12 @@ using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Observability; using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Sentry.AspNetCore; using Sentry.Extensibility; -using System.Text; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.Services.DependencyInjection; using SeaHaven.Services.Implementation; @@ -143,27 +140,7 @@ builder.Services.AddOptions -{ - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; -}) -.AddJwtBearer(options => -{ - options.SaveToken = true; - options.RequireHttpsMetadata = false; - options.TokenValidationParameters = new TokenValidationParameters() - { - ValidateIssuer = true, - ValidateAudience = true, - ValidAudience = configuration["JWT:ValidAudience"], - ValidIssuer = configuration["JWT:ValidIssuer"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( - configuration["JWT:Secret"] - ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) - }; -}); +builder.Services.AddSeaHavenJwtAuthentication(configuration); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { From b7be07411ea25aa3fb0c19ec1e4fa47b17c813e9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:08:33 -0300 Subject: [PATCH 2/4] 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. --- .../AuthenticationServiceTests.cs | 4 +- .../PasswordPolicyTests.cs | 6 +- .../ServiceRegistrationTests.cs | 15 + .../SessionStampServiceTests.cs | 138 +++++++++ .../TeamMemberServiceTests.cs | 6 +- .../UserServiceTests.cs | 3 +- .../JwtAuthenticationRegistration.cs | 26 ++ .../Implementation/UserDataService.cs | 9 + .../Interfaces/IUserDataService.cs | 3 + .../DependencyInjection/ServicesModule.cs | 2 + .../Helpers/SeaHavenClaimTypes.cs | 6 + .../Implementation/AuthenticationService.cs | 23 +- .../Implementation/SessionStampService.cs | 109 +++++++ .../Implementation/TeamMemberService.cs | 14 +- .../Implementation/UserService.cs | 9 +- .../Interfaces/ISessionStampCache.cs | 20 ++ .../Interfaces/ISessionStampService.cs | 21 ++ .../SessionRevocationTests.cs | 191 +++++++++++++ SeaHavenIndustries.Tests/SessionTestHost.cs | 268 ++++++++++++++++++ 19 files changed, 863 insertions(+), 10 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs create mode 100644 SeaHaven.Services/Implementation/SessionStampService.cs create mode 100644 SeaHaven.Services/Interfaces/ISessionStampCache.cs create mode 100644 SeaHaven.Services/Interfaces/ISessionStampService.cs create mode 100644 SeaHavenIndustries.Tests/SessionRevocationTests.cs create mode 100644 SeaHavenIndustries.Tests/SessionTestHost.cs diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 7d9aa08..a26fb62 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -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() diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index b33d466..3f5a7a0 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -132,7 +132,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); 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(), (forget ?? new Mock()).Object, - Mock.Of()); + Mock.Of(), + Mock.Of()); public async ValueTask DisposeAsync() { diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs index 84da086..b3e0e3d 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -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(); + + cache.Should().BeOfType(); + second.ServiceProvider.GetRequiredService().Should().BeSameAs(cache); + } } diff --git a/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs b/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs new file mode 100644 index 0000000..1706b5a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs @@ -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 _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())).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()), 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())) + .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(), It.IsAny()), 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); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index 3297fb1..3521716 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -29,7 +29,8 @@ public sealed class TeamMemberServiceTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); var result = await service.CreateAsync( ValidRequest() with { ServiceAreas = Array.Empty() }, @@ -410,7 +411,8 @@ public sealed class TeamMemberServiceTests overrides.Object, userData.Object, permissions.Object, - Mock.Of()); + Mock.Of(), + Mock.Of()); } private static Mock> UserManager() diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs index 916d0ec..62fa837 100644 --- a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -34,7 +34,8 @@ public class UserServiceTests manager, userData.Object, (accounts ?? new Mock()).Object, - email.Object); + email.Object, + Mock.Of()); } [Fact] diff --git a/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs index a69bbf1..976a6b2 100644 --- a/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs +++ b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs @@ -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; } + + /// + /// 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. + /// + 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(); + + 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."); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index dce7af1..37932bf 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -189,6 +189,15 @@ namespace SeaHaven.DataServices.Implementation } } + public async Task 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 GetEmailByIdAsync( string userId, CancellationToken cancellationToken) { diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 3c74dcf..1fd8680 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -20,6 +20,9 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); Task ExecuteTransactionalAsync(Func callback, CancellationToken cancellationToken); Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); + + /// The security stamp of an account that exists and is not deleted; otherwise null. + Task GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken); Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 455a0a8..1ef9e77 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -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(); services.Configure(configuration); services.Configure(configuration.GetSection(JwtOptions.SectionName)); services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); diff --git a/SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs b/SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs index 51437cb..711a21e 100644 --- a/SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs +++ b/SeaHaven.Services/Helpers/SeaHavenClaimTypes.cs @@ -11,6 +11,12 @@ namespace SeaHaven.Services.Helpers /// Signed org-wide elevation (Admin without AccountId). public const string OrgScopeAll = "all"; + + /// + /// A keyed hash of the account's security stamp at sign-in. Never the stamp + /// itself: the token is readable by whoever holds it. + /// + public const string SessionStamp = "session_stamp"; } /// Resolved media tenant scope from signed claims (fail-closed when Missing). diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index a9489a1..543f8a2 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -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 userManager, IOptions 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 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 { 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; } diff --git a/SeaHaven.Services/Implementation/SessionStampService.cs b/SeaHaven.Services/Implementation/SessionStampService.cs new file mode 100644 index 0000000..bd1daab --- /dev/null +++ b/SeaHaven.Services/Implementation/SessionStampService.cs @@ -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) + { + _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 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); + } + + /// + /// Holds each expected value for , so a change saved by another + /// instance is enforced here within that time; a change saved by this instance is + /// enforced at once through . + /// + public sealed class InMemorySessionStampCache : ISessionStampCache + { + public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60); + + private readonly ConcurrentDictionary _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(userId, entry)); + } + + public void Remove(string userId) + { + Interlocked.Increment(ref _generation); + _entries.TryRemove(userId, out _); + } + + private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt); + } +} diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 6d78a7b..958edd9 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -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 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) { diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs index f448a1f..94a1b4c 100644 --- a/SeaHaven.Services/Implementation/UserService.cs +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -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 userManager, IUserDataService userDataService, IAccountDataService accountDataService, - IEmailSender emailSender) + IEmailSender emailSender, + ISessionStampService sessionStamps) { _userManager = userManager; _userDataService = userDataService; _accountDataService = accountDataService; _emailSender = emailSender; + _sessionStamps = sessionStamps; } public async Task> 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); } } diff --git a/SeaHaven.Services/Interfaces/ISessionStampCache.cs b/SeaHaven.Services/Interfaces/ISessionStampCache.cs new file mode 100644 index 0000000..28339a0 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISessionStampCache.cs @@ -0,0 +1,20 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// The process-wide cache of expected session stamp values, keyed by user id. + /// Registered as a singleton. + /// + public interface ISessionStampCache + { + /// Changes on every removal; a read that spans one is not cached. + long Generation { get; } + + /// True with the cached value (null: the account matches nothing) while it is fresh. + bool TryGet(string userId, out string? expected); + + /// Caches a value read at , unless a removal has happened since. + void Set(string userId, string? expected, long generation); + + void Remove(string userId); + } +} diff --git a/SeaHaven.Services/Interfaces/ISessionStampService.cs b/SeaHaven.Services/Interfaces/ISessionStampService.cs new file mode 100644 index 0000000..30dff75 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISessionStampService.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// 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. + /// + public interface ISessionStampService + { + /// The value a token carries for . + string ClaimValueFor(string securityStamp); + + /// + /// True when matches the stamp of an account that + /// exists and is not deleted. Stored stamps are cached briefly. + /// + Task IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken); + + /// Drops the cached stamp; call after a change to the stamp or the account is saved. + void Forget(string userId); + } +} diff --git a/SeaHavenIndustries.Tests/SessionRevocationTests.cs b/SeaHavenIndustries.Tests/SessionRevocationTests.cs new file mode 100644 index 0000000..a2b5d85 --- /dev/null +++ b/SeaHavenIndustries.Tests/SessionRevocationTests.cs @@ -0,0 +1,191 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; + +namespace SeaHavenIndustries.Tests; + +/// +/// 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. +/// +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(), + 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); + } +} diff --git a/SeaHavenIndustries.Tests/SessionTestHost.cs b/SeaHavenIndustries.Tests/SessionTestHost.cs new file mode 100644 index 0000000..31bcf79 --- /dev/null +++ b/SeaHavenIndustries.Tests/SessionTestHost.cs @@ -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; + +/// +/// 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. +/// +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 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 + { + ["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(options => options.UseSqlite(connectionString)); + builder.Services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteSessionDbContext(provider.GetRequiredService>()))); + builder.Services.AddSeaHavenIdentity(); + builder.Services.AddSingleton(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().Database.EnsureCreatedAsync(); + + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features + .Get()!.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 AddUserAsync(string email, string? role = null) + { + await using var scope = _app.Services.CreateAsyncScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + 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>(); + if (!await roles.RoleExistsAsync(role)) + await roles.CreateAsync(new IdentityRole(role)); + await users.AddToRoleAsync(user, role); + } + + return user; + } + + public async Task 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 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); + } + + /// An authorized read that only succeeds for a signed-in, accepted session. + public Task ProfileAsync(string? token) => + SendAsync(HttpMethod.Get, "api/User/UserProfile", token); + + /// Runs Forgot Password end to end: request a code, read it from the email, reset. + 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 StoredSecurityStampAsync(string userId) + { + await using var scope = _app.Services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService() + .Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync(); + } + + /// + /// Re-signs with the host's real signing key after letting + /// change its claims, so only the claims differ from a token + /// the API issued. + /// + public static string Resign(string token, Action> 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 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; + } + } + } +} From cc4695025479d39ab11a9581cbc6c602b09a9658 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:10:46 -0300 Subject: [PATCH 3/4] test(auth): format the password change request --- SeaHavenIndustries.Tests/SessionRevocationTests.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/SeaHavenIndustries.Tests/SessionRevocationTests.cs b/SeaHavenIndustries.Tests/SessionRevocationTests.cs index a2b5d85..1cd9bd7 100644 --- a/SeaHavenIndustries.Tests/SessionRevocationTests.cs +++ b/SeaHavenIndustries.Tests/SessionRevocationTests.cs @@ -39,12 +39,13 @@ public sealed class SessionRevocationTests 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 - })) + 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); From 498f49a2d88c9123c0d294bd1b0bd83672e3d1f8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 19:26:25 -0300 Subject: [PATCH 4/4] 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. --- .../UserServiceTests.cs | 43 ++++++++++- .../Implementation/TeamMemberService.cs | 9 +-- .../Implementation/UserService.cs | 22 +++++- .../SessionRevocationTests.cs | 71 ++++++++++++++++++- SeaHavenIndustries.Tests/SessionTestHost.cs | 8 +++ 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs index 62fa837..3ef307d 100644 --- a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -26,7 +26,8 @@ public class UserServiceTests Mock userData, Mock email, out Mock> store, - Mock? accounts = null) + Mock? accounts = null, + Mock? sessions = null) { var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); store = s; @@ -35,7 +36,7 @@ public class UserServiceTests userData.Object, (accounts ?? new Mock()).Object, email.Object, - Mock.Of()); + (sessions ?? new Mock()).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(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(existing); + var accounts = new Mock(); + accounts.Setup(a => a.ExistsAsync(It.IsAny())).ReturnsAsync(true); + var sessions = new Mock(); + var service = NewService(userData, new Mock(), out var store, accounts, sessions); + store.Setup(s => s.GetRolesAsync(existing, It.IsAny())) + .ReturnsAsync(new List { "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()), Times.Never); + } + } } diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 958edd9..6ba22be 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -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) diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs index 94a1b4c..6e4644a 100644 --- a/SeaHaven.Services/Implementation/UserService.cs +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -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 }; } diff --git a/SeaHavenIndustries.Tests/SessionRevocationTests.cs b/SeaHavenIndustries.Tests/SessionRevocationTests.cs index 1cd9bd7..ec25227 100644 --- a/SeaHavenIndustries.Tests/SessionRevocationTests.cs +++ b/SeaHavenIndustries.Tests/SessionRevocationTests.cs @@ -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", diff --git a/SeaHavenIndustries.Tests/SessionTestHost.cs b/SeaHavenIndustries.Tests/SessionTestHost.cs index 31bcf79..0dca587 100644 --- a/SeaHavenIndustries.Tests/SessionTestHost.cs +++ b/SeaHavenIndustries.Tests/SessionTestHost.cs @@ -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>(); + if (!await roles.RoleExistsAsync(role)) + await roles.CreateAsync(new IdentityRole(role)); + } + public async Task SignInAsync(string email, string password = Password) { using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });