刷新浏览器时,如何处理Blazor Server中的用户OIDC令牌,而Cookie的令牌是无效的?

发布于 2025-02-13 03:55:31 字数 1304 浏览 1 评论 0原文

Microsoft建议不要在Blazor Server中使用httpcontext在这里)。要解决如何将用户令牌传递到Blazor Server应用程序的问题,Microsoft建议将令牌存储在scoped服务中(在这里)。乔恩·麦奎尔(Jon McGuire)的博客提出了一种类似的方法,该方法将令牌存储在cache中(在这里)。

只要用户留在同一Blazor服务器连接中,Microsoft的方法就可以正常工作。 但是,如果access_token被刷新,然后用户通过按F5或将URL粘贴到地址栏来重新加载页面,然后尝试试图检索令牌来自饼干。到这个时候, access_tokencookie中的refresh_token不再有效。乔恩·麦奎尔(Jon McGuire 。他给出了可能的解决方案的提示,但非常适合实施说明。该帖子的底部有很多评论,无法实施解决方案,没有明显的工作解决方案。我花了很多时间寻找解决方案,我发现的只是有人要求一个解决方案而没有收到任何有效的答案。

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

Microsoft recommend against using HttpContext in Blazor Server (here). To work around the issue of how to pass user tokens to a Blazor Server app, Microsoft recommend storing the tokens in a Scoped service (here). Jon McGuire’s blog suggests a similar approach that stores the tokens in Cache (here).

Microsoft’s approach above works just fine as long as the user stays within the same Blazor Server connection. However if the access_token is refreshed and the user then reloads the page either by pressing F5 or by pasting a URL into the address bar, then an attempt is made to retrieve the tokens from the cookie. By this time, the access_token and refresh_token in the cookie are no longer valid. Jon McGuire mentions this problem at the end of his blog post and refers to it as Stale Cookies (here). He gives hints about a possible solution, but is very light on implementation instructions. There are many comments at the bottom of that post from people unable to implement a solution, with no apparent working solution suggested. I spent a lot of time searching for a solution and all I found were people asking for one and not receiving any answers that worked.

Having found a solution that seems to work well, and also seems fairly principled, I thought it might be worth sharing my solution here. I would welcome any constructive criticism or suggestions for any significant improvements.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

套路撩心 2025-02-20 03:55:31

编辑20220715:在对Dominic Baier的方法进行了一些反馈之后,我们删除了scoped userSubProvider服务,有利于使用authEthationalticationStateProvider。这简化了我们的方法。我已经编辑了以下答案,以反映这一更改。


这种方法结合了Microsoft关于如何将令牌传递给Blazor Server应用程序的建议(在此),服务器端存储的存储singleton中的令牌为所有用户提供服务(受多米尼克·拜尔(Dominick Baier)的Blazor Server示例项目的启发在这里)。

我们没有在 _host.cshtml 文件中捕获代币并将其存储在scoped服务中(例如Microsoft在示例中执行),我们使用ontokenvalidated 事件的方式与Dominick Baier的样本类似,将令牌存储在singleton服务中,该服务可容纳所有人用户,我们调用此服务serversIdEtokenstore

当我们使用httpclient调用API时,它需要access_token(或refresh_token),然后将其检索user 从注射authenticationStateProvider中使用它来调用serversIdeTokenStore.getTokenSasync(),返回usertokenProvider(类似于Microsoft的tokenProvider)包含代币。如果httpclient需要刷新令牌,则它将填充userTokenProvider,并通过调用serversideTekenStore.setTokensSync()来保存它。

我们遇到的另一个问题是,如果应用程序重新启动时,Web浏览器的单独实例是打开的(因此,丢失了在serversideTokenstore中持有的数据),则使用cookie进行身份验证,但是我们已经通过丢失access_tokenrefresh_token。如果重新启动应用程序,则可能在生产中发生,但是在开发环境中发生的频率更高。如果我们无法获得合适的access_token,我们通过处理onvalidatePrincipal onvalidatePrincipal 来解决此问题。这迫使往返身份服务器,提供新的access_tokenrefresh_token。这种方法来自此堆栈溢出线程

某些代码不包括一些标准错误处理,日志记录等。

为了清晰/焦点,遵循的 代码> sub 从注射AuthenticationStateProvider中要求。它使用userub字符串时,呼叫serversIdeTokenStore.getToKensAsync()serversIdeTokenStore.setTokensAsync()

    var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
    string userSub = state.User.FindFirst("sub")?.Value;

usertokenProvider

    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;
        }
    }

startup.cs configureservices(或使用.NET 6/whitch)

    public void ConfigureServices(IServiceCollection services)
    {
        // …
        services.AddAuthentication(…)
        .AddCookie(“Cookies”, options =>
        {
            // …
            options.Events.OnValidatePrincipal = async context =>
            {
                if (context.Principal.Identity.IsAuthenticated)
                {
                    // get user sub 
                    var 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(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);
            };
            // …
        });
        // …
    }

Edit 20220715: After some feedback on our approach from Dominic Baier we removed our Scoped UserSubProvider service in favour of using AuthenticationStateProvider instead. This has simplified our approach. I have edited the following answer to reflect this change.


This approach combines advice from Microsoft on how to pass tokens to a Blazor Server app (here), with server side storage of tokens in a Singleton service for all users (inspired by Dominick Baier’s Blazor Server sample project on GitHub here).

Instead of capturing the tokens in the _Host.cshtml file and storing them in a Scoped service (like Microsoft do in their example), we use the OnTokenValidated event in a similar way to Dominick Baier’s sample, storing the tokens in a Singleton service that holds tokens for all Users, we call this service ServerSideTokenStore.

When we use our HttpClient to call an API and it needs an access_token (or refresh_token), then it retrieves the User’s sub from an injected AuthenticationStateProvider, uses it to call ServerSideTokenStore.GetTokensAsync(), which returns a UserTokenProvider (similar to Microsoft’s TokenProvider) containing the tokens. If the HttpClient needs to refresh the tokens then it populates a UserTokenProvider and saves it by calling ServerSideTokenStore.SetTokensAsync().

Another issue we had was if a separate instance of the web browser is open while the app restarts (and therefore loses the data held in ServerSideTokenStore) the user would still be authenticated using the cookie, but we’ve lost the access_token and refresh_token. This could happen in production if the application is restarted, but happens a lot more frequently in a dev environment. We work around this by handling OnValidatePrincipal and calling RejectPrincipal() if we cannot get a suitable access_token. This forces a round trip to IdentityServer which provides a new access_token and refresh_token. This approach came from this stack overflow thread.

(For clarity/focus, some of the code that follows excludes some standard error handling, logging, etc.)

Getting the User sub claim from AuthenticationStateProvider

Our HttpClient gets the user's sub claim from an injected AuthenticationStateProvider. It uses the userSub string when calling ServerSideTokenStore.GetTokensAsync() and ServerSideTokenStore.SetTokensAsync().

    var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
    string userSub = state.User.FindFirst("sub")?.Value;

UserTokenProvider

    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;
        }
    }

Startup.cs ConfigureServices (or equivalent location if using .NET 6 / whatever)

    public void ConfigureServices(IServiceCollection services)
    {
        // …
        services.AddAuthentication(…)
        .AddCookie(“Cookies”, options =>
        {
            // …
            options.Events.OnValidatePrincipal = async context =>
            {
                if (context.Principal.Identity.IsAuthenticated)
                {
                    // get user sub 
                    var 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(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);
            };
            // …
        });
        // …
    }
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文