IdentityServer多客户端多标签页令牌失效问题求助
问题现象
两个客户端连接同一IdentityServer,用户已完成登录。在标签页1打开客户端1,从客户端1打开新标签页访问客户端2,客户端2成功向IdentityServer请求并获取令牌。返回标签页1发起fetch请求时失败,提示令牌过期,必须刷新页面才能重新获取有效令牌。
错误日志
2022-12-15 13:57:33.6372|1|INFO|Microsoft.AspNetCore.Hosting.Diagnostics|Request starting HTTP/2.0 POST https://srv/administration/details?handler=ChecktName application/json; charset=UTF-8 74 2022-12-15 13:57:33.6372|7|INFO|Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler|Cookies was not authenticated. Failure message: Unprotect ticket failed 2022-12-15 13:57:33.6372|2|INFO|Microsoft.AspNetCore.Authorization.DefaultAuthorizationService|Authorization failed. 2022-12-15 13:57:33.6372|12|INFO|Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectHandler|AuthenticationScheme: oidc was challenged. 2022-12-15 13:57:33.6372|2|INFO|Microsoft.AspNetCore.Hosting.Diagnostics|Request finished in 1.6358ms 302
已尝试的无效方案
曾认为是Data Protection问题,在启动配置中添加以下代码,但未解决问题:
public void ConfigureServices(IServiceCollection services) { .... services.AddDataProtection(); .... }
补充信息
- IdentityServer和所有客户端运行在同一服务器、同一域名下,仅端口不同。
- 检查发现新标签页中refresh token被重新生成,导致第一个标签页的refresh token过期失效。
IdentityServer配置
services .AddIdentityServer(IdentityServerOptions) .AddInMemoryApiResources(Config.GetApis()) .AddInMemoryApiScopes(Config.GetScoops()) .AddInMemoryIdentityResources(Config.GetIdentityResources()) .AddInMemoryClients(Config.GetClients())) public static IEnumerable<Client> GetClients(IConfigurationSection section) { var clients = new List<Client>(); var eClients = new List<PlugIn>(); section.Bind(eClients); foreach (PlugIn plugIn in eClients) { string lUrl = plugIn.Url; if (!string.IsNullOrEmpty(lUrl) && lUrl.EndsWith("/")) { lUrl = lUrl.Substring(0, lUrl.Length - 1); } if (plugIn.ClientConfig != null) { var client = plugIn.ClientConfig; client.AllowedGrantTypes = GrantTypes.CodeAndClientCredentials; if (!client.AllowedScopes.Contains(IdentityServerConstants.StandardScopes.OpenId)) { client.AllowedScopes.Add(IdentityServerConstants.StandardScopes.OpenId); } if (!client.AllowedScopes.Contains(IdentityServerConstants.StandardScopes.Profile)) { client.AllowedScopes.Add(IdentityServerConstants.StandardScopes.Profile); } if (!client.AllowedScopes.Contains("roles")) { client.AllowedScopes.Add("roles"); } client.AllowedScopes.Add(IdentityServerConstants.LocalApi.ScopeName); client.AllowedScopes.Add(IdentityServerConstants.StandardScopes.OfflineAccess); client.AllowOfflineAccess = true; client.FrontChannelLogoutSessionRequired = false; client.UpdateAccessTokenClaimsOnRefresh = true; AddOidcSignInUrl(lUrl, client); client.RedirectUris.Add(lUrl + "/swagger/oauth2-redirect.html"); client.PostLogoutRedirectUris.Add(lUrl + "/signout-callback-oidc"); client.FrontChannelLogoutUri = lUrl + "/Clientlogout"; client.AlwaysIncludeUserClaimsInIdToken = true; client.AlwaysSendClientClaims = true; client.AbsoluteRefreshTokenLifetime = 86400; client.SlidingRefreshTokenLifetime = 43200; if (client.AccessTokenLifetime < 3600) { client.AccessTokenLifetime = 3600; } client.RefreshTokenUsage = TokenUsage.ReUse; client.RefreshTokenExpiration = TokenExpiration.Absolute; clients.Add(client); } } return clients; }
客户端配置
.... services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; }) .AddCookie("Cookies", options => { options.ExpireTimeSpan = TimeSpan.FromDays(14); options.SlidingExpiration = true; options.SessionStore = new DictionaryTicketStore(services); options.Cookie.Path = "/"; }) .AddOpenIdConnect("oidc", options => { options.Authority = this.CentralHubAdresse; options.ClientId = Assembly.GetEntryAssembly().GetName().Name; options.ClientSecret = "####"; options.ResponseType = "code"; options.GetClaimsFromUserInfoEndpoint = true; options.RequireHttpsMetadata = true; options.BackchannelHttpHandler = GetHandler(); options.Scope.Add("roles"); options.Scope.Add("offline_access"); options.Scope.Add("IdentityServerApi"); options.ClaimActions.MapJsonKey("role", "role", "role"); options.TokenValidationParameters.RoleClaimType = "role"; options.SaveTokens = true; options.UseTokenLifetime = false; });
解决思路
1. 统一Data Protection密钥配置
仅添加services.AddDataProtection()不足以让多个应用共享密钥,需将密钥持久化到共享存储,并统一应用名称,确保所有应用(IdentityServer、客户端1、客户端2)使用相同的密钥环解密Cookie。示例配置:
services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(@"C:\SharedDataProtectionKeys")) // 或共享网络路径 .SetApplicationName("YourSharedAppName");
所有相关应用都需要配置相同的SetApplicationName和密钥存储位置。
2. 调整Refresh Token策略
当前RefreshTokenUsage = TokenUsage.ReUse结合RefreshTokenExpiration = TokenExpiration.Absolute的配置,会导致同一用户同一客户端获取新授权时,旧refresh token被直接吊销。可改为令牌轮换+滑动过期策略,支持自动刷新并延长有效期:
client.RefreshTokenUsage = TokenUsage.OneTimeOnly; client.RefreshTokenExpiration = TokenExpiration.Sliding;
该配置下每次刷新令牌都会生成新的refresh token,旧token立即失效,但滑动过期会根据刷新操作延长绝对过期时间,减少因多标签页操作导致的token失效问题。
3. 优化客户端Cookie配置
- 检查自定义
DictionaryTicketStore是否存在ticket持久化或解密问题,可暂时移除该配置,使用默认内存存储验证是否解决问题。 - 为每个客户端配置唯一的Cookie名称,避免同域名下不同客户端的Cookie冲突:
.AddCookie("Cookies", options => { // 其他配置... options.Cookie.Name = "Client1_Auth_Cookie"; // 客户端2设置为不同名称 })
4. 实现客户端自动令牌刷新
在客户端OpenID Connect配置中添加令牌自动刷新逻辑,在令牌即将过期前主动刷新,避免因token过期导致请求失败:
options.Events.OnTokenValidated = async context => { var expiresAtStr = context.Properties.GetTokenValue("expires_at"); if (DateTime.TryParse(expiresAtStr, out var expiration)) { var timeUntilExpire = expiration - DateTime.UtcNow; // 提前5分钟刷新令牌 if (timeUntilExpire < TimeSpan.FromMinutes(5)) { var refreshToken = context.Properties.GetTokenValue("refresh_token"); var tokenClient = context.HttpContext.RequestServices.GetRequiredService<IHttpClientFactory>().CreateClient(); var tokenResponse = await tokenClient.RequestRefreshTokenAsync(new RefreshTokenRequest { Address = $"{options.Authority}/connect/token", ClientId = options.ClientId, ClientSecret = options.ClientSecret, RefreshToken = refreshToken }); if (!tokenResponse.IsError) { // 更新存储的令牌信息 context.Properties.UpdateTokenValue("access_token", tokenResponse.AccessToken); context.Properties.UpdateTokenValue("refresh_token", tokenResponse.RefreshToken); context.Properties.UpdateTokenValue("expires_at", DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o")); // 重新登录以更新Cookie await context.HttpContext.SignInAsync("Cookies", context.Principal, context.Properties); } } } };
5. 检查IdentityServer会话管理
确认IdentityServer的会话配置,若需支持同一用户多会话并发,可调整会话过期时间,并确保FrontChannelLogoutSessionRequired配置符合业务需求,避免注销一个会话影响其他正常会话。
内容的提问来源于stack exchange,提问作者user7555530

