From 206d6898dfff34cfffd631b7e4781abd9e6f6889 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 22:54:05 +0200 Subject: [PATCH] feat(identity): add "sign out everywhere" via security stamp regeneration Adds a user-initiated "Sign out everywhere" action that regenerates the ASP.NET Identity security stamp, signs out the current cookie, and publishes UserSignedOutEverywhereEvent so OpenIddict revokes any outstanding access/refresh tokens. Closes #179. Also fixes a latent bug in AuditConfigCacheInvalidator: Wolverine's default handler discovery only scans public types, so the existing internal handler was never being invoked on SettingChangedEvent. --- .../WebApplicationFactoryAuthExtensions.cs | 21 ++++ .../Pipeline/AuditConfigCacheInvalidator.cs | 2 +- .../OpenIddictWolverineExtension.cs | 16 +++ .../UserSignedOutEverywhereHandler.cs | 19 +++ .../Events/UserSignedOutEverywhereEvent.cs | 11 ++ .../UsersConstants.cs | 1 + .../ApplySecurityStampValidatorOptions.cs | 23 ++++ .../Pages/Account/Login.tsx | 14 ++- .../Pages/Account/LoginEndpoint.cs | 4 +- .../Pages/Account/Manage/ManageIndex.tsx | 54 ++++++++- .../Account/SignOutEverywhereEndpoint.cs | 60 ++++++++++ .../src/SimpleModule.Users/UsersModule.cs | 4 + .../SimpleModule.Users/UsersModuleOptions.cs | 7 ++ .../SignOutEverywhereEndpointTests.cs | 112 ++++++++++++++++++ packages/SimpleModule.Client/src/routes.ts | 1 + .../SimpleModuleWebApplicationFactory.Auth.cs | 14 +++ 16 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs create mode 100644 modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs diff --git a/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs index aebb1abb..74b8c80e 100644 --- a/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs +++ b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs @@ -26,6 +26,27 @@ params Claim[] claims return client; } + /// + /// Same as + /// but lets the caller pass , e.g. to disable + /// auto-redirect when the test asserts on the redirect itself. + /// + public static HttpClient CreateAuthenticatedClient( + this WebApplicationFactory factory, + WebApplicationFactoryClientOptions clientOptions, + params Claim[] claims + ) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(clientOptions); + ArgumentNullException.ThrowIfNull(claims); + + var client = factory.CreateClient(clientOptions); + ApplyClaims(client, claims); + return client; + } + /// /// Convenience overload that adds each entry of /// as a claim before applying any diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs index 0524b3da..b68696d5 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs @@ -4,7 +4,7 @@ namespace SimpleModule.AuditLogs.Pipeline; -internal static class AuditConfigCacheInvalidator +public static class AuditConfigCacheInvalidator { public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache) { diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs new file mode 100644 index 00000000..f25ae3af --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs @@ -0,0 +1,16 @@ +using Wolverine; +using Wolverine.Attributes; + +[assembly: WolverineModule(typeof(SimpleModule.OpenIddict.OpenIddictWolverineExtension))] + +namespace SimpleModule.OpenIddict; + +#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule] +internal sealed class OpenIddictWolverineExtension : IWolverineExtension +#pragma warning restore CA1812 +{ + public void Configure(WolverineOptions options) + { + options.Discovery.IncludeAssembly(typeof(OpenIddictWolverineExtension).Assembly); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs new file mode 100644 index 00000000..12e21695 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs @@ -0,0 +1,19 @@ +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.Users.Contracts.Events; + +namespace SimpleModule.OpenIddict.Services; + +/// +/// When the Users module fires "Sign out everywhere", revoke all OpenIddict access and refresh +/// tokens for that user. Bearer/refresh-token holders bypass the cookie SecurityStampValidator, +/// so they need explicit revocation; Users avoids a hard reference to OpenIddict by going +/// through the event bus. +/// +public static class UserSignedOutEverywhereHandler +{ + public static Task Handle( + UserSignedOutEverywhereEvent @event, + IOpenIddictSessionContracts sessions, + CancellationToken cancellationToken + ) => sessions.RevokeAllSessionsForUserAsync(@event.UserId.Value, cancellationToken); +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs new file mode 100644 index 00000000..f24d1cf2 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Users.Contracts.Events; + +/// +/// Raised after the user's security stamp has been regenerated by the "Sign out everywhere" +/// action. Subscribers may take additional revocation actions — e.g., OpenIddict revokes any +/// outstanding access/refresh tokens, since bearer holders bypass the cookie-side +/// SecurityStampValidator. +/// +public sealed record UserSignedOutEverywhereEvent(UserId UserId) : IEvent; diff --git a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs index 32d40cef..0a1b9736 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs @@ -49,5 +49,6 @@ public static class Routes public const string Disable2fa = "/Manage/Disable2fa"; public const string ResetAuthenticator = "/Manage/ResetAuthenticator"; public const string GenerateRecoveryCodes = "/Manage/GenerateRecoveryCodes"; + public const string SignOutEverywhere = "/Manage/SignOutEverywhere"; } } diff --git a/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs b/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs new file mode 100644 index 00000000..b15c3afd --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Users; + +/// +/// Bridges into ASP.NET Identity's +/// . This controls how quickly a "Sign out everywhere" +/// action propagates to other devices that hold a still-valid cookie. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812:Avoid uninstantiated internal classes", + Justification = "Instantiated by DI" +)] +internal sealed class ApplySecurityStampValidatorOptions(IOptions moduleOptions) + : IPostConfigureOptions +{ + public void PostConfigure(string? name, SecurityStampValidatorOptions options) + { + options.ValidationInterval = moduleOptions.Value.SecurityStampValidationInterval; + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx index 6521e32c..a30d4dec 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx @@ -16,10 +16,17 @@ interface Props { returnUrl: string; showTestAccounts: boolean; passkeyEnabled: boolean; + signedOutEverywhere?: boolean; errors?: { email?: string }; } -export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, errors }: Props) { +export default function Login({ + returnUrl, + showTestAccounts, + passkeyEnabled, + signedOutEverywhere, + errors, +}: Props) { const [passkeyError, setPasskeyError] = useState(null); const [passkeyLoading, setPasskeyLoading] = useState(false); @@ -95,6 +102,11 @@ export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, err + {signedOutEverywhere && ( +
+ Signed out of all devices. Sign in to continue. +
+ )} {errors?.email && (
{errors.email} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs index 34b6b515..7bf30c4c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs @@ -34,7 +34,8 @@ public void Map(IEndpointRouteBuilder app) ISettingsContracts settingsService, ISettingsDefinitionRegistry settingsDefinitions, IOptions passkeyOptions, - [FromQuery] string? returnUrl + [FromQuery] string? returnUrl, + [FromQuery] bool? signedOutEverywhere ) => { await context.SignOutAsync(IdentityConstants.ExternalScheme); @@ -56,6 +57,7 @@ [FromQuery] string? returnUrl passkeyEnabled = !string.IsNullOrEmpty( passkeyOptions.Value.ServerDomain ), + signedOutEverywhere = signedOutEverywhere == true, } ); } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx index 02bdefbf..36ed63bc 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx @@ -1,5 +1,18 @@ import { router } from '@inertiajs/react'; -import { Button, Field, FieldGroup, Input, Label } from '@simplemodule/ui'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldGroup, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; import ManageLayout from '@/components/ManageLayout'; interface Props { @@ -9,12 +22,18 @@ interface Props { } export default function ManageIndex({ username, phoneNumber, statusMessage }: Props) { + const [confirmOpen, setConfirmOpen] = useState(false); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); router.post('/Identity/Account/Manage', formData); } + function confirmSignOutEverywhere() { + router.post('/Identity/Account/Manage/SignOutEverywhere'); + } + return (

Profile

@@ -43,6 +62,39 @@ export default function ManageIndex({ username, phoneNumber, statusMessage }: Pr + +
+ +
+

Security

+

+ Sign out of every device you've ever signed in on, including this one. Useful if you've + lost a device or suspect your account has been compromised. +

+ +
+ + + + + Sign out everywhere? + + This will sign you out of every device, including this one. You'll need to sign in + again on this device. Continue? + + + + + + + +
); } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs new file mode 100644 index 00000000..ae5173c4 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using SimpleModule.Core; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Contracts.Events; +using Wolverine; + +namespace SimpleModule.Users.Pages.Account; + +public partial class SignOutEverywhereEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.SignOutEverywhere; + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "User {UserId} invoked sign-out-everywhere; security stamp regenerated." + )] + private static partial void LogSignedOutEverywhere(ILogger logger, string userId); + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async ( + ClaimsPrincipal principal, + UserManager userManager, + SignInManager signInManager, + IMessageBus bus, + ILogger logger + ) => + { + var user = await userManager.GetUserAsync(principal); + if (user is null) + return TypedResults.Redirect("/Identity/Account/Login"); + + // Canonical Identity primitive for "invalidate every cookie / token issued + // before now". SecurityStampValidator on the cookie middleware checks this + // on each request (at the configured interval), so other devices die naturally. + await userManager.UpdateSecurityStampAsync(user); + await signInManager.SignOutAsync(); + + LogSignedOutEverywhere(logger, user.Id); + + // OpenIddict subscribes to this event and revokes any active tokens. Going + // through the bus avoids a Users → OpenIddict reference, which the module + // graph forbids. + await bus.PublishAsync(new UserSignedOutEverywhereEvent(UserId.From(user.Id))); + + return TypedResults.Redirect( + "/Identity/Account/Login?signedOutEverywhere=true" + ); + } + ) + .DisableAntiforgery(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 670aec99..37fdbe50 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -77,6 +77,10 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config // Bridge UsersModuleOptions into ASP.NET Identity options services.AddSingleton, ApplyUsersModuleOptions>(); + services.AddSingleton< + IPostConfigureOptions, + ApplySecurityStampValidatorOptions + >(); services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); diff --git a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs index 81c30298..6e4f8148 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs @@ -41,4 +41,11 @@ public class UsersModuleOptions : IModuleOptions /// Duration of account lockout after exceeding max failed attempts. Default: 5 minutes. ///
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// How frequently the cookie middleware revalidates the user's security stamp. The Identity + /// default is 30 minutes; we shorten it so a "Sign out everywhere" action takes effect on + /// other devices much faster. Default: 1 minute. + /// + public TimeSpan SecurityStampValidationInterval { get; set; } = TimeSpan.FromMinutes(1); } diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs new file mode 100644 index 00000000..db12c4aa --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class SignOutEverywhereEndpointTests +{ + private const string EndpointPath = "/Identity/Account/Manage/SignOutEverywhere"; + + private static readonly WebApplicationFactoryClientOptions NoRedirect = new() + { + AllowAutoRedirect = false, + }; + + private readonly SimpleModuleWebApplicationFactory _factory; + + public SignOutEverywhereEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private async Task SeedUserAsync(string id) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var existing = await userManager.FindByIdAsync(id); + if (existing is not null) + return existing; + + var user = new ApplicationUser + { + Id = id, + UserName = $"{id}@example.com", + Email = $"{id}@example.com", + DisplayName = "Sign-out Test User", + }; + var result = await userManager.CreateAsync(user, "TestPass1234!"); + result.Succeeded.Should().BeTrue(); + return (await userManager.FindByIdAsync(id))!; + } + + private async Task GetSecurityStampAsync(string userId) + { + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = await userManager.FindByIdAsync(userId); + return user?.SecurityStamp; + } + + [Fact] + public async Task Post_WhenUnauthenticated_Returns401() + { + using var client = _factory.CreateClient(NoRedirect); + + var response = await client.PostAsync(EndpointPath, null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Post_WhenAuthenticated_RegeneratesSecurityStampAndRedirectsToLogin() + { + const string userId = "signout-everywhere-user"; + await SeedUserAsync(userId); + var stampBefore = await GetSecurityStampAsync(userId); + stampBefore.Should().NotBeNullOrEmpty(); + + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.PostAsync(EndpointPath, null); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + response + .Headers.Location?.OriginalString.Should() + .Contain("signedOutEverywhere=true", "user should land on login with the toast flag"); + + var stampAfter = await GetSecurityStampAsync(userId); + stampAfter + .Should() + .NotBeNullOrEmpty() + .And.NotBe( + stampBefore, + "UpdateSecurityStampAsync must change the stamp so existing cookies/tokens " + + "stop validating on every other device once SecurityStampValidator runs." + ); + } + + [Fact] + public async Task Post_WhenUserDoesNotExist_RedirectsToLogin() + { + using var client = _factory.CreateAuthenticatedClient( + NoRedirect, + new Claim(ClaimTypes.NameIdentifier, "nonexistent-signout-user") + ); + + var response = await client.PostAsync(EndpointPath, null); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + response.Headers.Location?.OriginalString.Should().Contain("/Identity/Account/Login"); + } +} diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 0707b4da..7b7eccfc 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -157,6 +157,7 @@ export const routes = { resetAuthenticator: () => '/Identity/Account/Manage/ResetAuthenticator' as const, resetPasswordConfirmation: () => '/Identity/Account/ResetPasswordConfirmation' as const, resetPassword: () => '/Identity/Account/ResetPassword' as const, + signOutEverywhere: () => '/Identity/Account/Manage/SignOutEverywhere' as const, twoFactorAuthentication: () => '/Identity/Account/Manage/TwoFactorAuthentication' as const, changePassword: () => '/Identity/Account/Manage/ChangePassword' as const, deletePersonalData: () => '/Identity/Account/Manage/DeletePersonalData' as const, diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs index ea615a87..876be336 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Mvc.Testing; using SimpleModule.Testing; namespace SimpleModule.Tests.Shared.Fixtures; @@ -23,4 +24,17 @@ public HttpClient CreateAuthenticatedClient(params Claim[] claims) EnsureDatabasesInitialized(); return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient(this, claims); } + + public HttpClient CreateAuthenticatedClient( + WebApplicationFactoryClientOptions clientOptions, + params Claim[] claims + ) + { + EnsureDatabasesInitialized(); + return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient( + this, + clientOptions, + claims + ); + } }