Disable cookie access when a user is disabled (#6971)

This commit is contained in:
Nicolas Dorier
2025-10-30 23:35:28 +09:00
committed by GitHub
parent b1cba47adf
commit b8fcb83fd6
4 changed files with 110 additions and 4 deletions

View File

@@ -220,6 +220,7 @@ namespace BTCPayServer.Tests
await s.GoToHome(); await s.GoToHome();
await s.GoToServer(ServerNavPages.Users); await s.GoToServer(ServerNavPages.Users);
// Manage user password reset // Manage user password reset
await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.Locator("#SearchTerm").ClearAsync();
await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email);
@@ -233,6 +234,12 @@ namespace BTCPayServer.Tests
await s.ClickPagePrimary(); await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "Password successfully set"); 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) // Manage user status (disable and enable)
// Disable user // Disable user
await s.Page.Locator("#SearchTerm").ClearAsync(); 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("#UsersList tr.user-overview-row:first-child .disable-user");
await s.Page.ClickAsync("#ConfirmContinue"); await s.Page.ClickAsync("#ConfirmContinue");
await s.FindAlertMessage(partialText: "User disabled"); 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 //Enable user
await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.Locator("#SearchTerm").ClearAsync();
await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email);
@@ -255,6 +269,14 @@ namespace BTCPayServer.Tests
await s.Page.ClickAsync("#ConfirmContinue"); await s.Page.ClickAsync("#ConfirmContinue");
await s.FindAlertMessage(partialText: "User enabled"); 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) // Manage user details (edit)
await s.Page.Locator("#SearchTerm").ClearAsync(); await s.Page.Locator("#SearchTerm").ClearAsync();
await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email); await s.Page.FillAsync("#SearchTerm", user.RegisterDetails.Email);

View File

@@ -13,6 +13,7 @@ using BTCPayServer.Logging;
using BTCPayServer.PaymentRequest; using BTCPayServer.PaymentRequest;
using BTCPayServer.Plugins; using BTCPayServer.Plugins;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Storage; using BTCPayServer.Storage;
using Fido2NetLib; using Fido2NetLib;
@@ -87,6 +88,9 @@ namespace BTCPayServer.Hosting
services.AddDataProtection() services.AddDataProtection()
.SetApplicationName("BTCPay Server") .SetApplicationName("BTCPay Server")
.PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir)); .PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir));
services.AddScoped<ISecurityStampValidator, BTCPayServerSecurityStampValidator>();
services.AddSingleton<BTCPayServerSecurityStampValidator.DisabledUsers>();
services.AddIdentity<ApplicationUser, IdentityRole>() services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>() .AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders() .AddDefaultTokenProviders()

View File

@@ -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<SecurityStampValidatorOptions> options,
SignInManager<ApplicationUser> signInManager,
ILoggerFactory logger,
UserManager<ApplicationUser> userManager,
BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers)
: SecurityStampValidator<ApplicationUser>(options, signInManager, logger)
{
public class DisabledUsers
{
ConcurrentDictionary<string, DateTimeOffset> _DisabledUsers = new ConcurrentDictionary<string, DateTimeOffset>();
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);
}
}

View File

@@ -24,6 +24,7 @@ namespace BTCPayServer.Services
private readonly FileService _fileService; private readonly FileService _fileService;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly BTCPayServerSecurityStampValidator.DisabledUsers _disabledUsers;
private readonly ILogger<UserService> _logger; private readonly ILogger<UserService> _logger;
public UserService( public UserService(
@@ -32,6 +33,7 @@ namespace BTCPayServer.Services
FileService fileService, FileService fileService,
EventAggregator eventAggregator, EventAggregator eventAggregator,
ApplicationDbContextFactory applicationDbContextFactory, ApplicationDbContextFactory applicationDbContextFactory,
BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers,
ILogger<UserService> logger) ILogger<UserService> logger)
{ {
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
@@ -39,6 +41,7 @@ namespace BTCPayServer.Services
_fileService = fileService; _fileService = fileService;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_disabledUsers = disabledUsers;
_logger = logger; _logger = logger;
} }
@@ -94,7 +97,7 @@ namespace BTCPayServer.Services
{ {
return user.Approved || !user.RequiresApproval; return user.Approved || !user.RequiresApproval;
} }
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error) public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)
{ {
error = null; error = null;
@@ -120,7 +123,7 @@ namespace BTCPayServer.Services
} }
return true; return true;
} }
public async Task<bool> SetUserApproval(string userId, bool approved, string loginLink) public async Task<bool> SetUserApproval(string userId, bool approved, string loginLink)
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
@@ -130,7 +133,7 @@ namespace BTCPayServer.Services
{ {
return false; return false;
} }
user.Approved = approved; user.Approved = approved;
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true }; var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
if (succeeded) if (succeeded)
@@ -145,7 +148,7 @@ namespace BTCPayServer.Services
return succeeded; return succeeded;
} }
public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline) public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
{ {
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
@@ -161,6 +164,17 @@ namespace BTCPayServer.Services
} }
var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline); 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) if (res.Succeeded)
{ {
_logger.LogInformation("User {Email} is now {Status}", user.Email, (lockedOutDeadline is null ? "unlocked" : "locked")); _logger.LogInformation("User {Email} is now {Status}", user.Email, (lockedOutDeadline is null ? "unlocked" : "locked"));