当浏览器刷新并且 cookie 的令牌无效时,如何在 Blazor Server 中处理用户 OIDC 令牌?

Rob*_*Rob 5 oauth access-token openid-connect refresh-token blazor-server-side

Microsoft 建议不要HttpContext在 Blazor Server 中使用(此处)。为了解决如何将用户令牌传递到 Blazor Server 应用程序的问题,Microsoft 建议将令牌存储在服务中Scoped此处)。Jon McGuire\xe2\x80\x99s 博客建议采用类似的方法将令牌存储在Cache此处)中。

\n

只要用户保持在同一个 Blazor 服务器连接中,上述 Microsoft\xe2\x80\x99s 方法就可以正常工作。但是,如果access_token刷新并且用户通过按 F5 或将 URL 粘贴到地址栏中来重新加载页面,则会尝试从 cookie 中检索令牌。此时, cookie中的access_tokenrefresh_token不再有效了。Jon McGuire 在他的博客文章末尾提到了这个问题,并将其称为 Stale Cookies(此处)。他给出了有关可能的解决方案的提示,但对实施说明却很少提及。该帖子的底部有许多来自无法实施解决方案的人的评论,但没有提出明显的可行解决方案。我花了很多时间寻找解决方案,但我发现人们都在寻求解决方案,但没有收到任何有效的答案。

\n

找到了一个似乎运行良好并且似乎相当有原则的解决方案后,我认为可能值得在这里分享我的解决方案。我欢迎任何建设性的批评或对任何重大改进的建议。

\n

Rob*_*Rob 5

编辑 20220715:在 Dominic Baier 对我们的方法提供一些反馈后,我们删除了我们的Scoped UserSubProvider服务,转而使用AuthenticationStateProvider。这简化了我们的方法。我编辑了以下答案以反映这一变化。

\n
\n

此方法结合了 Microsoft 关于如何将令牌传递到 Blazor Server 应用程序(此处)的建议,以及在服务中为所有用户提供令牌的服务器端存储Singleton(受到 GitHub 上的 Dominick Baier\xe2\x80\x99s Blazor Server 示例项目的启发,此处))。

\n

我们没有捕获_Host.cshtml文件中的令牌并将其存储在Scoped服务中(就像 Microsoft 在其示例中所做的那样),而是OnTokenValidated以与 Dominick Baier\xe2\x80\x99s 示例类似的方式使用该事件,将令牌存储在Singleton为所有人持有代币的服务Users,我们称之为服务ServerSideTokenStore

\n

当我们使用 ourHttpClient来调用 API 并且它需要一个access_token( 或refresh_token) 时,它会User从注入的 中检索 \xe2\x80\x99s sub AuthenticationStateProvider,使用它来调用ServerSideTokenStore.GetTokensAsync(),这会返回一个UserTokenProvider(类似于 Microsoft\xe2\x80\x99s TokenProvider)包含令牌。如果HttpClient需要刷新令牌,则它会填充 aUserTokenProvider并通过调用 来保存它ServerSideTokenStore.SetTokensAsync()

\n

我们遇到的另一个问题是,如果在应用程序重新启动时打开 Web 浏览器的单独实例(因此丢失 中保存的数据ServerSideTokenStore),用户仍将使用 cookie 进行身份验证,但我们\xe2\x80\x99已经丢失了access_tokenrefresh_token。如果重新启动应用程序,这种情况可能会在生产中发生,但在开发环境中发生的频率要高得多。如果我们无法OnValidatePrincipal获得RejectPrincipal()合适的access_token. 这会强制往返 IdentityServer,后者提供了新access_tokenrefresh_token. 这种方法来自这个堆栈溢出线程

\n

(为了清楚/重点,下面的一些代码排除了一些标准错误处理、日志记录等)

\n

从 AuthenticationStateProvider 获取 User 子声明

\n

我们从注入HttpClient的. 它在调用and时使用字符串。subAuthenticationStateProvideruserSubServerSideTokenStore.GetTokensAsync()ServerSideTokenStore.SetTokensAsync()

\n
    var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();\n    string userSub = state.User.FindFirst("sub")?.Value;\n
Run Code Online (Sandbox Code Playgroud)\n

用户令牌提供者

\n
    public class UserTokenProvider\n    {\n        public string AccessToken { get; set; }\n        public string RefreshToken { get; set; }\n        public DateTimeOffset Expiration { get; set; }\n    }\n
Run Code Online (Sandbox Code Playgroud)\n

服务器端令牌存储

\n
    public class ServerSideTokenStore\n    {\n        private readonly ConcurrentDictionary<string, UserTokenProvider> UserTokenProviders = new();\n    \n        public Task ClearTokensAsync(string userSub)\n        {\n            UserTokenProviders.TryRemove(userSub, out _);\n            return Task.CompletedTask;\n        }\n    \n        public Task<UserTokenProvider> GetTokensAsync(string userSub)\n        {\n            UserTokenProviders.TryGetValue(userSub, out var value);\n            return Task.FromResult(value);\n        }\n    \n        public Task StoreTokensAsync(string userSub, UserTokenProvider userTokenProvider)\n        {\n            UserTokenProviders[userSub] = userTokenProvider;\n            Return Task.CompletedTask;\n        }\n    }\n
Run Code Online (Sandbox Code Playgroud)\n

Startup.csConfigureServices(或等效位置,如果使用.NET 6/其他)

\n
    public void ConfigureServices(IServiceCollection services)\n    {\n        // \xe2\x80\xa6\n        services.AddAuthentication(\xe2\x80\xa6)\n        .AddCookie(\xe2\x80\x9cCookies\xe2\x80\x9d, options =>\n        {\n            // \xe2\x80\xa6\n            options.Events.OnValidatePrincipal = async context =>\n            {\n                if (context.Principal.Identity.IsAuthenticated)\n                {\n                    // get user sub \n                    var userSub = context.Principal.FindFirst(\xe2\x80\x9csub\xe2\x80\x9d).Value;\n                    // get user\'s tokens from server side token store\n                    var tokenStore =\n                        context.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();\n                    var tokens = await tokenStore.GetTokenAsync(userSub);\n                    if (tokens?.AccessToken == null \n                        || tokens?.Expiration == null \n                        || tokens?.RefreshToken == null)\n                    {\n                        // if we lack either an access or refresh token,\n                        // then reject the Principal (forcing a round trip to the id server)\n                        context.RejectPrincipal();\n                        return;\n                    }\n                    // if the access token has expired, attempt to refresh it\n                    if (tokens.Expiration < DateTimeOffset.UtcNow) \n                    {\n                        // we have a custom API client that takes care of refreshing our tokens \n                        // and storing them in ServerSideTokenStore, we call that here\n                        // \xe2\x80\xa6\n                        // check the tokens have been updated\n                        var newTokens = await tokenStore.GetTokenAsync(userSubProvider.UserSub);\n                        if (newTokens?.AccessToken == null \n                            || newTokens?.Expiration == null \n                            || newTokens.Expiration < DateTimeOffset.UtcNow)\n                        {\n                            // if we lack an access token or it was not successfully renewed, \n                            // then reject the Principal (forcing a round trip to the id server)\n                            context.RejectPrincipal();\n                            return;\n                        }\n                    }\n                }\n            }\n        }\n        .AddOpenIdConnect(\xe2\x80\x9coidc\xe2\x80\x9d, options =>\n        {\n            // \xe2\x80\xa6\n            options.Events.OnTokenValidated = async n =>\n            {\n                var svc = n.HttpContext.RequestServices.GetRequiredService<IServerSideTokenStore>();\n                var culture = new CultureInfo(\xe2\x80\x9cEN\xe2\x80\x9d) ;\n                var exp = DateTimeOffset\n                          .UtcNow\n                          .AddSeconds(double.Parse(n.TokenEndpointResponse !.ExpiresIn, culture));\n                var userTokenProvider = new UserTokenProvider() \n                {\n                    AcessToken = n.TokenEndpointResponse.AccessToken,\n                    Expiration = exp,\n                    RefreshToken = n.TokenEndpointResponse.RefreshToken\n                }\n                await svc.StoreTokensAsync(n.Principal.FindFirst(\xe2\x80\x9csub\xe2\x80\x9d).Value, userTokenProvider);\n            };\n            // \xe2\x80\xa6\n        });\n        // \xe2\x80\xa6\n    }\n
Run Code Online (Sandbox Code Playgroud)\n