ASP.NET Core Web Api之JWT刷新Token(三)

  • 2019 年 10 月 3 日
  • 筆記

前言

如题,本节我们进入JWT最后一节内容,JWT本质上就是从身份认证服务器获取访问令牌,继而对于用户后续可访问受保护资源,但是关键问题是:访问令牌的生命周期到底设置成多久呢?见过一些使用JWT的童鞋会将JWT过期时间设置成很长,有的几个小时,有的一天,有的甚至一个月,这么做当然存在问题,如果被恶意获得访问令牌,那么可在整个生命周期中使用访问令牌,也就是说存在冒充用户身份,此时身份认证服务器当然也就是始终信任该冒牌访问令牌,若要使得冒牌访问令牌无效,唯一的方案则是修改密钥,但是如果我们这么做了,则将使得已授予的访问令牌都将无效,所以更改密钥不是最佳方案,我们应该从源头尽量控制这个问题,而不是等到问题呈现再来想解决之道,刷新令牌闪亮登场。

 

RefreshToken

什么是刷新令牌呢?刷新访问令牌是用来从身份认证服务器交换获得新的访问令牌,有了刷新令牌可以在访问令牌过期后通过刷新令牌重新获取新的访问令牌而无需客户端通过凭据重新登录,如此一来,既保证了用户访问令牌过期后的良好体验,也保证了更高的系统安全性,同时,若通过刷新令牌获取新的访问令牌验证其无效可将受访者纳入黑名单限制其访问,那么访问令牌和刷新令牌的生命周期设置成多久合适呢?这取决于系统要求的安全性,一般来讲访问令牌的生命周期不会太长,比如5分钟,又比如获取微信的AccessToken的过期时间为2个小时。接下来我将用两张表来演示实现刷新令牌的整个过程,可能有更好的方案,欢迎在评论中提出,学习,学习。我们新建一个http://localhost:5000的WebApi用于身份认证,再新建一个http://localhost:5001的客户端,首先点击【模拟登录获取Toen】获取访问令牌和刷新令牌,然后点击【调用客户端获取当前时间】,如下:

接下来我们新建一张用户表(User)和用户刷新令牌表(UserRefreshToken),结构如下:

    public class User      {          public string Id { get; set; }          public string Email { get; set; }          public string UserName { get; set; }            private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();            public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens;            /// <summary>          /// 验证刷新token是否存在或过期          /// </summary>          /// <param name="refreshToken"></param>          /// <returns></returns>          public bool IsValidRefreshToken(string refreshToken)          {              return _userRefreshTokens.Any(d => d.Token.Equals(refreshToken) && d.Active);          }            /// <summary>          /// 创建刷新Token          /// </summary>          /// <param name="token"></param>          /// <param name="userId"></param>          /// <param name="minutes"></param>          public void CreateRefreshToken(string token, string userId, double minutes = 1)          {              _userRefreshTokens.Add(new UserRefreshToken() { Token = token, UserId = userId, Expires = DateTime.Now.AddMinutes(minutes) });          }            /// <summary>          /// 移除刷新token          /// </summary>          /// <param name="refreshToken"></param>          public void RemoveRefreshToken(string refreshToken)          {              _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken));          }

    public class UserRefreshToken      {          public string Id { get; private set; } = Guid.NewGuid().ToString();          public string Token { get; set; }          public DateTime Expires { get; set; }          public string UserId { get; set; }          public bool Active => DateTime.Now <= Expires;      }

如上可以看到对于刷新令牌的操作我们将其放在用户实体中,也就是使用EF Core中的Back Fields而不对外暴露。接下来我们将生成的访问令牌、刷新令牌、验证访问令牌、获取用户身份封装成对应方法如下:

        /// <summary>          /// 生成访问令牌          /// </summary>          /// <param name="claims"></param>          /// <returns></returns>          public string GenerateAccessToken(Claim[] claims)          {              var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));                var token = new JwtSecurityToken(                  issuer: "http://localhost:5000",                  audience: "http://localhost:5001",                  claims: claims,                  notBefore: DateTime.Now,                  expires: DateTime.Now.AddMinutes(1),                  signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)              );                return new JwtSecurityTokenHandler().WriteToken(token);          }            /// <summary>          /// 生成刷新Token          /// </summary>          /// <returns></returns>          public string GenerateRefreshToken()          {              var randomNumber = new byte[32];              using (var rng = RandomNumberGenerator.Create())              {                  rng.GetBytes(randomNumber);                  return Convert.ToBase64String(randomNumber);              }          }            /// <summary>          /// 从Token中获取用户身份          /// </summary>          /// <param name="token"></param>          /// <returns></returns>          public ClaimsPrincipal GetPrincipalFromAccessToken(string token)          {              var handler = new JwtSecurityTokenHandler();                try              {                  return handler.ValidateToken(token, new TokenValidationParameters                  {                      ValidateAudience = false,                      ValidateIssuer = false,                      ValidateIssuerSigningKey = true,                      IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),                      ValidateLifetime = false                  }, out SecurityToken validatedToken);              }              catch (Exception)              {                  return null;              }          }

当用户点击登录,访问身份认证服务器,登录成功后我们创建访问令牌和刷新令牌并返回,如下:

        [HttpPost("login")]          public async Task<IActionResult> Login()          {              var user = new User()              {                  Id = "D21D099B-B49B-4604-A247-71B0518A0B1C",                  UserName = "Jeffcky",                  Email = "[email protected]"              };                await context.Users.AddAsync(user);                var refreshToken = GenerateRefreshToken();                user.CreateRefreshToken(refreshToken, user.Id);                await context.SaveChangesAsync();                var claims = new Claim[]              {                  new Claim(ClaimTypes.Name, user.UserName),                  new Claim(JwtRegisteredClaimNames.Email, user.Email),                  new Claim(JwtRegisteredClaimNames.Sub, user.Id),              };                return Ok(new Response() { AccessToken = GenerateAccessToken(claims), RefreshToken = refreshToken });          }

此时我们回到如上给出的图,我们点击【模拟登录获取Token】,此时发出Ajax请求,然后将返回的访问令牌和刷新令牌存储到本地localStorage中,如下:

<input type="button" id="btn" value="模拟登录获取Token" />    <input type="button" id="btn-currentTime" value="调用客户端获取当前时间" />

       //模拟登陆          $('#btn').click(function () {              GetTokenAndRefreshToken();          });            //获取Token          function GetTokenAndRefreshToken() {           $.post('http://localhost:5000/api/account/login').done(function (data) {                  saveAccessToken(data.accessToken);                  saveRefreshToken(data.refreshToken);              });          }

        //从localStorage获取AccessToken          function getAccessToken() {              return localStorage.getItem('accessToken');          }            //从localStorage获取RefreshToken          function getRefreshToken() {              return localStorage.getItem('refreshToken');          }            //保存AccessToken到localStorage          function saveAccessToken(token) {              localStorage.setItem('accessToken', token);          }            //保存RefreshToken到localStorage          function saveRefreshToken(refreshToken) {              localStorage.setItem('refreshToken', refreshToken);          }

此时我们再来点击【调用客户端获取当前时间】,同时将登录返回的访问令牌设置到请求头中,代码如下:

       $('#btn-currentTime').click(function () {              GetCurrentTime();          });            //调用客户端获取当前时间          function GetCurrentTime() {              $.ajax({                  type: 'get',                  contentType: 'application/json',                  url: 'http://localhost:5001/api/home',                  beforeSend: function (xhr) {                      xhr.setRequestHeader('Authorization', 'Bearer ' + getAccessToken());                  },                  success: function (data) {                      alert(data);                  },                  error: function (xhr) {                    }              });          }

客户端请求接口很简单,为了让大家一步步看明白,我也给出来,如下:

        [Authorize]          [HttpGet("api/[controller]")]          public string GetCurrentTime()          {              return DateTime.Now.ToString("yyyy-MM-dd");          }

好了到了这里我们已经实现模拟登录获取访问令牌,并能够调用客户端接口获取到当前时间,同时我们也只是返回了刷新令牌并存储到了本地localStorage中,并未用到。当访问令牌过期后我们需要通过访问令牌和刷新令牌去获取新的访问令牌,对吧。那么问题来了。我们怎么知道访问令牌已经过期了呢?这是其一,其二是为何要发送旧的访问令牌去获取新的访问令牌呢?直接通过刷新令牌去换取不行吗?有问题是好的,就怕没有任何思考,我们一一来解答。我们在客户端添加JWT中间件时,里面有一个事件可以捕捉到访问令牌已过期(关于客户端配置JWT中间件第一节已讲过,这里不再啰嗦),如下:

                  options.Events = new JwtBearerEvents                    {                        OnAuthenticationFailed = context =>                        {                            if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))                            {                                context.Response.Headers.Add("act", "expired");                            }                            return Task.CompletedTask;                        }                    };

通过如上事件并捕捉访问令牌过期异常,这里我们在响应头添加了一个自定义键act,值为expired,因为一个401只能反映未授权,并不能代表访问令牌已过期。当我们在第一张图中点击【调用客户端获取当前时间】发出Ajax请求时,如果访问令牌过期,此时在Ajax请求中的error方法中捕捉到,我们在如上已给出发出Ajax请求的error方法中继续进行如下补充:

                error: function (xhr) {                      if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') {                          // 访问令牌肯定已过期                      }                  }

到了这里我们已经解决如何捕捉到访问令牌已过期的问题,接下来我们需要做的则是获取刷新令牌,直接通过刷新令牌换取新的访问令牌也并非不可,只不过还是为了安全性考虑,我们加上旧的访问令牌。接下来我们发出Ajax请求获取刷新令牌,如下:

        //获取刷新Token          function GetRefreshToken(func) {              var model = {                  accessToken: getAccessToken(),                  refreshToken: getRefreshToken()              };              $.ajax({                  type: "POST",                  contentType: "application/json; charset=utf-8",                  url: 'http://localhost:5000/api/account/refresh-token',                  dataType: "json",                  data: JSON.stringify(model),                  success: function (data) {                      if (!data.accessToken && !data.refreshToken) {                          // 跳转至登录                      } else {                          saveAccessToken(data.accessToken);                          saveRefreshToken(data.refreshToken);                          func();                      }                  }              });          }

发出Ajax请求获取刷新令牌的方法我们传入了一个函数,这个函数则是上一次调用接口访问令牌过期的请求,点击【调用客户端获取当前时间】按钮的Ajax请求error方法中,最终演变成如下这般:

              error: function (xhr) {                      if (xhr.status === 401 && xhr.getResponseHeader('act') === 'expired') {                            /* 访问令牌肯定已过期,将当前请求传入获取刷新令牌方法,                           * 以便获取刷新令牌换取新的令牌后继续当前请求                          */                          GetRefreshToken(GetCurrentTime);                      }                  }

接下来则是通过传入旧的访问令牌和刷新令牌调用接口换取新的访问令牌,如下:

        /// <summary>          /// 刷新Token          /// </summary>          /// <returns></returns>          [HttpPost("refresh-token")]          public async Task<IActionResult> RefreshToken([FromBody] Request request)          {              //TODO 参数校验                var principal = GetPrincipalFromAccessToken(request.AccessToken);                if (principal is null)              {                  return Ok(false);              }                var id = principal.Claims.First(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value;                if (string.IsNullOrEmpty(id))              {                  return Ok(false);              }                var user = await context.Users.Include(d => d.UserRefreshTokens)                  .FirstOrDefaultAsync(d => d.Id == id);                if (user is null || user.UserRefreshTokens?.Count() <= 0)              {                  return Ok(false);              }                if (!user.IsValidRefreshToken(request.RefreshToken))              {                  return Ok(false);              }                user.RemoveRefreshToken(request.RefreshToken);                var refreshToken = GenerateRefreshToken();                user.CreateRefreshToken(refreshToken, id);                try              {                  await context.SaveChangesAsync();              }              catch (Exception ex)              {                  throw ex;              }                var claims = new Claim[]              {                  new Claim(ClaimTypes.Name, user.UserName),                  new Claim(JwtRegisteredClaimNames.Email, user.Email),                  new Claim(JwtRegisteredClaimNames.Sub, user.Id),              };                return Ok(new Response()              {                  AccessToken = GenerateAccessToken(claims),                  RefreshToken = refreshToken              });          }

如上通过传入旧的访问令牌验证并获取用户身份,然后验证刷新令牌是否已经过期,如果未过期则创建新的访问令牌,同时更新刷新令牌。最终客户端访问令牌过期的那一刻,通过刷新令牌获取新的访问令牌继续调用上一请求,如下:

到这里关于JWT实现刷新Token就已结束,自我感觉此种实现刷新令牌将其存储到数据库的方案还算可取,将刷新令牌存储到Redis也可行,看个人选择吧。上述若刷新令牌验证无效,可将访问者添加至黑名单,不过是添加一个属性罢了。别着急,本节内容结束前,还留有彩蛋。

EntityFramework Core Back Fields深入探讨

无论是看视频还是看技术博客也好,一定要动手验证,看到这里觉得上述我所演示是不是毫无问题,如果阅读本文的你直接拷贝上述代码你会发现有问题,且听我娓娓道来,让我们来复习下Back Fields。Back Fields命名是有约定dei,上述我是根据约定而命名,所以千万别一意孤行,别乱来,比如如下命名将抛出如下异常:

 private readonly List<UserRefreshToken> _refreshTokens = new List<UserRefreshToken>();     public IEnumerable<UserRefreshToken> UserRefreshTokens => _refreshTokens;

上述我们配置刷新令牌的Back Fields,代码如下:

  private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();    public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens;

要是我们配置成如下形式,结果又会怎样呢?

 private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();   public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();

此时为了解决这个问题,我们必须将其显式配置成Back Fields,如下:

        protected override void OnModelCreating(ModelBuilder modelBuilder)          {              modelBuilder.Entity<User>(u =>              {                  var navigation = u.Metadata.FindNavigation(nameof(User.UserRefreshTokens));                  navigation.SetPropertyAccessMode(PropertyAccessMode.Field);              });          }

在我个人著作中也讲解到为了性能问题,可将字段进行ToList(),若进行了ToList(),必须显式配置成Back Fields,否则获取不到刷新令牌导航属性,如下:

private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();  public IEnumerable<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.ToList();

或者进行如下配置,我想应该也可取,不会存在性能问题,如下:

  private readonly List<UserRefreshToken> _userRefreshTokens = new List<UserRefreshToken>();    public IReadOnlyCollection<UserRefreshToken> UserRefreshTokens => _userRefreshTokens.AsReadOnly();

这是关于Back Fields问题之一,问题之二则是上述我们请求获取刷新令牌中,我们先在刷新令牌的Back Fields中移除掉旧的刷新令牌,而后再创建新的刷新令牌,但是会抛出如下异常:

我们看到在添加刷新令牌时,用户Id是有值的,对不对,这是为何呢?究其根本问题出在我们移除刷新令牌方法中,如下:

        /// <summary>          /// 移除刷新token          /// </summary>          /// <param name="refreshToken"></param>          public void RemoveRefreshToken(string refreshToken)          {              _userRefreshTokens.Remove(_userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken));          }

我们将查询出来的导航属性并将其映射到_userRefreshTokens字段中,此时是被上下文所追踪,上述我们查询出存在的刷新令牌并在跟踪的刷新令牌中进行移除,没毛病,没找到原因,于是乎,我将上述方法修改成如下看看是否必须需要主键才能删除旧的刷新令牌:

         /// <summary>          /// 移除刷新token          /// </summary>          /// <param name="refreshToken"></param>          public void RemoveRefreshToken(string refreshToken)          {              var id = _userRefreshTokens.FirstOrDefault(t => t.Token == refreshToken).Id;              _userRefreshTokens.Remove(new UserRefreshToken() { Id = id });          }

倒没抛出异常,创建了一个新的刷新令牌,但是旧的刷新令牌却没删除,如下:

至此未找到问题出在哪里,当前版本为2.2,难道不能通过Back Fields移除对象?这个问题待解决。

总结

本节我们重点讲解了如何实现JWT刷新令牌,并也略带讨论了EF Core中Back Fields以及尚未解决的问题,至此关于JWT已结束,下节开始正式进入Docker小白系列,感谢阅读。