diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index 045acea..9da31d9 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -26,7 +26,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, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System); + 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, new InMemoryPasswordResetThrottle(TimeProvider.System), TimeProvider.System, sessionStamps); } private static JwtOptions JwtOptions => new() diff --git a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs index 6e3a85e..117e3a7 100644 --- a/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs +++ b/Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs @@ -135,7 +135,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable Mock.Of(), Mock.Of(), new InMemoryPasswordResetThrottle(TimeProvider.System), - TimeProvider.System); + TimeProvider.System, + Mock.Of()); var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None); @@ -223,7 +224,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable (forget ?? new Mock()).Object, Mock.Of(), new InMemoryPasswordResetThrottle(TimeProvider.System), - TimeProvider.System); + TimeProvider.System, + 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..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; @@ -34,7 +35,8 @@ public class UserServiceTests manager, userData.Object, (accounts ?? new Mock()).Object, - email.Object); + email.Object, + (sessions ?? new Mock()).Object); } [Fact] @@ -429,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/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs new file mode 100644 index 0000000..976a6b2 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/JwtAuthenticationRegistration.cs @@ -0,0 +1,67 @@ +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 +{ + 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"))) + }; + 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/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index cef8fb7..d7c3d30 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; @@ -145,27 +142,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 => { 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 d64c04f..1fc488a 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 1b982d0..60448cc 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -22,6 +22,7 @@ namespace SeaHaven.Services.Implementation private readonly IPasswordResetEmailQueue _resetEmails; private readonly IPasswordResetThrottle _resetThrottle; private readonly TimeProvider _timeProvider; + private readonly ISessionStampService _sessionStamps; private byte[]? _resetCodeKey; public static readonly TimeSpan ResetCodeLifetime = TimeSpan.FromMinutes(15); @@ -37,7 +38,8 @@ namespace SeaHaven.Services.Implementation IForgetPasswordDataService forgetPasswordDataService, IPasswordResetEmailQueue resetEmails, IPasswordResetThrottle resetThrottle, - TimeProvider timeProvider) + TimeProvider timeProvider, + ISessionStampService sessionStamps) { _userManager = userManager; _jwtOptions = jwtOptions.Value; @@ -46,6 +48,7 @@ namespace SeaHaven.Services.Implementation _resetEmails = resetEmails; _resetThrottle = resetThrottle; _timeProvider = timeProvider; + _sessionStamps = sessionStamps; } private byte[] ResetCodeKey => _resetCodeKey ??= PasswordResetCodeSecrets.DeriveKey(_jwtOptions.Secret); @@ -64,12 +67,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) { @@ -113,9 +126,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 @@ -230,6 +247,7 @@ namespace SeaHaven.Services.Implementation } 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) { @@ -239,6 +257,7 @@ namespace SeaHaven.Services.Implementation return false; } + _sessionStamps.Forget(user.Id); await _forgetPasswordDataService.RemoveByEmailAsync(pending.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..6ba22be 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,15 @@ public sealed class TeamMemberService : ITeamMemberService return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role."); } + // 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 || 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 f448a1f..6e4644a 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) @@ -103,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) { @@ -114,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 }; } } @@ -147,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; @@ -157,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 }; } @@ -185,6 +208,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 +217,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..ec25227 --- /dev/null +++ b/SeaHavenIndustries.Tests/SessionRevocationTests.cs @@ -0,0 +1,259 @@ +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); + + var change = new + { + currentpassword = SessionTestHost.Password, + newpassword = NewPassword, + confirmpassword = NewPassword + }; + using (var changed = await host.SendAsync(HttpMethod.Post, "api/Authentication/ChangePassword", before, change)) + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + + await AssertRefusedAsync(host, before); + await AssertRefusedAsync(host, other); + var after = await host.SignInAsync("sam@example.com", NewPassword); + await AssertAcceptedAsync(host, after); + } + + [Fact] + public async Task A_deactivated_member_is_refused_and_the_old_token_stays_refused_after_reactivation() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("admin@example.com", "Admin"); + var member = await host.AddUserAsync("sam@example.com", "Scheduler"); + var admin = await host.SignInAsync("admin@example.com"); + var before = await host.SignInAsync("sam@example.com"); + await AssertAcceptedAsync(host, before); + + await UpdateMemberAsync(host, admin, member.Id, isActive: false); + await AssertRefusedAsync(host, before); + + await UpdateMemberAsync(host, admin, member.Id, isActive: true); + await AssertRefusedAsync(host, before); + var after = await host.SignInAsync("sam@example.com"); + await AssertAcceptedAsync(host, after); + } + + [Fact] + public async Task A_token_for_an_account_its_owner_deleted_is_refused() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("sam@example.com"); + var before = await host.SignInAsync("sam@example.com"); + + using (var deleted = await host.SendAsync(HttpMethod.Post, "api/User/DeleteCurrentUser", before)) + Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); + + await AssertRefusedAsync(host, before); + } + + [Fact] + public async Task A_token_for_an_account_an_admin_deleted_is_refused() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("admin@example.com", "Admin"); + var member = await host.AddUserAsync("sam@example.com"); + var admin = await host.SignInAsync("admin@example.com"); + var before = await host.SignInAsync("sam@example.com"); + await AssertAcceptedAsync(host, before); + + using (var deleted = await host.SendAsync(HttpMethod.Delete, "api/User/DeleteUser", admin, new { id = member.Id })) + Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); + + await AssertRefusedAsync(host, before); + } + + [Fact] + public async Task A_correctly_signed_token_with_a_forged_session_stamp_is_refused() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("sam@example.com"); + var issued = await host.SignInAsync("sam@example.com"); + await AssertAcceptedAsync(host, issued); + + var forged = SessionTestHost.Resign(issued, claims => + { + claims.RemoveAll(claim => claim.Type == SessionStampClaim); + claims.Add(new Claim(SessionStampClaim, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + }); + var resignedUnchanged = SessionTestHost.Resign(issued, _ => { }); + + await AssertAcceptedAsync(host, resignedUnchanged); + await AssertRefusedAsync(host, forged); + } + + [Fact] + public async Task A_correctly_signed_token_without_a_session_stamp_is_refused() + { + // The shape of every token issued before this check shipped. + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("sam@example.com"); + var issued = await host.SignInAsync("sam@example.com"); + + var stampless = SessionTestHost.Resign(issued, claims => claims.RemoveAll(claim => claim.Type == SessionStampClaim)); + + await AssertRefusedAsync(host, stampless); + } + + [Fact] + public async Task A_refused_token_gets_the_same_401_as_no_token_and_nothing_sensitive_is_logged() + { + await using var host = await SessionTestHost.StartAsync(); + var user = await host.AddUserAsync("sam@example.com"); + var before = await host.SignInAsync("sam@example.com"); + var stampClaim = new JwtSecurityTokenHandler().ReadJwtToken(before).Claims + .SingleOrDefault(claim => claim.Type == SessionStampClaim)?.Value; + var oldStamp = await host.StoredSecurityStampAsync(user.Id); + + await host.ResetPasswordAsync("sam@example.com", NewPassword); + + using var refused = await host.ProfileAsync(before); + using var anonymous = await host.ProfileAsync(null); + Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + Assert.Equal(await anonymous.Content.ReadAsStringAsync(), await refused.Content.ReadAsStringAsync()); + Assert.Empty(await refused.Content.ReadAsStringAsync()); + + var newStamp = await host.StoredSecurityStampAsync(user.Id); + var secrets = new[] { before, oldStamp, newStamp, stampClaim, "sam@example.com" } + .Where(secret => !string.IsNullOrEmpty(secret)) + .ToList(); + var leaks = host.Logged.Entries + .Where(entry => secrets.Any(secret => entry.Contains(secret!, StringComparison.OrdinalIgnoreCase))) + .ToList(); + Assert.Empty(leaks); + } + + [Fact] + public async Task A_member_demoted_on_the_team_page_is_refused_and_signs_in_again_with_the_new_role() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("admin@example.com", "Admin"); + var member = await host.AddUserAsync("sam@example.com", "Admin"); + await host.EnsureRoleAsync("Scheduler"); + var admin = await host.SignInAsync("admin@example.com"); + var before = await host.SignInAsync("sam@example.com"); + Assert.Equal(new[] { "Admin" }, RolesIn(before)); + await AssertAcceptedAsync(host, before); + + await UpdateMemberAsync(host, admin, member.Id, isActive: true, role: "Scheduler"); + + await AssertRefusedAsync(host, before); + var after = await host.SignInAsync("sam@example.com"); + Assert.Equal(new[] { "Scheduler" }, RolesIn(after)); + await AssertAcceptedAsync(host, after); + } + + [Fact] + public async Task A_user_whose_role_an_admin_edits_is_refused_and_signs_in_again_with_the_new_role() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("admin@example.com", "Admin"); + var member = await host.AddUserAsync("sam@example.com", "Admin"); + await host.EnsureRoleAsync("Dispatcher"); + var admin = await host.SignInAsync("admin@example.com"); + var before = await host.SignInAsync("sam@example.com"); + await AssertAcceptedAsync(host, before); + + var edit = new + { + id = member.Id, + name = "Sam", + email = "sam@example.com", + role = "Dispatcher" + }; + using (var edited = await host.SendAsync(HttpMethod.Put, "api/User/EditUser", admin, edit)) + Assert.Equal(HttpStatusCode.OK, edited.StatusCode); + + await AssertRefusedAsync(host, before); + var after = await host.SignInAsync("sam@example.com"); + Assert.Equal(new[] { "Dispatcher" }, RolesIn(after)); + await AssertAcceptedAsync(host, after); + } + + [Fact] + public async Task Editing_a_member_without_changing_role_status_or_email_keeps_their_session() + { + await using var host = await SessionTestHost.StartAsync(); + await host.AddUserAsync("admin@example.com", "Admin"); + var member = await host.AddUserAsync("sam@example.com", "Scheduler"); + var admin = await host.SignInAsync("admin@example.com"); + var before = await host.SignInAsync("sam@example.com"); + + await UpdateMemberAsync(host, admin, member.Id, isActive: true); + + await AssertAcceptedAsync(host, before); + } + + private static string[] RolesIn(string token) => + new JwtSecurityTokenHandler().ReadJwtToken(token).Claims + .Where(claim => claim.Type == ClaimTypes.Role) + .Select(claim => claim.Value) + .ToArray(); + + private static async Task UpdateMemberAsync(SessionTestHost host, string adminToken, string userId, bool isActive, string role = "Scheduler") + { + using var response = await host.SendAsync(HttpMethod.Put, $"api/team-members/{userId}", adminToken, new + { + name = "Sam Lee", + role, + color = "#0D9488", + email = "sam@example.com", + phone = "555-0100", + serviceAreas = Array.Empty(), + 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..a78bef3 --- /dev/null +++ b/SeaHavenIndustries.Tests/SessionTestHost.cs @@ -0,0 +1,280 @@ +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.HostedServices; +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); + // Forgot Password queues its email; the API host registers the same delivery. + builder.Services.AddSingleton(Sentry.Extensibility.HubAdapter.Instance); + builder.Services.AddPasswordResetEmailDelivery(); + 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 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 }); + 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; + } + } + } +}