在IdentityServer4客户端中刷新访问令牌

14

我想知道如何在使用ASP.NET Core MVC构建的IdentityServer4客户端中使用混合流来刷新访问令牌。如果我正确地理解了整个概念,客户端首先需要具有“offline_access”范围,以便能够使用刷新令牌,这是启用短期访问令牌和撤销刷新令牌的最佳实践,从而防止向客户端发出任何新的访问令牌。

我成功获取了访问令牌和刷新令牌,但是我应该如何处理MVC客户端中的实际更新过程? OpenId Connect(OIDC)中间件能否自动处理此问题? 还是说我应该在每次调用WEB Api时检查访问令牌的过期时间,基本上检查访问令牌是否已过期或即将过期(未来30秒),然后使用刷新令牌调用令牌端点刷新访问令牌?

建议在我的Controller操作方法中使用IdentityModel2库的TokenClient扩展方法RequestRefreshTokenAsync来调用令牌端点吗? 我看到一些代码在OIDC中间件事件中请求访问令牌,并使用响应存储一个包含到期日期时间的声明。问题在于我的OIDC已经自动请求了访问令牌,因此在收到第一个访问令牌后直接请求一个新的访问令牌并不好。

未包含访问令牌刷新逻辑的Controller操作方法示例:

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();
    }
3个回答

18
OIDC中间件不会为您处理这个问题。它会在检测到HTTP 401响应时执行,然后将用户重定向到IdentityServer登录页面。重定向到MVC应用程序后,它将声明转换为ClaimsIdentity,并将其传递给Cookies中间件,后者将其转化为会话cookie。
只要cookie仍然有效,每个其他请求都不涉及OIDC中间件。
所以你必须自己负责这个问题。另一件你需要考虑的事情是,每当你要刷新访问令牌时,你都必须更新现有的令牌,以便你不会失去它。如果你不这样做,会话cookie将始终包含相同的令牌-原始令牌-并且你将每次刷新它。
我发现的一个解决方案是将其挂钩到Cookies中间件中。 以下是一般流程:
- 在每个请求上,使用Cookies中间件事件检查访问令牌 - 如果接近到期时间,请请求一个新的 - 用新的访问和刷新令牌替换ClaimsIdentity - 指示Cookies中间件更新会话cookie,以包含新令牌
我喜欢这种方法的原因是,在您的MVC代码中,除非刷新令牌连续多次失败,否则您几乎可以保证始终拥有有效的访问令牌。
我不喜欢的是它与MVC紧密相连 - 更具体地说是Cookies中间件 - 所以它并不是真正的可移植。
您可以查看我整理的GitHub存储库。它确实使用IdentityModel,因为它负责处理所有事情,并隐藏了您必须对IdentityServer进行的大部分HTTP调用的复杂性。

1
伟大的解决方案,您认为此代码是否支持特定用户的多个并发HTTP请求?如果不在客户端设置中使用RefreshTokenUsage.ReUse,那么会有访问令牌续订的竞争吗?刷新令牌是否会失效?此外,您对javascript代码中的Ajax调用如何处理访问令牌续订有什么想法? - Jonas
修改此解决方案并将客户端身份验证 cookie 更改为会话 cookie(仍包含标识/声明、访问令牌和刷新令牌),并仍具有 12 小时的过期时间可能是有意义的。我不太喜欢持久性 cookie,让它们保持登录状态 12 小时或直到关闭浏览器更好。使用自定义声明类型“expire_at”来代替知道何时刷新访问令牌的逻辑。您认为您的解决方案是否适用于 OIDC 混合流程? - Jonas
当使用引用令牌(不透明访问令牌)时,我不确定这个解决方案是否有效,有什么想法吗? - Jonas
我认为现在关键是你想如何实现。只要在令牌更新时跟踪到过期日期,不透明令牌就不会产生影响。流程也是一样的;只要它允许您获取刷新令牌,它就可以工作。存储令牌和过期日期的cookie类型以及方式由您决定。明白了吗? - Mickaël Derriey
你在Github仓库里的代码很棒,但我发现这个代码不适用于使用许多AJAX请求的ASP.NET Core MVC Web应用程序。用户在页面上停留很长时间,使用依赖于AJAX请求的功能,访问令牌过期并且请求失败。用户首先需要刷新整个页面,以便向Web应用程序发出“正常”请求,然后更新访问令牌。 - Jonas
显示剩余9条评论

2

0
我找到了两个可能的解决方案,它们都是相等的,但发生在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中(由OpenIdConnectOptionsSignInScheme属性驱动)。这意味着OIDC中间件事件将在用户在IdP登录后执行一次。此时访问令牌永远不会过期。建议您在OIDC和cookie中间件事件中都设置断点,以了解事情的运作方式。 - Mickaël Derriey
谢谢您的反馈,请查看我上面的另一个答案,它实际上是有效的。 - Jonas

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