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

21
请查看下面的代码,解决了这个问题。
我正在尝试找到处理在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已过期,则尝试更新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令牌授权我的API时,它返回“未经授权”。有任何想法吗? - NewBieDevRo
Id_tokens不会进入您的API。access_tokens会进入您的API。我建议您在处理刷新令牌之前先测试并使您的API与access_tokens配合工作。刷新令牌只会发出新的access_token。 - bugnuker
请查看以下链接 https://leastprivilege.com/2019/01/14/automatic-oauth-2-0-token-management-in-asp-net-core/ 和 https://github.com/IdentityServer/IdentityServer4/tree/master/samples/Clients/src/MvcHybridAutomaticRefresh。 - Suketu Bhuta
如果您在尝试解决Blazor Server中的其他相关问题时遇到了这个线程,那么这可能会有所帮助,它包含了一些此线程中的信息。https://dev59.com/n8Tsa4cB1Zd3GeqPFcBY - Rob
2个回答

19

访问令牌和刷新令牌由ASP.NET Core存储在cookie中。

我认为需要注意的是,这些令牌存储在识别用户应用程序的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 中,类型为 CookieValidatePrincipalContext 的变量 context 将具有名为 ShouldRenew 的属性。将该属性设置为 true 将指示中间件发出新 cookie。

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

这种做法的好处在于,如果您的 MVC 应用程序只允许经过身份验证的用户访问它,MVC 将负责发出 HTTP 401 响应,认证系统会捕获并转换为 Challenge,然后将用户重定向回标识提供程序。

我在 GitHub 上的 mderriey/TokenRenewal 存储库中有一些代码,展示了如何使用这些事件的机制 (尽管它们的意图不同)。


这似乎运行得非常好,谢谢!我会发布我的完整解决方案来更新问题。再次感谢! - bugnuker
很高兴它对你有用。期待看到你将选择哪种解决方案。祝你好运! - Mickaël Derriey
现在我看了你在 GitHub 上的解决方案,你的代码比我的干净一点:D - bugnuker
1
这段代码从未被执行,因为一旦发送请求,令牌就会从浏览器中删除。或者我错过了什么?当Cookie过期时,它只是从浏览器中删除,并且由于从一开始就不存在,因此无法刷新。 - Deukalion
@Deukalion,我不理解你的评论。你说的“...一旦发送请求,令牌就会从浏览器中删除。”是什么意思?浏览器不会在发送请求后删除任何cookie。浏览器只有在达到cookie的过期日期或使用Set-Cookie响应头删除cookie时才会删除cookie。Cookie的过期与access_token过期或refresh_token过期和refresh_token撤销完全无关。 - Dai

8
我已经创建了一种替代实现,具有一些附加好处:
  • 与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;
            }
        }
    }
}


2
Ramon,感谢你在代码中使用以下内容:var openIdConnectOptions = serviceProvider.GetRequiredService<IOptionsSnapshot<OpenIdConnectOptions>>().Get(OpenIdConnectScheme); OpenIdConnectScheme是什么? - Ajt
我有两个问题关于这个例子。你在哪里调用这个方法来连接你的流程? private async Task OnValidatePrincipal(CookieValidatePrincipalContext context) to wire 那么这个“TokenClient”是做什么的?它可能来自IdentityServer吗?我找不到它的代码。
  • 我如何创建这个“TokenClient”,而不使用IdentityServer?
var tokenClient = new TokenClient(httpClient, tokenClientOptions); var tokenResponse = await tokenClient.RequestRefreshTokenAsync(refreshToken, cancellationToken: cancellationToken).ConfigureAwait(false);
- Lord02
能够像这样注册自定义处理程序: 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 - Lord02

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接