在ASP.NET Core中处理过期的刷新令牌

时间:2018-09-04 23:49:48

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

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

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

让我解释一下。

我正在使用OAUTH2和OIDC来请求授权码授予流(或与OIDC的混合流)。这种流/授予类型使我可以访问AccessToken和RefreshToken(也包括授权码,但这不是针对此问题)。

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

我可以刷新access_token,没有任何问题。当refresh_token过期,被撤销或以某种方式无效时,该问题就起作用了。

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

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

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

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

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

我愿意接受任何想法。

感谢您提供的任何帮助。

最适合我的代码解决方案


感谢Mickaël Derriey的帮助和指导(一定要查看他的答案以获取有关此解决方案的更多信息)这是我想出的解决方案,它对我有用: / p>

options.Events = new CookieAuthenticationEvents
{
    OnValidatePrincipal = context =>
    {
        //check to see if user is authenticated first
        if (context.Principal.Identity.IsAuthenticated)
        {
            //get the users 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;
    }
};

2 个答案:

答案 0 :(得分:9)

  

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

我认为必须注意,令牌存储在cookie中,该cookie标识应用程序的用户。

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

这就是为什么我认为最相关的地方是ASP.NET Core正在读取cookie的原因。每种身份验证机制都会公开多个事件。对于Cookie,有一个名为ValidatePrincipal的Cookie,它在读取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的{​​{1}}变量具有一个称为ShouldRenew。将该属性设置为CookieValidatePrincipalContext会指示中间件发出新的cookie。

如果您无法续签令牌,或者您发现刷新令牌已过期并且想要阻止用户继续前进,则同一类具有RejectPrincipal方法,该方法指示Cookie中间件处理请求好像是匿名的。

这样做的好处是,如果您的MVC应用仅允许经过身份验证的用户访问,则MVC将负责发出true响应,身份验证系统将捕获该响应并将其转换为“挑战”,而用户将被重定向回身份提供者。

我有一些代码说明了如何在GitHub上的mderriey/TokenRenewal存储库中进行处理。尽管目的不同,但它说明了如何使用这些事件的机制。

答案 1 :(得分:5)

我创建了一个替代实现,它具有一些其他优点:

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