From b8fcb83fd63e4b6d2c8bc2f66c5dc5cee735f59d Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Thu, 30 Oct 2025 23:35:28 +0900 Subject: [PATCH] Disable cookie access when a user is disabled (#6971) --- BTCPayServer.Tests/PlaywrightTests.cs | 22 +++++++ BTCPayServer/Hosting/Startup.cs | 4 ++ .../BTCPayServerSecurityStampValidator.cs | 66 +++++++++++++++++++ BTCPayServer/Services/UserService.cs | 22 +++++-- 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 BTCPayServer/Services/BTCPayServerSecurityStampValidator.cs diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs index 8938d3061..8e95b4c78 100644 --- a/BTCPayServer.Tests/PlaywrightTests.cs +++ b/BTCPayServer.Tests/PlaywrightTests.cs @@ -220,6 +220,7 @@ namespace BTCPayServer.Tests await s.GoToHome(); await s.GoToServer(ServerNavPages.Users); + // Manage user password reset await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); @@ -233,6 +234,12 @@ namespace BTCPayServer.Tests await s.ClickPagePrimary(); await s.FindAlertMessage(partialText: "Password successfully set"); + var userPage = await s.Browser.NewPageAsync(); + await using (await s.SwitchPage(userPage, false)) + { + await s.GoToLogin(); + await s.LogIn(user.Email, user.Password); + } // Manage user status (disable and enable) // Disable user await s.Page.Locator("#SearchTerm").ClearAsync(); @@ -244,6 +251,13 @@ namespace BTCPayServer.Tests await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .disable-user"); await s.Page.ClickAsync("#ConfirmContinue"); await s.FindAlertMessage(partialText: "User disabled"); + + await using (await s.SwitchPage(userPage, false)) + { + await s.Page.ReloadAsync(); + await s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning, partialText: "Your user account is currently disabled"); + } + //Enable user await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); @@ -255,6 +269,14 @@ namespace BTCPayServer.Tests await s.Page.ClickAsync("#ConfirmContinue"); await s.FindAlertMessage(partialText: "User enabled"); + await using (await s.SwitchPage(userPage)) + { + // Can log again + await s.LogIn(user.Email, "Password@1!"); + await s.CreateNewStore(); + await s.Logout(); + } + // Manage user details (edit) await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index fad935308..7747abbd9 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -13,6 +13,7 @@ using BTCPayServer.Logging; using BTCPayServer.PaymentRequest; using BTCPayServer.Plugins; using BTCPayServer.Security; +using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Storage; using Fido2NetLib; @@ -87,6 +88,9 @@ namespace BTCPayServer.Hosting services.AddDataProtection() .SetApplicationName("BTCPay Server") .PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir)); + + services.AddScoped(); + services.AddSingleton(); services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders() diff --git a/BTCPayServer/Services/BTCPayServerSecurityStampValidator.cs b/BTCPayServer/Services/BTCPayServerSecurityStampValidator.cs new file mode 100644 index 000000000..0c178e8ac --- /dev/null +++ b/BTCPayServer/Services/BTCPayServerSecurityStampValidator.cs @@ -0,0 +1,66 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Services; + +public class BTCPayServerSecurityStampValidator( + IOptions options, + SignInManager signInManager, + ILoggerFactory logger, + UserManager userManager, + BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers) + : SecurityStampValidator(options, signInManager, logger) +{ + public class DisabledUsers + { + ConcurrentDictionary _DisabledUsers = new ConcurrentDictionary(); + public bool HasAny => !_DisabledUsers.IsEmpty; + + public void Add(string user) + { + _DisabledUsers.TryAdd(user, DateTimeOffset.UtcNow); + } + + public void Remove(string user) + { + _DisabledUsers.TryRemove(user, out _); + } + + public bool Contains(string id) => _DisabledUsers.ContainsKey(id); + + public void Cleanup(TimeSpan validationInterval) + { + if (_DisabledUsers.IsEmpty) + return; + var now = DateTimeOffset.UtcNow; + foreach (var kv in _DisabledUsers) + { + if (now - kv.Value > validationInterval) + Remove(kv.Key); + } + } + } + + public override async Task ValidateAsync(CookieValidatePrincipalContext context) + { + if (disabledUsers.HasAny && + context.Principal is not null && + userManager.GetUserId(context.Principal) is string id && + disabledUsers.Contains(id)) + { + context.Properties.IssuedUtc = null; + } + disabledUsers.Cleanup(Options.ValidationInterval); + await base.ValidateAsync(context); + } +} diff --git a/BTCPayServer/Services/UserService.cs b/BTCPayServer/Services/UserService.cs index 8676148eb..4a0e0bab2 100644 --- a/BTCPayServer/Services/UserService.cs +++ b/BTCPayServer/Services/UserService.cs @@ -24,6 +24,7 @@ namespace BTCPayServer.Services private readonly FileService _fileService; private readonly EventAggregator _eventAggregator; private readonly ApplicationDbContextFactory _applicationDbContextFactory; + private readonly BTCPayServerSecurityStampValidator.DisabledUsers _disabledUsers; private readonly ILogger _logger; public UserService( @@ -32,6 +33,7 @@ namespace BTCPayServer.Services FileService fileService, EventAggregator eventAggregator, ApplicationDbContextFactory applicationDbContextFactory, + BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers, ILogger logger) { _serviceProvider = serviceProvider; @@ -39,6 +41,7 @@ namespace BTCPayServer.Services _fileService = fileService; _eventAggregator = eventAggregator; _applicationDbContextFactory = applicationDbContextFactory; + _disabledUsers = disabledUsers; _logger = logger; } @@ -94,7 +97,7 @@ namespace BTCPayServer.Services { return user.Approved || !user.RequiresApproval; } - + public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error) { error = null; @@ -120,7 +123,7 @@ namespace BTCPayServer.Services } return true; } - + public async Task SetUserApproval(string userId, bool approved, string loginLink) { using var scope = _serviceProvider.CreateScope(); @@ -130,7 +133,7 @@ namespace BTCPayServer.Services { return false; } - + user.Approved = approved; var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true }; if (succeeded) @@ -145,7 +148,7 @@ namespace BTCPayServer.Services return succeeded; } - + public async Task ToggleUser(string userId, DateTimeOffset? lockedOutDeadline) { using var scope = _serviceProvider.CreateScope(); @@ -161,6 +164,17 @@ namespace BTCPayServer.Services } var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline); + // Without this, the user won't be logged out automatically when his authentication ticket expires + if (lockedOutDeadline is not null) + { + await userManager.UpdateSecurityStampAsync(user); + _disabledUsers.Add(userId); + } + else + { + _disabledUsers.Remove(userId); + } + if (res.Succeeded) { _logger.LogInformation("User {Email} is now {Status}", user.Email, (lockedOutDeadline is null ? "unlocked" : "locked"));