热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

详解ASP.NETCoreWebApi之JWT刷新Token

这篇文章主要介绍了详解ASP.NETCoreWebApi之JWT刷新Token,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

前言

如题,本节我们进入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 _userRefreshTokens = new List();

    public IEnumerable UserRefreshTokens => _userRefreshTokens;

    /// 
    /// 验证刷新token是否存在或过期
    /// 
    /// 
    /// 
    public bool IsValidRefreshToken(string refreshToken)
    {
      return _userRefreshTokens.Any(d => d.Token.Equals(refreshToken) && d.Active);
    }

    /// 
    /// 创建刷新Token
    /// 
    /// 
    /// 
    /// 
    public void CreateRefreshToken(string token, string userId, double minutes = 1)
    {
      _userRefreshTokens.Add(new UserRefreshToken() { Token = token, UserId = userId, Expires = DateTime.Now.AddMinutes(minutes) });
    }

    /// 
    /// 移除刷新token
    /// 
    /// 
    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而不对外暴露。接下来我们将生成的访问令牌、刷新令牌、验证访问令牌、获取用户身份封装成对应方法如下:

/// 
    /// 生成访问令牌
    /// 
    /// 
    /// 
    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);
    }

    /// 
    /// 生成刷新Token
    /// 
    /// 
    public string GenerateRefreshToken()
    {
      var randomNumber = new byte[32];
      using (var rng = RandomNumberGenerator.Create())
      {
        rng.GetBytes(randomNumber);
        return Convert.ToBase64String(randomNumber);
      }
    }

    /// 
    /// 从Token中获取用户身份
    /// 
    /// 
    /// 
    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 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中,如下:




//模拟登陆
    $('#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);
          }
        }

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

/// 
    /// 刷新Token
    /// 
    /// 
    [HttpPost("refresh-token")]
    public async Task 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)&#63;.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&#63;.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 _refreshTokens = new List();

 public IEnumerable UserRefreshTokens => _refreshTokens;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/// 
    /// 移除刷新token
    /// 
    /// 
    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内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!


推荐阅读
  • 单点登录原理及实现方案详解
    本文详细介绍了单点登录的原理及实现方案,其中包括共享Session的方式,以及基于Redis的Session共享方案。同时,还分享了作者在应用环境中所遇到的问题和经验,希望对读者有所帮助。 ... [详细]
  • 本文介绍了Hyperledger Fabric外部链码构建与运行的相关知识,包括在Hyperledger Fabric 2.0版本之前链码构建和运行的困难性,外部构建模式的实现原理以及外部构建和运行API的使用方法。通过本文的介绍,读者可以了解到如何利用外部构建和运行的方式来实现链码的构建和运行,并且不再受限于特定的语言和部署环境。 ... [详细]
  • 图解redis的持久化存储机制RDB和AOF的原理和优缺点
    本文通过图解的方式介绍了redis的持久化存储机制RDB和AOF的原理和优缺点。RDB是将redis内存中的数据保存为快照文件,恢复速度较快但不支持拉链式快照。AOF是将操作日志保存到磁盘,实时存储数据但恢复速度较慢。文章详细分析了两种机制的优缺点,帮助读者更好地理解redis的持久化存储策略。 ... [详细]
  • 本文介绍了高校天文共享平台的开发过程中的思考和规划。该平台旨在为高校学生提供天象预报、科普知识、观测活动、图片分享等功能。文章分析了项目的技术栈选择、网站前端布局、业务流程、数据库结构等方面,并总结了项目存在的问题,如前后端未分离、代码混乱等。作者表示希望通过记录和规划,能够理清思路,进一步完善该平台。 ... [详细]
  • CentOS 7部署KVM虚拟化环境之一架构介绍
    本文介绍了CentOS 7部署KVM虚拟化环境的架构,详细解释了虚拟化技术的概念和原理,包括全虚拟化和半虚拟化。同时介绍了虚拟机的概念和虚拟化软件的作用。 ... [详细]
  • 本文介绍了前端人员必须知道的三个问题,即前端都做哪些事、前端都需要哪些技术,以及前端的发展阶段。初级阶段包括HTML、CSS、JavaScript和jQuery的基础知识。进阶阶段涵盖了面向对象编程、响应式设计、Ajax、HTML5等新兴技术。高级阶段包括架构基础、模块化开发、预编译和前沿规范等内容。此外,还介绍了一些后端服务,如Node.js。 ... [详细]
  • 本文介绍了Redis中RDB文件和AOF文件的保存和还原机制。RDB文件用于保存和还原Redis服务器所有数据库中的键值对数据,SAVE命令和BGSAVE命令分别用于阻塞服务器和由子进程执行保存操作。同时执行SAVE命令和BGSAVE命令,以及同时执行两个BGSAVE命令都会产生竞争条件。服务器会保存所有用save选项设置的保存条件,当满足任意一个保存条件时,服务器会自动执行BGSAVE命令。此外,还介绍了RDB文件和AOF文件在操作方面的冲突以及同时执行大量磁盘写入操作的不良影响。 ... [详细]
  • 本文介绍了如何使用jQuery和AJAX来实现动态更新两个div的方法。通过调用PHP文件并返回JSON字符串,可以将不同的文本分别插入到两个div中,从而实现页面的动态更新。 ... [详细]
  • SpringBoot整合SpringSecurity+JWT实现单点登录
    SpringBoot整合SpringSecurity+JWT实现单点登录,Go语言社区,Golang程序员人脉社 ... [详细]
  • 本文介绍了Java后台Jsonp处理方法及其应用场景。首先解释了Jsonp是一个非官方的协议,它允许在服务器端通过Script tags返回至客户端,并通过javascript callback的形式实现跨域访问。然后介绍了JSON系统开发方法,它是一种面向数据结构的分析和设计方法,以活动为中心,将一连串的活动顺序组合成一个完整的工作进程。接着给出了一个客户端示例代码,使用了jQuery的ajax方法请求一个Jsonp数据。 ... [详细]
  • 微信官方授权及获取OpenId的方法,服务器通过SpringBoot实现
    主要步骤:前端获取到code(wx.login),传入服务器服务器通过参数AppID和AppSecret访问官方接口,获取到OpenId ... [详细]
  • 本文介绍了NetCore WebAPI开发的探索过程,包括新建项目、运行接口获取数据、跨平台部署等。同时还提供了客户端访问代码示例,包括Post函数、服务器post地址、api参数等。详细讲解了部署模式选择、框架依赖和独立部署的区别,以及在Windows和Linux平台上的部署方法。 ... [详细]
  • jmeter实践:从csv中获取带引号的数据详情的技巧和运行全部数据的方法
    本文分享了jmeter实践中从csv中获取带引号的数据的解决办法,包括设置CSV Data Set Config和运行脚本获取数据的方法。另外还介绍了循环运行csv中全部数据的解决方法,避免每次修改csv用例都需要修改脚本的麻烦。通过了解和掌握工具的细节点,可以更好地解决问题和提高技术水平。 ... [详细]
  • Go语言实现堆排序的详细教程
    本文主要介绍了Go语言实现堆排序的详细教程,包括大根堆的定义和完全二叉树的概念。通过图解和算法描述,详细介绍了堆排序的实现过程。堆排序是一种效率很高的排序算法,时间复杂度为O(nlgn)。阅读本文大约需要15分钟。 ... [详细]
  • 本文介绍了一种处理AJAX操作授权过期的全局方式,以解决Asp.net MVC中Session过期异常的问题。同时还介绍了基于WebImage的图片上传工具类。详细内容请参考链接:https://www.cnblogs.com/starluck/p/8284949.html ... [详细]
author-avatar
书友59082326
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有