From f9328a0f9fc1812cca3b28372be5dd9e4a7f2c19 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 28 Sep 2026 14:54:56 -0700 Subject: [PATCH] perf(gateway): verify each request's account token once Account-token verification runs PBKDF2 (120,000 iterations, about 17 ms of CPU) inside OperatorAccountService's global lock and then rewrites the accounts file. The role checks added in this branch meant that /v1/*, /apps/chat, A2A, /ws, /ws/live, and mutating MCP tools verified the same token twice per request, halving account-token throughput on those surfaces. Cache the verification outcome in HttpContext.Items for the rest of the request. Revocation, disabling, and role changes still apply from the next request. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Endpoints/EndpointHelpers.cs | 28 ++++++- .../EndpointHelpersAuthenticationTests.cs | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs diff --git a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs index 209ec51c..1c899934 100644 --- a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs +++ b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs @@ -69,7 +69,7 @@ public static bool IsAuthorizedRequest(HttpContext ctx, GatewayConfig config, bo if (operatorAccounts is not null) { var token = GatewaySecurity.GetToken(ctx, config.Security.AllowQueryStringToken); - if (!string.IsNullOrWhiteSpace(token) && operatorAccounts.TryAuthenticateToken(token, out _)) + if (!string.IsNullOrWhiteSpace(token) && TryAuthenticateAccountToken(ctx, operatorAccounts, token, out _)) return true; } } @@ -161,7 +161,7 @@ public static OperatorAuthorizationResult AuthorizeOperatorRequest( if (IsAllowedAuthMode(policy, OrganizationAuthModeNames.AccountToken) && !string.IsNullOrWhiteSpace(token) && operatorAccounts is not null && - operatorAccounts.TryAuthenticateToken(token, out var accountIdentity)) + TryAuthenticateAccountToken(ctx, operatorAccounts, token, out var accountIdentity)) { return new OperatorAuthorizationResult( true, @@ -203,6 +203,30 @@ operatorAccounts is not null && IsBootstrapAdmin: false); } + private sealed record AccountTokenVerification(string Token, bool Succeeded, OperatorIdentitySnapshot? Identity); + + // Token verification is deliberately slow (PBKDF2) and serialized inside OperatorAccountService, and one + // request can pass through several checks (authentication, role, identity). Verify each request's token + // once and reuse the outcome; revocation still applies from the next request. + private static bool TryAuthenticateAccountToken( + HttpContext ctx, + OperatorAccountService operatorAccounts, + string token, + out OperatorIdentitySnapshot? identity) + { + if (ctx.Items.TryGetValue(typeof(AccountTokenVerification), out var cached) && + cached is AccountTokenVerification previous && + string.Equals(previous.Token, token, StringComparison.Ordinal)) + { + identity = previous.Identity; + return previous.Succeeded; + } + + var succeeded = operatorAccounts.TryAuthenticateToken(token, out identity); + ctx.Items[typeof(AccountTokenVerification)] = new AccountTokenVerification(token, succeeded, identity); + return succeeded; + } + public static bool TrySetMaxRequestBodySize(HttpContext ctx, long maxBytes) { var feature = ctx.Features.Get(); diff --git a/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs b/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs new file mode 100644 index 00000000..3bf10f14 --- /dev/null +++ b/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using OpenClaw.Core.Models; +using OpenClaw.Gateway; +using OpenClaw.Gateway.Bootstrap; +using OpenClaw.Gateway.Endpoints; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class EndpointHelpersAuthenticationTests : IDisposable +{ + private readonly string _storagePath = Path.Combine(Path.GetTempPath(), "openclaw-endpoint-auth-tests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_storagePath, recursive: true); } + catch { } + } + + [Fact] + public void AccountToken_WhenCheckedTwiceInOneRequest_ShouldBeVerifiedOnce() + { + var (startup, services, accounts, accountId, token) = CreateOperatorToken(); + var ctx = CreateRequest(services, token.Token); + + Assert.True(EndpointHelpers.IsAuthorizedRequest(ctx, startup.Config, startup.IsNonLoopbackBind)); + + // Revoking after the first check makes a second verification observable: a re-run of the slow + // token check would now fail, while a reused result for this request still succeeds. + Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id)); + var auth = EndpointHelpers.AuthorizeOperatorRequest(ctx, startup, services.GetRequiredService(), requireCsrf: false); + + Assert.True(auth.IsAuthorized); + Assert.Equal(accountId, auth.AccountId); + } + + [Fact] + public void AccountToken_WhenRevokedBeforeNextRequest_ShouldBeRejected() + { + var (startup, services, accounts, accountId, token) = CreateOperatorToken(); + Assert.True(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind)); + + Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id)); + + Assert.False(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind)); + } + + private (GatewayStartupContext Startup, ServiceProvider Services, OperatorAccountService Accounts, string AccountId, OperatorAccountTokenCreateResponse Token) CreateOperatorToken() + { + var config = new GatewayConfig { AuthToken = "bootstrap-token" }; + var startup = new GatewayStartupContext + { + Config = config, + RuntimeState = RuntimeModeResolver.Resolve(config.Runtime, dynamicCodeSupported: true), + IsNonLoopbackBind = true + }; + var accounts = new OperatorAccountService(_storagePath, NullLogger.Instance); + var account = accounts.Create(new OperatorAccountCreateRequest + { + Username = "memo-operator", + Password = "P@ssw0rd123!", + Role = OperatorRoleNames.Operator + }); + var token = accounts.CreateToken(account.Id, new OperatorAccountTokenCreateRequest { Label = "memo" })!; + + var services = new ServiceCollection(); + services.AddSingleton(new BrowserSessionAuthService(config)); + services.AddSingleton(accounts); + services.AddSingleton(new OrganizationPolicyService(_storagePath, NullLogger.Instance)); + return (startup, services.BuildServiceProvider(), accounts, account.Id, token); + } + + private static DefaultHttpContext CreateRequest(IServiceProvider services, string token) + { + var ctx = new DefaultHttpContext { RequestServices = services }; + ctx.Request.Headers.Authorization = $"Bearer {token}"; + return ctx; + } +}