处理 ASP.NET 核心中的过期刷新令牌

Handling Expired Refresh Tokens in ASP.NET Core

请参阅下面的解决此问题的代码

我正在尝试找到处理在 ASP.NET Core 2.1 中过期的刷新令牌的最佳和最有效的方法。

让我再解释一下。

我正在使用 OAUTH2 和 OIDC 请求授权码授权流程(或与 OIDC 的混合流程)。此 flow/grant 类型使我可以访问 AccessToken 和 RefreshToken(还有授权码,但这不适用于此问题)。

访问令牌和刷新令牌由ASP.NET核心存储,可以分别使用HttpContext.GetTokenAsync("access_token");HttpContext.GetTokenAsync("refresh_token");检索。

我可以毫无问题地刷新 access_token。当 refresh_token 过期、撤销或以某种方式无效时,问题就会出现。

正确的流程是让用户登录并再次返回整个身份验证流程。然后应用程序会返回一组新的令牌。

我的问题是如何以最佳和最正确的方法实现这一点。我决定编写一个自定义中间件,尝试在 access_token 过期时更新它。然后,中间件将新令牌设置到 HttpContext 的 AuthenticationProperties 中,以便以后在管道中的任何调用都可以使用它。

如果由于任何原因刷新令牌失败,我需要再次调用 ChallengeAsync。我正在从中间件调用 ChallengeAsync。

这是我 运行 一些有趣行为的地方。大多数情况下这是有效的,但是,有时我会收到 500 条错误,但没有关于失败原因的有用信息。似乎中间件在尝试从中间件调用 ChallengeAsync 时遇到问题,也许另一个中间件也在尝试访问上下文。

我不太确定发生了什么。我不太确定这是否是放置此逻辑的正确位置。也许我不应该在中间件中使用它,也许在其他地方。也许 HttpClient 的 Polly 是最好的地方。

我愿意接受任何想法。

感谢您提供的任何帮助。

对我有用的代码解决方案


感谢 Mickaël Derriey 的帮助和指导(请务必在该解决方案的上下文中查看他的回答以获取更多信息)。这是我想出的解决方案,它对我有用:

options.Events = new CookieAuthenticationEvents
{
    OnValidatePrincipal = context =>
    {
        //check to see if user is authenticated first
        if (context.Principal.Identity.IsAuthenticated)
        {
            //get the user's tokens
            var tokens = context.Properties.GetTokens();
            var refreshToken = tokens.FirstOrDefault(t => t.Name == "refresh_token");
            var accessToken = tokens.FirstOrDefault(t => t.Name == "access_token");
            var exp = tokens.FirstOrDefault(t => t.Name == "expires_at");
            var expires = DateTime.Parse(exp.Value);
            //check to see if the token has expired
            if (expires < DateTime.Now)
            {
                //token is expired, let's attempt to renew
                var tokenEndpoint = "https://token.endpoint.server";
                var tokenClient = new TokenClient(tokenEndpoint, clientId, clientSecret);
                var tokenResponse = tokenClient.RequestRefreshTokenAsync(refreshToken.Value).Result;
                //check for error while renewing - any error will trigger a new login.
                if (tokenResponse.IsError)
                {
                    //reject Principal
                    context.RejectPrincipal();
                    return Task.CompletedTask;
                }
                //set new token values
                refreshToken.Value = tokenResponse.RefreshToken;
                accessToken.Value = tokenResponse.AccessToken;
                //set new expiration date
                var newExpires = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn);
                exp.Value = newExpires.ToString("o", CultureInfo.InvariantCulture);
                //set tokens in auth properties 
                context.Properties.StoreTokens(tokens);
                //trigger context to renew cookie with new token values
                context.ShouldRenew = true;
                return Task.CompletedTask;
            }
        }
        return Task.CompletedTask;
    }
};

The access token and refresh token are stored by ASP.NET core

我认为重要的是要注意令牌存储在 cookie 中,用于识别您的应用程序的用户。

现在这是我的观点,但我认为自定义中间件不是刷新令牌的正确位置。 这样做的原因是,如果您成功刷新令牌,则需要替换现有令牌并将其以新 cookie 的形式发送回浏览器,以替换现有令牌。

这就是为什么我认为最相关的地方是 ASP.NET Core 读取 cookie 时。每个身份验证机制都会公开几个事件;对于 cookie,有一个名为 ValidatePrincipal 的方法,在读取 cookie 并成功从中反序列化身份后,每个请求都会调用它。

public void ConfigureServices(ServiceCollection services)
{
    services
        .AddAuthentication()
        .AddCookies(new CookieAuthenticationOptions
        {
            Events = new CookieAuthenticationEvents
            {
                OnValidatePrincipal = context =>
                {
                    // context.Principal gives you access to the logged-in user
                    // context.Properties.GetTokens() gives you access to all the tokens

                    return Task.CompletedTask;
                }
            }
        });
}

这种方法的好处是,如果您设法更新令牌并将其存储在 AuthenticationProperties 中,context 类型的 CookieValidatePrincipalContext 变量有一个属性 叫 ShouldRenew。将 属性 设置为 true 指示中间件发出新的 cookie。

如果您无法更新令牌或者您发现刷新令牌已过期并且您想阻止用户继续操作,同样的 class 有一个 RejectPrincipal 方法指示将请求视为匿名的 cookie 中间件。

这样做的好处是,如果您的 MVC 应用程序只允许经过身份验证的用户访问它,MVC 将负责发出 HTTP 401 响应,身份验证系统将捕获该响应并将其转化为挑战和用户将被重定向回身份提供者。

我在 GitHub 上的 mderriey/TokenRenewal 存储库中有一些代码可以说明这将如何工作。虽然意图不同,但它展示了如何使用这些事件的机制。

我创建了一个具有一些额外好处的替代实现:

  • 兼容 ASP.NET Core v3.1
  • 重新使用已传递给 AddOpenIdConnect 方法的 OpenID 配置选项。这使得客户端配置更容易一些。
  • 使用 Open ID Connect 发现文档来确定令牌端点。您可以选择缓存配置以节省与 Identity Server 的额外往返。
  • 在身份验证调用(异步操作)期间不阻塞线程,提高了可伸缩性。

这是更新后的 OnValidatePrincipal 方法:

private async Task OnValidatePrincipal(CookieValidatePrincipalContext context)
{
    const string accessTokenName = "access_token";
    const string refreshTokenName = "refresh_token";
    const string expirationTokenName = "expires_at";

    if (context.Principal.Identity.IsAuthenticated)
    {
        var exp = context.Properties.GetTokenValue(expirationTokenName);
        if (exp != null)
        {
            var expires = DateTime.Parse(exp, CultureInfo.InvariantCulture).ToUniversalTime();
            if (expires < DateTime.UtcNow)
            {
                // If we don't have the refresh token, then check if this client has set the
                // "AllowOfflineAccess" property set in Identity Server and if we have requested
                // the "OpenIdConnectScope.OfflineAccess" scope when requesting an access token.
                var refreshToken = context.Properties.GetTokenValue(refreshTokenName);
                if (refreshToken == null)
                {
                    context.RejectPrincipal();
                    return;
                }

                var cancellationToken = context.HttpContext.RequestAborted;

                // Obtain the OpenIdConnect options that have been registered with the
                // "AddOpenIdConnect" call. Make sure we get the same scheme that has
                // been passed to the "AddOpenIdConnect" call.
                //
                // TODO: Cache the token client options
                // The OpenId Connect configuration will not change, unless there has
                // been a change to the client's settings. In that case, it is a good
                // idea not to refresh and make sure the user does re-authenticate.
                var serviceProvider = context.HttpContext.RequestServices;
                var openIdConnectOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get(OpenIdConnectScheme);
                var configuration = openIdConnectOptions.Configuration ?? await openIdConnectOptions.ConfigurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
                
                // Set the proper token client options
                var tokenClientOptions = new TokenClientOptions
                {
                    Address = configuration.TokenEndpoint,
                    ClientId = openIdConnectOptions.ClientId,
                    ClientSecret = openIdConnectOptions.ClientSecret
                };
                
                var httpClientFactory = serviceProvider.GetService<IHttpClientFactory>();
                using var httpClient = httpClientFactory.CreateClient();

                var tokenClient = new TokenClient(httpClient, tokenClientOptions);
                var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancellationToken: cancellationToken).ConfigureAwait(false);
                if (tokenResponse.IsError)
                {
                    context.RejectPrincipal();
                    return;
                }

                // Update the tokens
                var expirationValue = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o", CultureInfo.InvariantCulture);
                context.Properties.StoreTokens(new []
                {
                    new AuthenticationToken { Name = refreshTokenName, Value = tokenResponse.RefreshToken },
                    new AuthenticationToken { Name = accessTokenName, Value = tokenResponse.AccessToken },
                    new AuthenticationToken { Name = expirationTokenName, Value = expirationValue }
                });

                // Update the cookie with the new tokens
                context.ShouldRenew = true;
            }
        }
    }
}