Duende.AccessTokenManagement配置的HTTP客户端发送错误用户访问令牌问题
我们的ASP.NET Core 8站点使用Duende.AccessTokenManagement的AddUserAccessTokenHttpClient功能配置HTTP客户端,目的是给发送到API的请求自动附加(必要时续订)Bearer访问令牌。但发现多用户同时使用时,部分请求会错误携带其他用户的令牌,导致API误判请求归属。目前尚未找到稳定复现步骤,但确定必须有多用户并发的触发条件。调试Duende代码时,追踪到问题出在从会话中检索已保存访问令牌的环节,但暂时无法定位根因,现梳理相关配置及可能的问题点。
重要说明
- 通过OpenID Connect对接Identity Server完成身份验证
- 使用自定义Session Store将应用程序Cookie存储在分布式SQL Server缓存中
Startup.cs相关代码片段
services.AddDistributedSqlServerCache(options => { options.ConnectionString = "My-Connection-String"; options.SchemaName = "dbo"; options.TableName = "MyCacheTable"; }); services.AddSession(options => { options.IdleTimeout = TimeSpan.FromDays(7); options.Cookie.MaxAge = TimeSpan.FromDays(365); options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; options.Cookie.HttpOnly = false; options.Cookie.IsEssential = true; options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None; }); services.Configure<DistributedCacheTicketStoreOptions>(options => { options.TimeoutMinutes = 60 * 24 * 30; }); services.AddSingleton<ITicketStore, DistributedCacheTicketStore>(); services.AddOptions<CookieAuthenticationOptions>("Application").Configure<IServiceProvider>((options, serviceProvider) => { options.ExpireTimeSpan = TimeSpan.FromDays(365); options.Cookie.Name = "mvccookie." + Environment.EnvironmentName; options.Cookie.Path = "/"; options.Cookie.MaxAge = TimeSpan.FromDays(365); options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; options.Cookie.HttpOnly = false; options.Cookie.IsEssential = true; options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None; options.SessionStore = serviceProvider.GetService<ITicketStore>(); options.LoginPath = "/account/login"; options.Events.OnSigningOut = async e => { await e.HttpContext.RevokeRefreshTokenAsync(); }; }); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = options.DefaultSignInScheme = options.DefaultScheme = "Application"; options.DefaultChallengeScheme = "oidc"; }) .AddCookie("Application") .AddOpenIdConnect("oidc", "Identity Server", options => { options.SignInScheme = "Application"; options.GetClaimsFromUserInfoEndpoint = true; options.SaveTokens = true; options.UsePkce = true; options.Authority = "My Identity Server Url"; options.ClientId = "My Client Id"; options.ClientSecret = "My Client Secret"; options.ResponseType = "code id_token token"; Array.ForEach("openid profile email roles offline_access".Split(' '), options.Scope.Add); options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RoleClaimType = "role", ValidAudience = "MyApp", ValidateAudience = true, ValidIssuer = "My Identity Server Url", ValidateIssuer = true, AuthenticationType = "Application" }; }); // 添加用户和客户端访问令牌管理 services.AddOpenIdConnectAccessTokenManagement(options => { // 客户端配置从OpenID Connect设置中推断 // 如果需要显式指定作用域,在此处配置,否则不会发送scope参数 options.RefreshBeforeExpiration = TimeSpan.FromSeconds(30); }); // 注册使用托管用户访问令牌的HTTP客户端 services.AddUserAccessTokenHttpClient("MyApiClient", configureClient: client => { client.BaseAddress = new Uri("https://myapi.com/"); });
自定义TicketStore实现代码
public class DistributedCacheTicketStoreOptions { public double TimeoutMinutes { get; set; } } public class DistributedCacheTicketStore : ITicketStore { private const string _keyPrefix = "AuthSessionStore-"; private readonly DistributedCacheTicketStoreOptions _options; private readonly IDistributedCache _distributedCache; private readonly IDataProtector _dataProtector; private readonly TicketSerializer _ticketSerializer = TicketSerializer.Default; private readonly ILogger<DistributedCacheTicketStore> _logger; public DistributedCacheTicketStore( IOptions<DistributedCacheTicketStoreOptions> options, IDistributedCache distributedCache, IDataProtectionProvider dataProtectionProvider, ILogger<DistributedCacheTicketStore> logger) { _options = options.Value; _distributedCache = distributedCache; _dataProtector = dataProtectionProvider.CreateProtector(this.GetType().FullName); _logger = logger; } public async Task<string> StoreAsync(AuthenticationTicket ticket) { var key = $"{_keyPrefix}{Guid.NewGuid().ToString("N")}"; await RenewAsync(key, ticket); return key; } public async Task RenewAsync(string key, AuthenticationTicket ticket) { if (ticket == null) { throw new ArgumentNullException(nameof(ticket)); } // 注意:使用`services.enableImmediateLogout();`会导致此方法在每个请求中被调用 var options = new DistributedCacheEntryOptions(); var expiresUtc = ticket.Properties.ExpiresUtc; if (expiresUtc.HasValue) { options.SetAbsoluteExpiration(expiresUtc.Value); } if (ticket.Properties.AllowRefresh ?? false) { options.SetSlidingExpiration(TimeSpan.FromMinutes(_options.TimeoutMinutes)); } await _distributedCache.SetAsync(key, _ticketSerializer.Serialize(ticket), options); } public async Task<AuthenticationTicket> RetrieveAsync(string key) { var value = await _distributedCache.GetAsync(key); var ticket = value != null ? _ticketSerializer.Deserialize(value) : null; return ticket; } public async Task RemoveAsync(string key) { await _distributedCache.RemoveAsync(key); } }
可能的问题原因分析
Cookie安全配置风险
代码中设置options.Cookie.HttpOnly = false,这允许前端JavaScript访问身份认证Cookie,存在XSS攻击导致会话ID窃取的风险,进而引发令牌串用问题。建议立即将HttpOnly改为true,这是身份认证Cookie的标准安全配置。分布式缓存的键关联问题
虽然StoreAsync使用Guid生成唯一缓存键,但需确认:- Cookie中存储的会话ID是否正确映射到缓存键,是否存在并发场景下的键复用或错误关联
- SQL Server分布式缓存的
SetAsync操作是否为原子性,避免多请求同时更新同一键导致票据覆盖
HttpClient与用户上下文的绑定问题
Duende的UserAccessTokenHandler需要依赖当前请求的HttpContext获取用户令牌,若该Handler被错误注册为单例(而非Scoped),会导致多请求复用同一上下文,引发令牌串用。需确认AddUserAccessTokenHttpClient内部的Handler注册逻辑是否正确。身份认证票据的序列化/反序列化问题
虽然使用了默认的TicketSerializer.Default,但需验证其在多线程并发场景下的线程安全性。若序列化过程中出现数据损坏,可能导致票据被错误反序列化为其他用户的信息。滑动过期的并发冲突
在RenewAsync中,当AllowRefresh为true时设置了滑动过期,若多个请求同时更新同一缓存键,可能导致旧的认证票据被覆盖,极端情况下可能获取到其他用户的缓存数据(若键出现冲突)。中间件顺序或上下文复用问题
检查ASP.NET Core中间件注册顺序,确保身份认证中间件在所有业务中间件之前。若存在自定义中间件错误地将用户上下文存储在静态变量或单例服务中,会导致多请求上下文串用。
内容的提问来源于stack exchange,提问作者skaaks

