音效素材网提供各类素材,打造精品素材网站!

站内导航 站长工具 投稿中心 手机访问

音效素材

详解ASP.NET Core Web Api之JWT刷新Token
日期:2021-09-07 22:51:24   来源:脚本之家

前言

如题,本节我们进入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 = "2752154844@qq.com"
      };

      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小白系列,感谢阅读。

到此这篇关于详解ASP.NET Core Web Api之JWT刷新Token的文章就介绍到这了,更多相关ASP.NET Core Web Api JWT刷新Token内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

    您感兴趣的教程

    在docker中安装mysql详解

    本篇文章主要介绍了在docker中安装mysql详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编...

    详解 安装 docker mysql

    win10中文输入法仅在桌面显示怎么办?

    win10中文输入法仅在桌面显示怎么办?

    win10系统使用搜狗,QQ输入法只有在显示桌面的时候才出来,在使用其他程序输入框里面却只能输入字母数字,win10中...

    win10 中文输入法

    一分钟掌握linux系统目录结构

    这篇文章主要介绍了linux系统目录结构,通过结构图和多张表格了解linux系统目录结构,感兴趣的小伙伴们可以参考一...

    结构 目录 系统 linux

    PHP程序员玩转Linux系列 Linux和Windows安装

    这篇文章主要为大家详细介绍了PHP程序员玩转Linux系列文章,Linux和Windows安装nginx教程,具有一定的参考价值,感兴趣...

    玩转 程序员 安装 系列 PHP

    win10怎么安装杜比音效Doby V4.1 win10安装杜

    第四代杜比®家庭影院®技术包含了一整套协同工作的技术,让PC 发出清晰的环绕声同时第四代杜比家庭影院技术...

    win10杜比音效

    纯CSS实现iOS风格打开关闭选择框功能

    这篇文章主要介绍了纯CSS实现iOS风格打开关闭选择框,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作...

    css ios c

    Win7如何给C盘扩容 Win7系统电脑C盘扩容的办法

    Win7如何给C盘扩容 Win7系统电脑C盘扩容的

    Win7给电脑C盘扩容的办法大家知道吗?当系统分区C盘空间不足时,就需要给它扩容了,如果不管,C盘没有足够的空间...

    Win7 C盘 扩容

    百度推广竞品词的投放策略

    SEM是基于关键词搜索的营销活动。作为推广人员,我们所做的工作,就是打理成千上万的关键词,关注它们的质量度...

    百度推广 竞品词

    Visual Studio Code(vscode) git的使用教程

    这篇文章主要介绍了详解Visual Studio Code(vscode) git的使用,小编觉得挺不错的,现在分享给大家,也给大家做个参考。...

    教程 Studio Visual Code git

    七牛云储存创始人分享七牛的创立故事与

    这篇文章主要介绍了七牛云储存创始人分享七牛的创立故事与对Go语言的应用,七牛选用Go语言这门新兴的编程语言进行...

    七牛 Go语言

    Win10预览版Mobile 10547即将发布 9月19日上午

    微软副总裁Gabriel Aul的Twitter透露了 Win10 Mobile预览版10536即将发布,他表示该版本已进入内部慢速版阶段,发布时间目...

    Win10 预览版

    HTML标签meta总结,HTML5 head meta 属性整理

    移动前端开发中添加一些webkit专属的HTML5头部标签,帮助浏览器更好解析HTML代码,更好地将移动web前端页面表现出来...

    移动端html5模拟长按事件的实现方法

    这篇文章主要介绍了移动端html5模拟长按事件的实现方法的相关资料,小编觉得挺不错的,现在分享给大家,也给大家...

    移动端 html5 长按

    HTML常用meta大全(推荐)

    这篇文章主要介绍了HTML常用meta大全(推荐),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参...

    cdr怎么把图片转换成位图? cdr图片转换为位图的教程

    cdr怎么把图片转换成位图? cdr图片转换为

    cdr怎么把图片转换成位图?cdr中插入的图片想要转换成位图,该怎么转换呢?下面我们就来看看cdr图片转换为位图的...

    cdr 图片 位图

    win10系统怎么录屏?win10系统自带录屏详细教程

    win10系统怎么录屏?win10系统自带录屏详细

    当我们是使用win10系统的时候,想要录制电脑上的画面,这时候有人会想到下个第三方软件,其实可以用电脑上的自带...

    win10 系统自带录屏 详细教程

    + 更多教程 +
    ASP编程JSP编程PHP编程.NET编程python编程