dy评论下单 Token 无感刷新机制:解决鉴权认证服务器中的问题

1.序言

近来在搞一个信令认证服务器,其中有个问题就是token的无感刷新。Token无感刷新是一种在用户不感知的情况下手动更新访问令牌(Token)的机制,以维持用户的登入状态。

通常是使用一个短期的token来做权限认证,而更长时间的refreshToken来做短token的刷新,而在实现的过程中就有各类问题下来诸如:

dy评论下单 Token 无感刷新机制:解决鉴权认证服务器中的问题

下边我就对里面那些问题给出我自己的见解,希望能对读者有所帮助

2.顾客端实现2.1初始版本

看法:每次顾客端发起的恳求会被服务器端gateway拦截,此时在gateway中判定token是否无效(过期):

而后端处可以拦截到当前服务器返回的响应状态码,按照状态码来执行对应的操作,也就是下边要引出的axios

2.1.1服务器端gateway实现拦截器

注意环境springboot3+java17,通过承继GlobalFilter来实现对应的filter逻辑

@Component
public class MyAccessFilter implements GlobalFilterOrdered
{
    @Override
    public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String uri = request.getURI().getPath();
        HttpMethod method = request.getMethod();

        // OPTION直接放行
        if(method.matches(HttpMethod.OPTIONS.name()))
            return chain.filter(exchange);

        //登录请求直接放行
        if(SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
            return chain.filter(exchange);
   
  //获取token
        String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
        if(null != token){

            //判断token是否过时
            if(!JWTHelper.isOutDate(token)){
                return chain.filter(exchange);

            }else{
                if(!SecurityAccessConstant.REQUEST_REFRESH.equals(uri))    //当前不是刷新请求可以刷新返回的状态码就是511
                    return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(),
                        ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));

                //当前是刷新请求 但refreshToken都过期了,即刷新不支持
                return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
            }

        }
        
        return ResponseUtils.out(exchange , ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
    }

    @Override
    public int getOrder() {
        //数值越小 优先级越高
        return Ordered.LOWEST_PRECEDENCE;
    }
}

2.1.1.1问题Q2解决

正常情况下解析的token会报错,这么就在解析的时侯拦截错误dy评论下单,假如catch到JwtException,此时就觉得该token无效早已过期了返回true

否则则执行正常逻辑获取并返回token中的过期时间与当前时间比较的结果

//判断当前token是否过期
public static boolean isOutDate(String token){
    try {
        Jws claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
        Date expirationDate = claimsJws.getBody().getExpiration();
        return expirationDate.before(new Date());
    } catch (JwtException e) {
        // JWT token无效或已损坏
        return true;
    }
}

2.1.2axios拦截器

在拦截器中,我们使用判别响应码,倘若是401则清空用户数据回挪到登入页面,而假如是511则使用refreshToken再恳求刷新一次(其他的情况在这儿就不做剖析,感兴趣的读者可以自行研究)

// 响应拦截器
service.interceptors.response.use(
 
  // 响应成功进入第1个函数
  // 该函数的参数是响应对象
  function(response) {
    console.log(response)
    return response.data.data;
  },
  // 响应失败进入第2个函数,该函数的参数是错误对象
  async function(error) {
    // 如果响应码是 401 ,则请求获取新的 token
    // 响应拦截器中的 error 就是那个响应的错误对象
    if(error.response == undefined)
      return Promise.reject(error);

    const status = error.response.status
    const authStore = useAuthStore()
    let message = ''

    switch(status){
      case 401// 无权限
        authStore.reset() // 清空store中的权限数据
        window.sessionStorage.removeItem('isAuthenticated')
        window.sessionStorage.removeItem('token')
        window.sessionStorage.removeItem('refreshToken')
        message = 'token 失效,请重新登录'
        // 跳转到登录页  
        window.location.href = '/auth/login';
        break;
      case 511// 当前token需要刷新
        try {
          const data = refresh()

          if(data !== null){
            data.then((value) => {
              // Use the string value here
              if(value !== ''){
                // 如果获取成功,则把新的 token 更新到容器中
                console.log("刷新 token  成功", value);
                window.sessionStorage.setItem("token",value)
                // 把之前失败的用户请求继续发出去
                // config 是一个对象,其中包含本次失败请求相关的那些配置信息,例如 url、method 都有
                // return 把 request 的请求结果继续返回给发请求的具体位置
                error.config.headers['Authorization'] = 'Bearer ' +value;
                return service(error.config);
              }
              console.log(value);
            }).catch((error) => {
              // Handle any errors that occurred while resolving the promise
              console.error(error);
            });
            
          }
          
        } catch (err) {
          // 如果获取失败,直接跳转 登录页
          console.log("请求刷线 token 失败", err);
          router.push("/login");
        }
        break;
        case '403':
          message = '拒绝访问'
          break;
        case '404':
          message = '请求地址错误'
          break;
        case '500':
          message = '服务器故障'
          break;
        default:
          message = '网络连接故障'  
   
    }
    
    Message.error(message)
    return Promise.reject(error);
  }
);

2.1.3refresh刷新token方式实现

这儿实现是重新用axios原生发异步恳求,而不是使用在request.ts中导入的恳求方式(由于上面定义了恳求拦截器,每次恳求之前就会取出token并放在恳求头,这就又弄成恳求头中携带的token无效了,造成重复发送刷新恳求步入死循环,所以不能这样做)

/**
 * 刷新token 
 * 成功返回新token
 * 失败返回空字符串''
 */

export async function refresh() : Promise<string>{
  const refreshToken = window.sessionStorage.getItem("refreshToken")
  console.log("in >>> " ,refreshToken)
  if(refreshToken == undefined)
      return '' //本来就没有这个更新token则直接返回
  
  try {
    const response = await axios({
      method'GET',
      url'http://127.0.0.1:9001/api/simple/cloud/access/refresh',// 认证服务器地址
      headers: {
        Authorization`Bearer ${refreshToken}`//header中放入的是refreshToken用于刷新请求
      },
    });
  // 如果顺利返回会得到 data,由于后端使用统一结果返回ResultData,所以会多封装一层code、data
    if (response.data) {
      return response.data.data; //所以这里有两个data
    } else {
      return '';
    }
  } catch (error) {
    console.log(error);
    return '';
  }
}  

2.1.4正常和刷新情况下的console输出信息剖析

悉心的读者可以注意到左边的代码有好多地方有控制台的输出,加上那些可以更便捷的看懂代码的逻辑,下边我们就运行代码跑跑瞧瞧结果返回情况,这儿建议诸位结合代码剖析瞧瞧我做输出的地方是在那里。

右图是正常情况下的返回结果,注意这儿的token是以hizFIGg结尾,而refreshToken是以suvm-EgQ结尾(这两个注意与异常的来比对)正常情况下返回的结果肯定是200即ok

注意>>>>>处输出的结果是点击该按键后点击风波返回的结果,对应着Q3的思索,具体剖析会结合失败的事例来演示

下边来看异常情况的剖析,因为token太长了,所以分拆两张图片更容易看一点,从左侧的图开始剖析

到这儿左图剖析完毕,步入下图的剖析(肯定有读者困惑你这蓝色的warn咋不讲)别急这块我会和下图的白色error一起讲解

最后返回结果可以看见早已没有前面注意部份提及的>>>>>输出内容,令通过更新好的token发送二次恳求得到的结果记作data,此时的data早已不能返回原先的getAllUser方式调用处,由于原先的方式早已结束,浅显点话说就是这样的二次调用结果毫无意义dy评论下单,用户还是须要刷新网页或则二次点击以获取资源

这就是Q3提出的思索,因为异步调用而非阻塞式的调用方法造成原方式提早中止,可以考虑换成阻塞式的调用refresh形式刷新token,然而这样又会造成该次点击的响应变慢,用户体验差(有更好想法的读者可以在评论区一起讨论)

2.2改进版本

既然异步方式不得行,那能不能换种思路?不要在失败的时侯发送,而是提早检测存在本地的token有没有过期,当检测token过期时间大于一个临界点,则异步调用刷新token方式,更新现有的token信息,此时是不是就解决前面的问题,只要是服务器端gateway拦截到token失效的恳求我都要求重新登入。此时就引出一个定时器的概念

在TypeScript中,定时器主要是指通过setInterval和setTimeout这两个函数来实现的周期性或延时执行代码的功能。

首先,setInterval是一个可以根据指定的时间间隔重复执行某段代码或函数的技巧。它接受两个参数:第一个参数是你想要周期性执行的函数或代码块,第二个参数是时间间隔,单位为微秒。

因为当setInterval被调用时,它会在指定的时间间隔后执行给定的函数或代码块。这个时间间隔是以微秒为单位的,并且它是从调用setInterval的那一刻开始估算的。这意味着一旦setInterval被调用,定时器都会立刻启动,并在每位指定的时间间隔后重复执行。所以该定时器的设定应当置于login方式登陆返回结果处

2.2.1定义定时器类

通过该定时器类,可以实现MyTimer.start方式调用setInterval间隔delay时间步执行,判定当前的token过期时间是否大于我们设置的minCheck,假如大于则使用refreshToken异步刷新token

import { refresh } from "@/api/system/auth/index"
import { jwtDecode } from "jwt-decode";

export class MyTimer {
  private timerId: any | null = null;
  // delay为重复探查的间隔时间 , minCheck是判断token是否是快过期的
   start(delay: number, minCheck : number): void {
     this.timerId = setInterval(async () => {
       const currentToken = window.sessionStorage.getItem('token');
       console.log("timer++++")
       if (currentToken) {
         // 如果存在token,判断是否过期
         let expirationTime = 0;
         expirationTime = getExpirationTime(currentToken) ; // 假设有一个函数用于获取token的过期时间
         
         const timeRemaining = expirationTime - Date.now();
         if (timeRemaining <= minCheck) {
           // 如果剩余时间小于等于5分钟,则异步发送刷新请求并更新token
           await refresh();
         }
       } else {
         // 如果不存在token,则直接发送刷新请求并更新token
         await refresh();
       }
   }, delay);
   }
 
   stop(): void {
     if (this.timerId !== null) {
       clearInterval(this.timerId);
       this.timerId = null;
     }
   }
 }
// 获取过期时间
 function getExpirationTime(rawToken:string) : number{
   const res = jwtDecode(rawToken)
   
   return res.exp as number
 }

2.2.2更改Login点击风波

只用看新增的方式,其他的都是一些权限跟token等的储存

import { MyTimer } from "@/utils/tokenMonitor"

const submit = () => {
  if (validate()) {
    login(formData)
      .then((data: UserInfoRes) => {
        if (data) {
          // 在这里添加需要执行的操作
          const token = data.token;

          // 将token存储到authStore中
          const authStore = useAuthStore()
          authStore.setToken(token)
          window.sessionStorage.setItem('token', token)
          window.sessionStorage.setItem('refreshToken', data.refreshToken)
          authStore.setIsAuthenticated(true)
          window.sessionStorage.setItem('isAuthenticated''true')
          authStore.setName(data.name)
          authStore.setButtons(data.buttons)
          authStore.setRoles(data.roles)
          authStore.setRouters(data.routers)
  //新增 引入计时器》》》》》》》》》》》》》》》》》》》》》》》》》》》》》》
          const clock = new MyTimer();
          clock.start(1000*30,1000*30);

          init({ message: "logged in success", color: 'success' });
          push({ name: 'dashboard' })

        }
      })
      .catch(() => {
        init({ message: "logged in fail , please check carefully!", color: '#FF0000' });
      });

  }else{
    Message.error('error submit!!')
    return false
  }
}

2.2.3测试

按理来说测试时侯应当没有问题,能正确解析token,而实际运行时侯却报错,未能正确解析token报错

InvalidTokenError:Invalidtokenspecified:invalidjsonforpart#2

而后续换成jwt.verify()使用秘钥来解码同样报错,甚至难以加载出页面,console年报错信息如下

半天这token解析不了就很奇怪了,前面在网上查阅资料的过程中总结下来,因为前端生成的token是通过jjwt这个依赖实现的,对于不同的库底层的编码实现逻辑会有差别造成a库加密生成的token并不能完全被b库的方式来揭秘

找到了缘由,那我们应当怎样获取token中的过期时间呢?可以使用与jjwt相同的实现逻辑库来解码该token或则不妨换个思路,从服务器端下发token的时侯我就带上这个过期时间,这样就省去了后端解码这个步骤,所以就引出了如下最终实现版本

2.3最终定时器版本(实现可以直接看这儿)2.3.1服务器端更改2.3.1.1按照token获取其过期时间

// 获取当前token过期时间 这里不判断是否过期因为是通过了过期判断才进来的
public static Date getExpirationDate(String token) {
    if(StringUtil.isBlank(token))
        return null;

    Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
    return claims.getExpiration();
}

2.3.1.2领取token处携带过期时间

//存放token到请求头中
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token",tokenArray[0]);
// 新增设置过期时间 毫秒数
map.put("tokenExpire",JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken",tokenArray[1]);

同样在refreshToken处也就不是只返回token,也须要带上其过期时间,代码与前面相同就不重复写了

2.3.2更改监控器类MyTimer

最终版本该类中包含这三个属性,分别是

同时使用单例模式全局导入惟一的实例便捷管理,对于前面的token未能解析问题,直接从服务器端获取token的过期时间expire之后与当前时间比较就好啦。

import { refresh } from "@/api/system/auth/index"

class MyTimer {
    private timerId: any | null = null;
    private delay: number; //执行间隔时间
    private minCheck: number; //判断token过期时间是否小于该值

    private static instance: MyTimer;

    public static getInstance(): MyTimer {
      if (!MyTimer.instance) {
        MyTimer.instance = new MyTimer();
      }
      return MyTimer.instance;
    }

    private constructor() {
      this.delay = 30000// Default delay value in milliseconds
      this.minCheck = 60000// Default minCheck value in milliseconds (1 minutes)
    }
 //启动监控器的方法
    start(): void {
      this.timerId = setInterval(async () => {
        const currentToken = window.sessionStorage.getItem('token');
        console.log("timer++++",currentToken)
        if (currentToken) {
          // 如果存在token,判断是否过期
          const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string// 假设有一个函数用于获取token的过期时间
          const expirationTime = parseInt(tokenExpireStr, 10); //以10进制转换string字符串
          const timeRemaining = expirationTime - Date.now();
          console.log("ttime sub++++",timeRemaining)
          if (timeRemaining <= this.minCheck) {
            // 如果剩余时间小于等于minCheck分钟,则异步发送刷新请求并更新token
            try{
              await refresh();
            }catch (error) {
              console.error('刷新失败:', error);
              window.sessionStorage.removeItem('isAuthenticated')
              window.sessionStorage.removeItem('token')
              window.sessionStorage.removeItem('refreshToken')
              Message.error("token reflesh got some ploblem , please login")
              // 跳转到登录页的代码
              window.location.href = '/auth/login';
            }
          }
        } else {
          Message.error("token invalidate , please login")
          // token不存在 则跳转到登录页  
          window.location.href = '/auth/login';
        }
    }, this.delay);

    console.log(this.timerId)
    }
  //关闭监控器的方法
    stop(): void {
      if (this.timerId !== null) {
        clearInterval(this.timerId);
        this.timerId = null;
      }
    }
 //提供设置监控器的刷新间隔和需要刷新的阈值
    setDelay(delay: number): void {
      this.delay = delay;
    }
  
    setMinCheck(minCheck: number): void {
      this.minCheck = minCheck;
    }
  }
//导出全局唯一的实例方便管理
 export const myFilterInstance = MyTimer.getInstance();
// 加到每一个页面上,当页面刷新时候则重启定时器,防止定时器刷掉
 export function onPageRender(){
    // Stop the current timer if it's running
    myFilterInstance.stop();

    // Start the timer with the updated delay and minCheck values
    myFilterInstance.start();
 }

2.3.3onPageRender使用

须要注意最后一个方式onPageRender,因为在测试中发觉当通过导航栏访问的页面情况下会造成定时器给kill掉了,没法刷新token,发送新恳求的时侯才会报错,所以最好的方式是在每位页面上添加onPageRender方式,该方式也很简单就是重启一下定时器,只要给定时器刷新token才能解决前面的问题,!

在页面中添加的代码如下:

import { onPageRender } from '@/utils/tokenMonitor'
// 新增一个监听器,在页面渲染时候执行
window.addEventListener('load', () => {
  onPageRender();
});

2.3.4测试

按照最终的测试结果(右图,读者可以结合代码中输出句子来看)

3.服务器端实现

这些实现方式是在gateway处做拦截判定当前的token是否过期,假如过期则通过WebClient携带refreshToken异步发起恳求到认证服务器更新,下边代码实现了发起恳求到获取数据的过程,并且没有实现原先恳求的再发送(偷个懒,前面再来填坑)

// 向认证服务器发送请求,获取新的token
 Mono newTokenMono = WebClient.create().get()
         .uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL+SecurityAccessConstant.REQUEST_REFRESH
                 , new String[]{"refreshToken", token}))
         .retrieve()
         .bodyToMono(ResultData.class);


 // 原子操作
 AtomicBoolean isPass = new AtomicBoolean(false);
 //订阅数据
 newTokenMono.subscribe(resultData -> {
     if(resultData.getCode() == "200"){
         exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN,
                 SecurityAccessConstant.TOKEN_PREFIX + resultData.getData());
         isPass.set(true);
     }

 }).dispose(); // 销毁资源

 if(isPass.get()){
     // 如果成功获取到资源(新token则发送新请求)
     return chain.filter(exchange.mutate().request().build());
 }

4.如何选择在服务器端实现的益处如下:而在顾客端实现的益处又如下:

可见在不同的场景下实现的方式有所不同,要按照实际需求来决定,常常在一些高精度高安全性的系统中适宜在服务器端做token的刷新,其他场景(比如联通端应用或简单的Web应用等)下可以尝试顾客端实现的方式分担服务器压力

来源:blog.csdn.net/PleaseBeStrong/article/details/138967393

推荐全新学习项目

全新,包括手机端微商城项目和后台管理系统,整个电商购物流程早已能流畅支持,囊括商品浏览、搜索、商品评论、商品尺寸选择、加入购物车、立即订购、下单、订单支付、后台发货、退货等。功能强悍,主流技术栈,特别值得学习。

项目包含2个版本:

线上演示:

从文档到视频、接口调试、学习看板等方面,让项目学习愈发容易,内容愈加沉淀。全套约44小时,共260期,讲解十分详尽饱满。下边详尽为你们介绍:

构架与业务

使用主流的技术构架,真正手把手教你从0到1怎么搭建项目四肢架、项目构架剖析、建表逻辑、业务剖析、实现等。

单体版本:springboot2.7、mybatisplus、rabbitmq、elasticsearch、redis

微服务版本:springcloudalibaba2021.0.5.0,nacos、seata、openFeign、sentinel

后端:vue3.2、elementplus、vantui