【问题标题】:Handling Expired Refresh Tokens in ASP.NET Core在 ASP.NET Core 中处理过期的刷新令牌
【发布时间】:2019-02-10 00:27:28
【问题描述】:

解决此问题的代码见下文

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

让我再解释一下。

我正在使用 OAUTH2 和 OIDC 请求授权码授权流(或带有 OIDC 的混合流)。这种流/授权类型使我可以访问 AccessToken 和 RefreshToken(授权码也是如此,但这不适用于这个问题)。

访问令牌和刷新令牌由 ASP.NET Core 存储,可以分别使用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;
    }
};

【问题讨论】:

  • 我一直在关注您的代码,因为我有类似的要求,我希望 ID 令牌能够访问我的 api。但是当我尝试使用上面的代码创建新的 id 令牌时,它会创建令牌。但是当我使用 id_token 授权我的 API 时,它返回“未授权”。有什么想法吗?
  • Id_tokens 不会转到您的 API。 access_tokens 转到您的 API。我建议您先测试并让您的 API 与 access_tokens 一起工作,然后再让刷新令牌工作。刷新令牌只会发出一个新的 access_token

标签: asp.net-core oauth asp.net-core-2.1 refresh-token openid-connect


【解决方案1】:

访问令牌和刷新令牌由 ASP.NET 核心存储

我认为重要的是要注意令牌存储在向您的应用程序识别用户的 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。

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

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

我有一些代码可以在 GitHub 上的 mderriey/TokenRenewal 存储库中显示如何解决这个问题。虽然意图不同,但它显示了如何使用这些事件的机制。

【讨论】:

  • 这似乎工作得很好,谢谢!我将发布我的完整解决方案以更新问题。再次感谢!
  • 很高兴它对你有用。渴望看到您将采用的解决方案。祝你好运!
  • 现在我在 GitHub 上查看了您的解决方案,您的代码比我的更简洁:D
  • 此代码永远不会执行,因为一旦发送请求,令牌就会从浏览器中删除。或者我错过了什么?当 Cookie 过期时,它只是从浏览器中删除,并且由于它从一开始就不存在而无法刷新。
  • @Deukalion 我不明白你的评论。你是什​​么意思“......一旦发送请求,令牌就会从浏览器中删除。”?发送请求后,浏览器不会删除任何 cookie。浏览器只会删除 cookie 的过期日期或使用 Set-Cookie 响应标头删除 cookie)。 Cookie 过期与access_token 过期或refresh_token 过期和refresh_token 撤销完全分开。
【解决方案2】:

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

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

【讨论】:

  • Ramon 感谢您的代码 var openIdConnectOptions = serviceProvider.GetRequiredService>().Get(OpenIdConnectScheme);什么是 OpenIdConnectScheme?
  • 我有两个关于这个例子的问题。您在哪里将此方法称为您的流程? private async Task OnValidatePrincipal(CookieValidatePrincipalContext context) to wire 这个“TokenClient”有什么作用?它可能来自 IdentityServer?我找不到它的代码。 - 我怎样才能创建这个“TokenClient”没有使用IdentityServer? var tokenClient = new TokenClient(httpClient, tokenClientOptions); var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancelToken: cancelToken).ConfigureAwait(false);
  • 能够像这样注册一个自定义处理程序:startup.cs: .AddCookie(options => { ... // 这将处理 OpenId 协议的新 refresh_tokens 的发布:options。 EventsType = typeof(CustomCookieAuthEvents); 自定义类: public class CustomCookieAuthEvents : CookieAuthenticationEvents { public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) 现在,我只需要 TokenClient ...你有这个实现吗?? tokenClient.RequestRefreshTokenAsync
猜你喜欢
  • 2019-09-05
  • 1970-01-01
  • 1970-01-01
  • 2014-09-18
  • 2019-01-24
  • 2019-03-23
  • 2018-03-30
  • 2020-07-06
  • 2014-08-01
相关资源
最近更新 更多