请参阅下面的代码,了解解决此问题的代码
我正在尝试找到最佳和最有效的方法来处理在 Core 2.1 中已过期 ASP.NET 刷新令牌。
让我再解释一下。
我正在使用 OAUTH2 和 OIDC 来请求授权代码授权流(或使用 OIDC 的混合流(。此流/授权类型使我可以访问访问令牌和刷新令牌(也是授权代码,但这不是针对此问题的(。
访问令牌和刷新令牌由 ASP.NET 核心存储,可以分别使用HttpContext.GetTokenAsync("access_token");
和HttpContext.GetTokenAsync("refresh_token");
进行检索。
我可以毫无问题地刷新access_token
。当refresh_token
过期、撤销或以某种方式无效时,问题就会发挥作用。
正确的流程是让用户登录并再次返回整个身份验证流程。然后,应用程序将返回一组新的令牌。
我的问题是我如何以最好和最正确的方法实现这一目标。我决定编写一个自定义中间件,如果access_token
已过期,则尝试续订该中间件。然后,中间件将新令牌设置到 HttpContext 的AuthenticationProperties
中,以便以后管道中的任何调用都可以使用它。
如果刷新令牌因任何原因失败,我需要再次调用 ChallengeAsync。我从中间件调用ChallengeAsync。
这就是我遇到一些有趣行为的地方。但是,大多数情况下,这有效,有时我会收到 500 个错误,但没有关于失败的有用信息。似乎中间件在尝试从中间件调用 ChallengeAsync 时遇到了问题,也许另一个中间件也在尝试访问上下文。
我不太确定发生了什么。我不太确定这是否是放置此逻辑的正确位置。也许我不应该在中间件中使用它,也许在其他地方。也许Polly for the HttpClient是最好的地方。
我对任何想法都持开放态度。
感谢您提供的任何帮助。
对我有用的代码解决方案
感谢 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;
}
};
访问令牌和刷新令牌由 ASP.NET 核心存储
我认为重要的是要注意令牌存储在向应用程序标识用户的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
响应,身份验证系统将捕获该响应并将其转换为质询,用户将被重定向回身份提供程序。
我有一些代码显示了如何在 GitHub 上的mderriey/TokenRenewal
存储库中工作。虽然意图不同,但它显示了如何使用这些事件的机制。
我创建了一个具有一些额外好处的替代实现:
- 兼容 ASP.NET 酷睿v3.1
- 重用已传递给
AddOpenIdConnect
方法的OpenID 配置选项。这使得客户端配置更容易一些。 - 使用"开放 ID 连接"发现文档确定令牌终结点。您可以选择缓存配置以节省与身份服务器的额外往返。
- 在身份验证调用(异步操作(期间不会阻止线程,从而提高可伸缩性。
这是更新的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;
}
}
}
}