【问题标题】:How to handle user OIDC tokens in Blazor Server when the browser is refreshed and the cookie’s tokens are invalid?当浏览器刷新且 cookie 的令牌无效时,如何在 Blazor Server 中处理用户 OIDC 令牌?
【发布时间】:2022-07-05 18:56:57
【问题描述】:

Microsoft 建议不要在 Blazor Server (here) 中使用 HttpContext。要解决如何将用户令牌传递给 Blazor 服务器应用的问题,Microsoft 建议将令牌存储在 Scoped 服务 (here) 中。 Jon McGuire 的博客提出了一种类似的方法,将令牌存储在 Cache (here) 中。

只要用户保持在同一个 Blazor Server 连接中,Microsoft 的上述方法就可以正常工作。 但是,如果刷新了access_token,然后用户通过按 F5 或将 URL 粘贴到地址栏来重新加载页面,则会尝试从 cookie 中检索令牌。此时,cookie 中的access_token 和refresh_token 不再有效。 Jon McGuire 在他的博文末尾提到了这个问题,并将其称为 Stale Cookies (here)。他提供了有关可能解决方案的提示,但对实施说明非常轻描淡写。该帖子底部有许多 cmets 来自无法实施解决方案的人,没有建议明显的工作解决方案。我花了很多时间寻找解决方案,但我发现的只是人们要求解决方案,但没有收到任何有效的答案。

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

【问题讨论】:

    标签: oauth openid-connect access-token blazor-server-side refresh-token


    【解决方案1】:

    此方法结合了 Microsoft 关于如何将令牌传递给 Blazor 服务器应用程序 (here) 的建议,以及为所有用户在 Singleton 服务中的服务器端存储令牌(灵感来自 Dominick Baier 在GitHub here)。

    总之,我们的User 的sub 声明被捕获在_Host.cshtml 文件中,并以类似于Microsoft 捕获令牌的方式传递到App 组件在他们上面的示例代码中,然后将其保存到Scoped 服务中,我们称之为UserSubProvider。我们不是在 _Host.cshtml 文件中捕获令牌并将它们存储在 Scoped 服务中,而是以与 Dominick Baier 的示例类似的方式使用 OnTokenValidated 事件,将令牌存储在 @ 987654332@ 服务持有所有Users 的令牌,我们将此服务称为ServerSideTokenStore。

    当我们使用 HttpClient 调用 API 并且它需要 access_token(或 refresh_token)时,它会从 UserSubProvider 检索 User 的子,使用它来调用 @987654340 @,它返回包含令牌的 UserTokenProvider(类似于 Microsoft 的 TokenProvider)。如果HttpClient 需要刷新令牌,那么它会填充UserTokenProvider 并通过调用ServerSideTokenStore.SetTokensAsync() 来保存它。

    我们遇到的另一个问题是,如果在应用程序重新启动时打开了一个单独的 Web 浏览器实例(因此会丢失 ServerSideTokenStore 中保存的数据),用户仍将使用 cookie 进行身份验证,但我们丢失了access_token 和 refresh_token。如果应用程序重新启动,这可能在生产中发生,但在开发环境中发生的频率要高得多。如果我们无法获得合适的access_token,我们会通过处理OnValidatePrincipal 并调用RejectPrincipal() 来解决此问题。这会强制往返于 IdentityServer,它提供了一个新的 access_token 和 refresh_token。这个方法来自this stack overflow thread。

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

    UserSubProvider

        public class UserSubProvider
        {
            public string Sub { get; set; }
        }
    

    用户令牌提供者

        public class UserTokenProvider
        {
            public string AccessToken { get; set; }
            public string RefreshToken { get; set; }
            public DateTimeOffset Expiration { get; set; }
        }
    

    ServerSideTokenStore

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

    _Host.cshtml

        @using Microsoft.AspNetCore.Authentication
        
        // ...
        
        @{
            var userSub = HttpContext.User.Identity.Name;
        }
        
        <component type="typeof(App)" param-UserSub="userSub" render-mode="ServerPrerendered" />
    

    App.razor

        @inject IUserSubProvider UserSubProvider
        
        // ...
        
        @code {
            [Parameter]
            Public string UserSub { get; set; }
        
            protected override Task OnInitializedAsync()
            {
                UserSubProvider.Sub = UserSub;
                return base.OnInitializedAsync();
            }
        }
    

    Startup.cs ConfigureServices(或使用 .NET 6 / 其他的等效位置)

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

    【讨论】:

      猜你喜欢
      • 2022-01-28
      • 2016-03-22
      • 2016-03-13
      • 2014-08-01
      • 2020-06-02
      • 1970-01-01
      • 2021-01-29
      • 1970-01-01
      • 2020-08-14
      相关资源
      最近更新 更多