mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
Merge remote-tracking branch 'origin/main' into fix/ab/sh-403-reset-hardening-2
# Conflicts: # Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs # Api.SeaHavenIndustries.Tests/PasswordPolicyTests.cs # SeaHaven.Services/Implementation/AuthenticationService.cs
This commit is contained in:
commit
c985e9423d
20 changed files with 1047 additions and 36 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -135,7 +135,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
Mock.Of<IForgetPasswordDataService>(),
|
||||
Mock.Of<IPasswordResetEmailQueue>(),
|
||||
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
|
||||
|
||||
|
|
@ -223,7 +224,8 @@ public sealed class PasswordPolicyTests : IAsyncDisposable
|
|||
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
|
||||
Mock.Of<IPasswordResetEmailQueue>(),
|
||||
new InMemoryPasswordResetThrottle(TimeProvider.System),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -72,4 +72,19 @@ public class ServiceRegistrationTests
|
|||
|
||||
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionStampCache_IsOneInstanceForTheWholeProcess()
|
||||
{
|
||||
// A scoped cache would never be hit, and a stamp change on one request would
|
||||
// never evict the value another request cached.
|
||||
using var provider = BuildConventionServices().BuildServiceProvider();
|
||||
using var first = provider.CreateScope();
|
||||
using var second = provider.CreateScope();
|
||||
|
||||
var cache = first.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>();
|
||||
|
||||
cache.Should().BeOfType<InMemorySessionStampCache>();
|
||||
second.ServiceProvider.GetRequiredService<SeaHaven.Services.Interfaces.ISessionStampCache>().Should().BeSameAs(cache);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal file
138
Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using FluentAssertions;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using Xunit;
|
||||
|
||||
namespace Api.SeaHavenIndustries.Tests;
|
||||
|
||||
public class SessionStampServiceTests
|
||||
{
|
||||
private const string UserId = "user-1";
|
||||
|
||||
private readonly Mock<IUserDataService> _users = new();
|
||||
private readonly SteppedTimeProvider _time = new();
|
||||
private readonly InMemorySessionStampCache _cache;
|
||||
|
||||
public SessionStampServiceTests()
|
||||
{
|
||||
_cache = new InMemorySessionStampCache(_time);
|
||||
}
|
||||
|
||||
private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") =>
|
||||
new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret }));
|
||||
|
||||
private void StoredStamp(string? stamp) =>
|
||||
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>())).ReturnsAsync(stamp);
|
||||
|
||||
[Fact]
|
||||
public void The_token_carries_a_keyed_hash_never_the_stamp_itself()
|
||||
{
|
||||
var value = NewService().ClaimValueFor("STAMP-ONE");
|
||||
|
||||
value.Should().NotContain("STAMP-ONE");
|
||||
value.Should().Be(NewService().ClaimValueFor("STAMP-ONE"));
|
||||
value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO"));
|
||||
value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_matching_stamp_is_read_once_per_cache_lifetime()
|
||||
{
|
||||
StoredStamp("STAMP-ONE");
|
||||
var service = NewService();
|
||||
var issued = service.ClaimValueFor("STAMP-ONE");
|
||||
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
(await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
_users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires()
|
||||
{
|
||||
StoredStamp("STAMP-ONE");
|
||||
var service = NewService();
|
||||
var issued = service.ClaimValueFor("STAMP-ONE");
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
StoredStamp("STAMP-TWO");
|
||||
_time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1));
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
_time.Advance(TimeSpan.FromSeconds(1));
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stamp_changed_by_this_instance_is_enforced_at_once()
|
||||
{
|
||||
StoredStamp("STAMP-ONE");
|
||||
var service = NewService();
|
||||
var issued = service.ClaimValueFor("STAMP-ONE");
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
|
||||
StoredStamp("STAMP-TWO");
|
||||
service.Forget(UserId);
|
||||
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_races_a_change_is_not_cached()
|
||||
{
|
||||
var service = NewService();
|
||||
var issued = service.ClaimValueFor("STAMP-ONE");
|
||||
var reads = 0;
|
||||
_users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
// The first read returns the old stamp while this instance saves a new one.
|
||||
if (reads++ == 0)
|
||||
{
|
||||
service.Forget(UserId);
|
||||
return "STAMP-ONE";
|
||||
}
|
||||
|
||||
return "STAMP-TWO";
|
||||
});
|
||||
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue();
|
||||
(await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored)
|
||||
{
|
||||
StoredStamp(stored);
|
||||
var service = NewService();
|
||||
|
||||
(await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse();
|
||||
(await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue)
|
||||
{
|
||||
StoredStamp("STAMP-ONE");
|
||||
|
||||
(await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse();
|
||||
|
||||
_users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
private sealed class SteppedTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,8 @@ public sealed class TeamMemberServiceTests
|
|||
Mock.Of<ITeamPermissionOverrideDataService>(),
|
||||
Mock.Of<IUserDataService>(),
|
||||
Mock.Of<ITeamPermissionService>(),
|
||||
Mock.Of<ITeamMemberInviteService>());
|
||||
Mock.Of<ITeamMemberInviteService>(),
|
||||
Mock.Of<ISessionStampService>());
|
||||
|
||||
var result = await service.CreateAsync(
|
||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||
|
|
@ -410,7 +411,8 @@ public sealed class TeamMemberServiceTests
|
|||
overrides.Object,
|
||||
userData.Object,
|
||||
permissions.Object,
|
||||
Mock.Of<ITeamMemberInviteService>());
|
||||
Mock.Of<ITeamMemberInviteService>(),
|
||||
Mock.Of<ISessionStampService>());
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> UserManager()
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ public class UserServiceTests
|
|||
Mock<IUserDataService> userData,
|
||||
Mock<IEmailSender> email,
|
||||
out Mock<IUserRoleStore<ApplicationUser>> store,
|
||||
Mock<IAccountDataService>? accounts = null)
|
||||
Mock<IAccountDataService>? accounts = null,
|
||||
Mock<ISessionStampService>? sessions = null)
|
||||
{
|
||||
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
|
||||
store = s;
|
||||
|
|
@ -34,7 +35,8 @@ public class UserServiceTests
|
|||
manager,
|
||||
userData.Object,
|
||||
(accounts ?? new Mock<IAccountDataService>()).Object,
|
||||
email.Object);
|
||||
email.Object,
|
||||
(sessions ?? new Mock<ISessionStampService>()).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<IUserDataService>();
|
||||
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
||||
var accounts = new Mock<IAccountDataService>();
|
||||
accounts.Setup(a => a.ExistsAsync(It.IsAny<int>())).ReturnsAsync(true);
|
||||
var sessions = new Mock<ISessionStampService>();
|
||||
var service = NewService(userData, new Mock<IEmailSender>(), out var store, accounts, sessions);
|
||||
store.Setup(s => s.GetRolesAsync(existing, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<string> { "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<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a correctly signed token whose session stamp no longer matches the
|
||||
/// account: the password was reset or changed, or the account was deactivated or
|
||||
/// deleted, after the token was issued. A token without a stamp is refused too.
|
||||
/// </summary>
|
||||
private static async Task RejectEndedSessionAsync(TokenValidatedContext context)
|
||||
{
|
||||
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var claimValue = context.Principal?.FindFirstValue(SeaHavenClaimTypes.SessionStamp);
|
||||
var sessions = context.HttpContext.RequestServices.GetRequiredService<ISessionStampService>();
|
||||
|
||||
if (string.IsNullOrEmpty(userId)
|
||||
|| !await sessions.IsCurrentAsync(userId, claimValue, context.HttpContext.RequestAborted))
|
||||
{
|
||||
// The handler logs this text; it names no account, token or stamp.
|
||||
context.Fail("The session has ended.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SeaHaven.Services.Implementation.WorkOrderOpsHealthO
|
|||
health.LegacySunsetDate = legacy.Value.SunsetDate;
|
||||
});
|
||||
|
||||
builder.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")))
|
||||
};
|
||||
});
|
||||
builder.Services.AddSeaHavenJwtAuthentication(configuration);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -189,6 +189,15 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _context.Users
|
||||
.AsNoTracking()
|
||||
.Where(user => user.Id == userId && user.IsDeleted != true)
|
||||
.Select(user => user.SecurityStamp)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string?> GetEmailByIdAsync(
|
||||
string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
|
||||
Task ExecuteTransactionalAsync(Func<CancellationToken, Task> callback, CancellationToken cancellationToken);
|
||||
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The security stamp of an account that exists and is not deleted; otherwise null.</summary>
|
||||
Task<string?> GetActiveSecurityStampAsync(string userId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyDictionary<string, string>> GetDisplayNamesByIdsAsync(IEnumerable<string> ids);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Implementation;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
using System.Reflection;
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ namespace SeaHaven.Services.DependencyInjection
|
|||
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<ISessionStampCache, InMemorySessionStampCache>();
|
||||
services.Configure<FrontendOptions>(configuration);
|
||||
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
||||
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ namespace SeaHaven.Services.Helpers
|
|||
|
||||
/// <summary>Signed org-wide elevation (Admin without AccountId).</summary>
|
||||
public const string OrgScopeAll = "all";
|
||||
|
||||
/// <summary>
|
||||
/// A keyed hash of the account's security stamp at sign-in. Never the stamp
|
||||
/// itself: the token is readable by whoever holds it.
|
||||
/// </summary>
|
||||
public const string SessionStamp = "session_stamp";
|
||||
}
|
||||
|
||||
/// <summary>Resolved media tenant scope from signed claims (fail-closed when Missing).</summary>
|
||||
|
|
|
|||
|
|
@ -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<Claim>
|
||||
{
|
||||
new Claim(ClaimTypes.Name, user.UserName ?? ""),
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(SeaHavenClaimTypes.SessionStamp, _sessionStamps.ClaimValueFor(user.SecurityStamp!))
|
||||
};
|
||||
foreach (var userRole in userRoles)
|
||||
{
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal file
109
SeaHaven.Services/Implementation/SessionStampService.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.Services.Configuration;
|
||||
using SeaHaven.Services.Interfaces;
|
||||
|
||||
namespace SeaHaven.Services.Implementation
|
||||
{
|
||||
public class SessionStampService : ISessionStampService
|
||||
{
|
||||
private readonly IUserDataService _userDataService;
|
||||
private readonly ISessionStampCache _cache;
|
||||
private readonly byte[] _key;
|
||||
|
||||
public SessionStampService(
|
||||
IUserDataService userDataService,
|
||||
ISessionStampCache cache,
|
||||
IOptions<JwtOptions> jwtOptions)
|
||||
{
|
||||
_userDataService = userDataService;
|
||||
_cache = cache;
|
||||
_key = HKDF.DeriveKey(
|
||||
HashAlgorithmName.SHA256,
|
||||
Encoding.UTF8.GetBytes(jwtOptions.Value.Secret),
|
||||
32,
|
||||
info: Encoding.UTF8.GetBytes("session-stamp-v1"));
|
||||
}
|
||||
|
||||
public string ClaimValueFor(string securityStamp)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(securityStamp);
|
||||
return Base64UrlEncoder.Encode(HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(securityStamp)));
|
||||
}
|
||||
|
||||
public async Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(claimValue))
|
||||
return false;
|
||||
|
||||
if (!_cache.TryGet(userId, out var expected))
|
||||
{
|
||||
var generation = _cache.Generation;
|
||||
var stamp = await _userDataService.GetActiveSecurityStampAsync(userId, cancellationToken);
|
||||
// A missing, deleted or stampless account has no current value: nothing matches it.
|
||||
expected = string.IsNullOrEmpty(stamp) ? null : ClaimValueFor(stamp);
|
||||
_cache.Set(userId, expected, generation);
|
||||
}
|
||||
|
||||
return expected != null && CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(claimValue));
|
||||
}
|
||||
|
||||
public void Forget(string userId) => _cache.Remove(userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds each expected value for <see cref="Lifetime"/>, so a change saved by another
|
||||
/// instance is enforced here within that time; a change saved by this instance is
|
||||
/// enforced at once through <see cref="Remove"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemorySessionStampCache : ISessionStampCache
|
||||
{
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private long _generation;
|
||||
|
||||
public InMemorySessionStampCache(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
|
||||
public bool TryGet(string userId, out string? expected)
|
||||
{
|
||||
if (_entries.TryGetValue(userId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
expected = entry.Expected;
|
||||
return true;
|
||||
}
|
||||
|
||||
expected = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Set(string userId, string? expected, long generation)
|
||||
{
|
||||
var entry = new Entry(expected, _timeProvider.GetUtcNow().Add(Lifetime));
|
||||
_entries[userId] = entry;
|
||||
// A removal since the read may have raced it: drop the value rather than keep it.
|
||||
if (Generation != generation)
|
||||
_entries.TryRemove(new KeyValuePair<string, Entry>(userId, entry));
|
||||
}
|
||||
|
||||
public void Remove(string userId)
|
||||
{
|
||||
Interlocked.Increment(ref _generation);
|
||||
_entries.TryRemove(userId, out _);
|
||||
}
|
||||
|
||||
private sealed record Entry(string? Expected, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
private readonly IUserDataService _userDataService;
|
||||
private readonly ITeamPermissionService _permissionService;
|
||||
private readonly ITeamMemberInviteService _inviteService;
|
||||
private readonly ISessionStampService _sessionStamps;
|
||||
|
||||
public TeamMemberService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
|
|
@ -29,8 +30,10 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
ITeamPermissionOverrideDataService permissionDataService,
|
||||
IUserDataService userDataService,
|
||||
ITeamPermissionService permissionService,
|
||||
ITeamMemberInviteService inviteService)
|
||||
ITeamMemberInviteService inviteService,
|
||||
ISessionStampService sessionStamps)
|
||||
{
|
||||
_sessionStamps = sessionStamps;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_areaDataService = areaDataService;
|
||||
|
|
@ -197,6 +200,8 @@ public sealed class TeamMemberService : ITeamMemberService
|
|||
user.PhoneNumber = candidate.Phone?.Trim();
|
||||
user.Color = color;
|
||||
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
|
||||
var activeChanged = request.IsActive == (user.IsDeleted == true);
|
||||
var deactivated = activeChanged && !request.IsActive;
|
||||
user.IsDeleted = !request.IsActive;
|
||||
|
||||
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
|
||||
|
|
@ -229,7 +234,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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,17 +15,20 @@ namespace SeaHaven.Services.Implementation
|
|||
private readonly IUserDataService _userDataService;
|
||||
private readonly IAccountDataService _accountDataService;
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly ISessionStampService _sessionStamps;
|
||||
|
||||
public UserService(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IUserDataService userDataService,
|
||||
IAccountDataService accountDataService,
|
||||
IEmailSender emailSender)
|
||||
IEmailSender emailSender,
|
||||
ISessionStampService sessionStamps)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userDataService = userDataService;
|
||||
_accountDataService = accountDataService;
|
||||
_emailSender = emailSender;
|
||||
_sessionStamps = sessionStamps;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserListRowDTO>> GetUsersAsync(CancellationToken cancellationToken)
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal file
20
SeaHaven.Services/Interfaces/ISessionStampCache.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// The process-wide cache of expected session stamp values, keyed by user id.
|
||||
/// Registered as a singleton.
|
||||
/// </summary>
|
||||
public interface ISessionStampCache
|
||||
{
|
||||
/// <summary>Changes on every removal; a read that spans one is not cached.</summary>
|
||||
long Generation { get; }
|
||||
|
||||
/// <summary>True with the cached value (null: the account matches nothing) while it is fresh.</summary>
|
||||
bool TryGet(string userId, out string? expected);
|
||||
|
||||
/// <summary>Caches a value read at <paramref name="generation"/>, unless a removal has happened since.</summary>
|
||||
void Set(string userId, string? expected, long generation);
|
||||
|
||||
void Remove(string userId);
|
||||
}
|
||||
}
|
||||
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal file
21
SeaHaven.Services/Interfaces/ISessionStampService.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace SeaHaven.Services.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Ties a sign-in token to the account's security stamp, so a password reset or
|
||||
/// change, a deactivation, or a deletion ends every session issued before it.
|
||||
/// </summary>
|
||||
public interface ISessionStampService
|
||||
{
|
||||
/// <summary>The value a token carries for <paramref name="securityStamp"/>.</summary>
|
||||
string ClaimValueFor(string securityStamp);
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="claimValue"/> matches the stamp of an account that
|
||||
/// exists and is not deleted. Stored stamps are cached briefly.
|
||||
/// </summary>
|
||||
Task<bool> IsCurrentAsync(string userId, string? claimValue, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Drops the cached stamp; call after a change to the stamp or the account is saved.</summary>
|
||||
void Forget(string userId);
|
||||
}
|
||||
}
|
||||
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal file
259
SeaHavenIndustries.Tests/SessionRevocationTests.cs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A sign-in token must stop working once the account's password is reset or changed,
|
||||
/// or once the account is deactivated or deleted. Every case goes through the real API
|
||||
/// host: sign in over HTTP, change the account through its endpoint, then call an
|
||||
/// authorized endpoint with the old and the new token.
|
||||
/// </summary>
|
||||
public sealed class SessionRevocationTests
|
||||
{
|
||||
private const string SessionStampClaim = "session_stamp";
|
||||
private const string NewPassword = "Quiet-Tide-77!";
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_issued_before_a_password_reset_is_refused_and_the_new_sign_in_works()
|
||||
{
|
||||
await using var host = await SessionTestHost.StartAsync();
|
||||
await host.AddUserAsync("sam@example.com");
|
||||
var before = await host.SignInAsync("sam@example.com");
|
||||
await AssertAcceptedAsync(host, before);
|
||||
|
||||
await host.ResetPasswordAsync("sam@example.com", NewPassword);
|
||||
|
||||
await AssertRefusedAsync(host, before);
|
||||
var after = await host.SignInAsync("sam@example.com", NewPassword);
|
||||
await AssertAcceptedAsync(host, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_issued_before_a_password_change_is_refused_and_the_new_sign_in_works()
|
||||
{
|
||||
await using var host = await SessionTestHost.StartAsync();
|
||||
await host.AddUserAsync("sam@example.com");
|
||||
var before = await host.SignInAsync("sam@example.com");
|
||||
var other = await host.SignInAsync("sam@example.com");
|
||||
await AssertAcceptedAsync(host, other);
|
||||
|
||||
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<string>(),
|
||||
isActive
|
||||
});
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task AssertAcceptedAsync(SessionTestHost host, string token)
|
||||
{
|
||||
using var response = await host.ProfileAsync(token);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task AssertRefusedAsync(SessionTestHost host, string token)
|
||||
{
|
||||
using var response = await host.ProfileAsync(token);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
280
SeaHavenIndustries.Tests/SessionTestHost.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts the real sign-in, user and team member controllers on Kestrel over a SQLite
|
||||
/// file database, with Identity and bearer authentication registered exactly as the
|
||||
/// API host registers them. Email goes to an in-memory sender and every log line is
|
||||
/// captured.
|
||||
/// </summary>
|
||||
internal sealed class SessionTestHost : IAsyncDisposable
|
||||
{
|
||||
public static readonly string JwtSecret = new('s', 64);
|
||||
public const string Issuer = "issuer";
|
||||
public const string Audience = "audience";
|
||||
public const string Password = "Harbor-Light-42!";
|
||||
|
||||
private static readonly Regex ResetCode = new(@"Reset Code is: (\d{6})", RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly WebApplication _app;
|
||||
private readonly string _databasePath;
|
||||
|
||||
private SessionTestHost(WebApplication app, string databasePath, HttpClient client)
|
||||
{
|
||||
_app = app;
|
||||
_databasePath = databasePath;
|
||||
Client = client;
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
public CapturingEmailSender Sent { get; private set; } = null!;
|
||||
public CapturingLoggerProvider Logged { get; private set; } = null!;
|
||||
|
||||
public static async Task<SessionTestHost> StartAsync()
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
|
||||
var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath, DefaultTimeout = 30 }.ToString();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["JWT:Secret"] = JwtSecret,
|
||||
["JWT:ValidIssuer"] = Issuer,
|
||||
["JWT:ValidAudience"] = Audience
|
||||
});
|
||||
|
||||
var sent = new CapturingEmailSender();
|
||||
var logged = new CapturingLoggerProvider();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.SetMinimumLevel(LogLevel.Trace);
|
||||
builder.Logging.AddProvider(logged);
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
||||
new SqliteSessionDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
||||
builder.Services.AddSeaHavenIdentity();
|
||||
builder.Services.AddSingleton<IEmailSender>(sent);
|
||||
builder.Services.AddDataServices();
|
||||
builder.Services.AddBusinessServices(builder.Configuration);
|
||||
// Forgot Password queues its email; the API host registers the same delivery.
|
||||
builder.Services.AddSingleton<Sentry.IHub>(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<ApplicationDbContext>().Database.EnsureCreatedAsync();
|
||||
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features
|
||||
.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
|
||||
var host = new SessionTestHost(app, databasePath, new HttpClient { BaseAddress = new Uri(address) });
|
||||
host.Sent = sent;
|
||||
host.Logged = logged;
|
||||
return host;
|
||||
}
|
||||
|
||||
public async Task<ApplicationUser> AddUserAsync(string email, string? role = null)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = "Sam",
|
||||
LastName = "Lee",
|
||||
EmailConfirmed = true,
|
||||
UniqueName = "Active",
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
var created = await users.CreateAsync(user, Password);
|
||||
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description)));
|
||||
|
||||
if (role != null)
|
||||
{
|
||||
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
if (!await roles.RoleExistsAsync(role))
|
||||
await roles.CreateAsync(new IdentityRole(role));
|
||||
await users.AddToRoleAsync(user, role);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task EnsureRoleAsync(string role)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||
if (!await roles.RoleExistsAsync(role))
|
||||
await roles.CreateAsync(new IdentityRole(role));
|
||||
}
|
||||
|
||||
public async Task<string> SignInAsync(string email, string password = Password)
|
||||
{
|
||||
using var response = await Client.PostAsJsonAsync("api/Authentication/login", new { username = email, password });
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return payload.RootElement.GetProperty("token").GetString()!;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token, object? json = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (token != null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
if (json != null)
|
||||
request.Content = JsonContent.Create(json);
|
||||
return Client.SendAsync(request);
|
||||
}
|
||||
|
||||
/// <summary>An authorized read that only succeeds for a signed-in, accepted session.</summary>
|
||||
public Task<HttpResponseMessage> ProfileAsync(string? token) =>
|
||||
SendAsync(HttpMethod.Get, "api/User/UserProfile", token);
|
||||
|
||||
/// <summary>Runs Forgot Password end to end: request a code, read it from the email, reset.</summary>
|
||||
public async Task ResetPasswordAsync(string email, string newPassword)
|
||||
{
|
||||
var before = Sent.Messages.Count;
|
||||
using (var requested = await SendAsync(
|
||||
HttpMethod.Post, $"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(email)}", null, new { email }))
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, requested.StatusCode);
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
SentEmail? message;
|
||||
while ((message = Sent.Messages.Skip(before).LastOrDefault(sent => sent.To == email && ResetCode.IsMatch(sent.Body))) == null)
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
throw new TimeoutException("No password reset email was sent.");
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
var code = ResetCode.Match(message.Body).Groups[1].Value;
|
||||
using var reset = await SendAsync(
|
||||
HttpMethod.Post, "api/Authentication/ResetPassword", null, new { email, code, password = newPassword });
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, reset.StatusCode);
|
||||
}
|
||||
|
||||
public async Task<string?> StoredSecurityStampAsync(string userId)
|
||||
{
|
||||
await using var scope = _app.Services.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>()
|
||||
.Users.AsNoTracking().Where(user => user.Id == userId).Select(user => user.SecurityStamp).SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-signs <paramref name="token"/> with the host's real signing key after letting
|
||||
/// <paramref name="edit"/> change its claims, so only the claims differ from a token
|
||||
/// the API issued.
|
||||
/// </summary>
|
||||
public static string Resign(string token, Action<List<System.Security.Claims.Claim>> edit)
|
||||
{
|
||||
var original = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||
var claims = original.Claims
|
||||
.Where(claim => claim.Type is not (JwtRegisteredClaimNames.Exp or JwtRegisteredClaimNames.Iss or JwtRegisteredClaimNames.Aud or JwtRegisteredClaimNames.Nbf or JwtRegisteredClaimNames.Iat))
|
||||
.ToList();
|
||||
edit(claims);
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret));
|
||||
var resigned = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
expires: original.ValidTo,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
return new JwtSecurityTokenHandler().WriteToken(resigned);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _databasePath, _databasePath + "-wal", _databasePath + "-shm", _databasePath + "-journal" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SessionControllers : ControllerFeatureProvider
|
||||
{
|
||||
protected override bool IsController(System.Reflection.TypeInfo typeInfo) =>
|
||||
typeInfo.AsType() == typeof(AuthenticationController)
|
||||
|| typeInfo.AsType() == typeof(UserController)
|
||||
|| typeInfo.AsType() == typeof(TeamMemberController);
|
||||
}
|
||||
|
||||
private sealed class SqliteSessionDbContext : ApplicationDbContext
|
||||
{
|
||||
public SqliteSessionDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
||||
{
|
||||
// SQL Server filter syntax does not carry over; a filtered unique index
|
||||
// without its filter would wrongly reject a second user.
|
||||
if (index.GetFilter() is not null)
|
||||
{
|
||||
index.SetFilter(null);
|
||||
index.IsUnique = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in builder.Model.GetEntityTypes()
|
||||
.SelectMany(entity => entity.GetProperties())
|
||||
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
|
||||
{
|
||||
property.ValueGenerated = ValueGenerated.Never;
|
||||
property.IsConcurrencyToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue