刷新 IdentityServer4 客户端中的访问令牌
Posted
技术标签:
【中文标题】刷新 IdentityServer4 客户端中的访问令牌【英文标题】:Refreshing access tokens in IdentityServer4 clients 【发布时间】:2017-05-01 07:12:38 【问题描述】:我想知道如何使用混合流,它是使用 ASP.NET Core MVC 构建的。
如果我正确理解了整个概念,则客户端首先需要具有“offline_access”范围才能使用刷新令牌,这是启用短期访问令牌和撤销刷新令牌以防止任何新令牌的最佳实践将颁发给客户端的访问令牌。
我成功获取了访问令牌和刷新令牌,但是在MVC客户端中访问令牌的实际更新过程应该如何处理呢?
OpenId Connect (OIDC) 中间件可以自动处理吗?或者我应该通过基本上检查访问令牌是否已过期或即将过期(即将到来的 30 秒)来检查访问令牌的过期时间,然后通过使用刷新令牌调用令牌端点来刷新访问令牌?
是否建议在我的 Controller 操作方法中使用 IdentityModel2 库 TokenClient
扩展方法 RequestRefreshTokenAsync
来调用令牌端点?
我已经看到在 OIDC 中间件事件中请求访问令牌并使用响应存储包含过期日期时间的声明的代码。问题是我的 OIDC 不知何故已经自动请求访问令牌,因此在收到第一个访问令牌后直接请求新的访问令牌感觉不好。
没有访问令牌刷新逻辑的控制器操作方法示例:
public async Task<IActionResult> GetInvoices()
var token = await HttpContext.Authentication.GetTokenAsync("access_token");
var client = new HttpClient();
client.SetBearerToken(token);
var response = await client.GetStringAsync("http://localhost:5001/api/getInvoices");
ViewBag.Json = JArray.Parse(response).ToString();
return View();
【问题讨论】:
【参考方案1】:OIDC 中间件不会为您处理这些问题。它在检测到HTTP 401
响应时执行,然后将用户重定向到 IdentityServer 登录页面。在重定向到您的 MVC 应用程序后,它会将声明转换为 ClaimsIdentity
并将其传递给 Cookies 中间件,该中间件会将其具体化为会话 cookie。
只要 cookie 仍然有效,其他所有请求都不会涉及 OIDC 中间件。
所以你必须自己处理这件事。您要考虑的另一件事是,每当您要刷新访问令牌时,都必须更新现有的令牌,以免丢失它。如果您不这样做,会话 cookie 将始终包含相同的令牌 - 原始令牌 - 并且您每次都会刷新它。
我找到的一个解决方案是将其挂接到 Cookies 中间件中。 这是一般流程:
在每个请求上,使用 Cookies 中间件事件来检查访问令牌 如果接近到期时间,请申请新的 替换ClaimsIdentity
中的新访问和刷新令牌
指示 Cookies 中间件更新会话 cookie,使其包含新令牌
我喜欢这种方法的一点是,在您的 MVC 代码中,几乎可以保证您始终拥有一个有效的访问令牌,除非引用该令牌连续多次失败。
我不喜欢它与 MVC 密切相关 - 更具体地说是 Cookies 中间件 - 所以它不是真正可移植的。
大家可以看看我整理的this GitHub repo。它确实使用了IdentityModel
,因为它会处理所有事情并隐藏您必须对 IdentityServer 进行的大部分 HTTP 调用的复杂性。
【讨论】:
很好的解决方案,关于此代码是否支持特定用户的多个并发 HTTP 请求有什么想法吗?如果不在客户端设置中使用 RefreshTokenUsage.ReUse,是否会出现访问令牌更新和刷新令牌无效的竞赛?还有关于javascript代码中的Ajax调用如何处理访问令牌更新的任何想法? 谢谢。对于并发调用,我建议使用自动处理令牌更新和过期的解决方案,例如this simple but very handy .NET wrapper。对于 JavaScript,我建议使用 Brock Allen 创建的oidc-client-js
。然后,通过 HTTP 调用在您的应用中集成将取决于您使用的框架。
非常有趣的谢谢!我在这里找到了开发者写的博客文章strathweb.com/2016/11/…
现在我需要弄清楚代码的放置位置,我正在考虑某种可以在任何需要的地方注入的服务类。问题是如何连接 IOC 容器中的类以便为每个用户分离每个实例?
如果我必须这样做,我会将令牌存储在安全会话 cookie 中。然后你用现有的令牌喂异步惰性。如果仍然可以使用,那很好。如果没有,包装器可以更新它并替换 cookie 中的令牌(意味着向浏览器发出一个新的 cookie)。至于 A IPC 注册,每个 HTTP 请求的生命周期应该可以正常工作。【参考方案2】:
我在 ASP.NET Core 2.0 中创建了一个基于操作过滤器和 OIDC 中间件的解决方案。
AJAX 请求也将通过操作过滤器,因此更新访问令牌/刷新令牌。
https://gist.github.com/devJ0n/43c6888161169e09fec542d2dc12af09
【讨论】:
【参考方案3】:我发现了两种可能的解决方案,它们都是相同的,但在 OIDC 中间件中发生的时间不同。在事件中,我提取访问令牌过期时间值并将其存储为声明,稍后可用于检查是否可以使用当前访问令牌调用 Web API,或者我是否应该使用刷新请求新的访问令牌令牌。
如果有人可以就哪些事件更适合使用提供任何意见,我将不胜感激。
var oidcOptions = new OpenIdConnectOptions
AuthenticationScheme = appSettings.OpenIdConnect.AuthenticationScheme,
SignInScheme = appSettings.OpenIdConnect.SignInScheme,
Authority = appSettings.OpenIdConnect.Authority,
RequireHttpsMetadata = _hostingEnvironment.IsDevelopment() ? false : true,
PostLogoutRedirectUri = appSettings.OpenIdConnect.PostLogoutRedirectUri,
ClientId = appSettings.OpenIdConnect.ClientId,
ClientSecret = appSettings.OpenIdConnect.ClientSecret,
ResponseType = appSettings.OpenIdConnect.ResponseType,
UseTokenLifetime = appSettings.OpenIdConnect.UseTokenLifetime,
SaveTokens = appSettings.OpenIdConnect.SaveTokens,
GetClaimsFromUserInfoEndpoint = appSettings.OpenIdConnect.GetClaimsFromUserInfoEndpoint,
Events = new OpenIdConnectEvents
OnTicketReceived = TicketReceived,
OnUserInformationReceived = UserInformationReceived
,
TokenValidationParameters = new TokenValidationParameters
NameClaimType = appSettings.OpenIdConnect.NameClaimType,
RoleClaimType = appSettings.OpenIdConnect.RoleClaimType
;
oidcOptions.Scope.Clear();
foreach (var scope in appSettings.OpenIdConnect.Scopes)
oidcOptions.Scope.Add(scope);
app.UseOpenIdConnectAuthentication(oidcOptions);
这里有一些我可以选择的事件示例:
public async Task TicketReceived(TicketReceivedContext trc)
await Task.Run(() =>
Debug.WriteLine("TicketReceived");
//Alternatives to get the expires_at value
//var expiresAt1 = trc.Ticket.Properties.GetTokens().SingleOrDefault(t => t.Name == "expires_at").Value;
//var expiresAt2 = trc.Ticket.Properties.GetTokenValue("expires_at");
//var expiresAt3 = trc.Ticket.Properties.Items[".Token.expires_at"];
//Outputs:
//expiresAt1 = "2016-12-19T11:58:24.0006542+00:00"
//expiresAt2 = "2016-12-19T11:58:24.0006542+00:00"
//expiresAt3 = "2016-12-19T11:58:24.0006542+00:00"
//Remove OIDC protocol claims ("iss","aud","exp","iat","auth_time","nonce","acr","amr","azp","nbf","c_hash","sid","idp")
ClaimsPrincipal p = TransformClaims(trc.Ticket.Principal);
//var identity = p.Identity as ClaimsIdentity;
// keep track of access token expiration
//identity.AddClaim(new Claim("expires_at1", expiresAt1.ToString()));
//identity.AddClaim(new Claim("expires_at2", expiresAt2.ToString()));
//identity.AddClaim(new Claim("expires_at3", expiresAt3.ToString()));
//Todo: Check if it's OK to replace principal instead of the ticket, currently I can't make it work when replacing the whole ticket.
//trc.Ticket = new AuthenticationTicket(p, trc.Ticket.Properties, trc.Ticket.AuthenticationScheme);
trc.Principal = p;
);
我也有 UserInformationReceived 事件,我不确定是否应该使用它来代替 TicketReceived 事件。
public async Task UserInformationReceived(UserInformationReceivedContext uirc)
await Task.Run(() =>
Debug.WriteLine("UserInformationReceived");
////Alternatives to get the expires_at value
//var expiresAt4 = uirc.Ticket.Properties.GetTokens().SingleOrDefault(t => t.Name == "expires_at").Value;
//var expiresAt5 = uirc.Ticket.Properties.GetTokenValue("expires_at");
//var expiresAt6 = uirc.Ticket.Properties.Items[".Token.expires_at"];
//var expiresIn1 = uirc.ProtocolMessage.ExpiresIn;
//Outputs:
//expiresAt4 = "2016-12-19T11:58:24.0006542+00:00"
//expiresAt5 = "2016-12-19T11:58:24.0006542+00:00"
//expiresAt6 = "2016-12-19T11:58:24.0006542+00:00"
//expiresIn = "60" <-- The 60 seconds test interval for the access token lifetime is configured in the IdentityServer client configuration settings
var identity = uirc.Ticket.Principal.Identity as ClaimsIdentity;
//Keep track of access token expiration
//Add a claim with information about when the access token is expired, it's possible that I instead should use expiresAt4, expiresAt5 or expiresAt6
//instead of manually calculating the expire time.
//This claim will later be checked before calling Web API's and if needed a new access token will be requested via the IdentityModel2 library.
//identity.AddClaim(new Claim("expires_at4", expiresAt4.ToString()));
//identity.AddClaim(new Claim("expires_at5", expiresAt5.ToString()));
//identity.AddClaim(new Claim("expires_at6", expiresAt6.ToString()));
//identity.AddClaim(new Claim("expires_in1", expiresIn1.ToString()));
identity.AddClaim(new Claim("expires_in", DateTime.Now.AddSeconds(Convert.ToDouble(uirc.ProtocolMessage.ExpiresIn)).ToLocalTime().ToString()));
//identity.AddClaim(new Claim("expires_in3", DateTime.Now.AddSeconds(Convert.ToDouble(uirc.ProtocolMessage.ExpiresIn)).ToString()));
//The following is not needed when to OIDC middleware CookieAuthenticationOptions.SaveTokens = true
//identity.AddClaim(new Claim("access_token", uirc.ProtocolMessage.AccessToken));
//identity.Claims.Append(new Claim("refresh_token", uirc.ProtocolMessage.RefreshToken));
//identity.AddClaim(new Claim("id_token", uirc.ProtocolMessage.IdToken));
);
【讨论】:
恐怕这行不通。原因是当检测到未经授权的请求时,OIDC 中间件只会执行一次。用户登录后,身份验证信息将存储在 cookie 中(由OpenIdConnectOptions
的SignInScheme
属性驱动)。这意味着 OIDC 中间件事件将在用户登录 IdP 之后执行一次。此时访问令牌永远不会过期。我建议你在 OIDC 和 cookie 中间件事件中都设置断点,看看事情是如何工作的。
感谢反馈,请参阅我上面的其他答案,它确实有效。以上是关于刷新 IdentityServer4 客户端中的访问令牌的主要内容,如果未能解决你的问题,请参考以下文章
没有 cookie 的 IdentityServer4 外部身份验证
IdentityServer4 - 刷新令牌混合流程 - Cookie 和存储