商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > SpringBoot实现无感刷新token的具体方法

SpringBoot实现无感刷新token的具体方法

  发布于2026-05-29 阅读(0)

扫一扫,手机访问

前言

Token无感刷新,简单说,就是让用户在无感知的情况下,自动把访问令牌(Token)更新一下,维持住登录状态。听起来挺美,但真正动手做起来,坑不少。

常见的做法是:用短期的token做权限认证,再配一个更长期的refreshToken专职负责刷新。但这条路走下来,总会撞上几个经典问题:

  • Q1: 这事儿到底该在服务端干,还是客户端就能搞定?
  • Q2: token过期后解析不了,那它里面的过期时间咋取出来?
  • Q3: 无感刷新的核心是——拿到新token后,得把之前失败的请求重新发出去,还得把结果正确返给调用方。这个流程怎么接得上?

下面针对这些问题,梳理一下可以参考的思路。

一、客户端实现

1.1 初始版本

思路是这样:每次客户端发请求,服务端的gateway先拦下来,判断token是否过期。过期了,返回一个特定的状态码(比如511)通知客户端;没过期,放行,正常走业务逻辑。

前端那边呢,拦截响应状态码,根据码值决定下一步动作。于是,axios拦截器就登场了。

1.1.1 服务端gateway拦截器

环境是SpringBoot3 + Ja va17,通过继承GlobalFilter来实现过滤逻辑。

@Component
public class MyAccessFilter implements GlobalFilter, Ordered
{
    @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;
    }
}

1.1.1.1 解答Q2:怎么获取过期时间?

正常情况下解析token会报错。所以在解析的时候截获异常,如果catch到JwtException,就认为token无效过期了,返回true。否则正常解析,取出过期时间和当前时间比较。

// 判断当前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) {
        return true;
    }
}

1.1.2 axios拦截器

在拦截器里,根据响应码做判断。401就清空用户数据,跳到登录页;511就用refreshToken去刷新。

// 响应拦截器
service.interceptors.response.use(
  function(response) {
    console.log(response)
    return response.data.data;
  },
  async function(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()
        window.sessionStorage.removeItem('isAuthenticated')
        window.sessionStorage.removeItem('token')
        window.sessionStorage.removeItem('refreshToken')
        message = 'token 失效,请重新登录'
        window.location.href = '/auth/login';
        break;
      case 511:
        try {
          const data = refresh()
          if(data !== null){
            data.then((value) => {
              if(value !== ''){
                console.log("刷新 token 成功", value);
                window.sessionStorage.setItem("token",value)
                error.config.headers['Authorization'] = 'Bearer ' +value;
                return service(error.config);
              }
              console.log(value);
            }).catch((error) => {
              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);
  }
);

1.1.3 refresh刷新token的方法

这里直接用axios原声发异步请求,而不是用request.ts里导出的方法。因为那个方法自带请求拦截器,每次请求前都会自动带上token——但这里要刷新token,老token已经失效了,再用它就会死循环。

/**
 * 刷新token 
 * 成功返回新token,失败返回空字符串''
 */
export async function refresh() : Promise{
  const refreshToken = window.sessionStorage.getItem("refreshToken")
  console.log("in >>> " ,refreshToken)
  if(refreshToken == undefined)
      return ''
  
  try {
    const response = await axios({
      method: 'GET',
      url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',
      headers: {
        Authorization: `Bearer ${refreshToken}`,
      },
    });
    if (response.data) {
      return response.data.data;
    } else {
      return '';
    }
  } catch (error) {
    console.log(error);
    return '';
  }
}  

1.1.4 正常与异常情况下的控制台输出分析

代码里塞了不少console.log,跑起来能更直观地看流程。结合测试结果一起分析。

下面的截图是正常情况。注意这里token以hizFIGg结尾,refreshTokensuvm-EgQ结尾。正常返回就是200 OK。

SpringBoot实现无感刷新token的具体方法

再看看异常情况。因为token太长拆了两张图。从左图开始分析:

  • 发起第一次请求后,后端gateway拦截器报511(对应case 511,触发刷新流程)。
  • in >>>进入refresh方法,成功打出refreshToken,仍是suvm-EgQ结尾。
  • 接着打出“刷新token成功”,返回的是新token,用它替换旧的并重发请求。

左图分析完毕,进入右图:

  • 用新token发请求,请求拦截器捕获到的token已更新,以V0dYcMA结尾;refreshToken仍是suvm-EgQ(说明refreshToken只用来刷新,本身不刷新)。
  • 但是注意到“Uncaught error status 511”——这不就是一开始的错误吗?实际上,原按钮点击事件调用的getAllUser方法已经结束,返回的结果就是那个511 error。而refresh是异步调用,它的执行穿插在里面。

SpringBoot实现无感刷新token的具体方法

SpringBoot实现无感刷新token的具体方法

最终结果里,没有出现之前“>>>>>”那部分输出。用新token发第二次请求得到的数据,已经无法返回到原来的getAllUser方法调用处——因为原方法早就结束了。通俗点说,这个二次调用结果毫无意义,用户还是得手动刷新页面或再点一次。

这就是Q3的核心问题:异步调用导致原方法提前终止。如果改成阻塞式调用,响应会变慢,影响用户体验。有更好的想法欢迎讨论。

1.2 改进版本

既然异步路子走不通,换个思路如何:不在失败时去刷新,而是提前检查本地存储的token有没有过期。如果发现token剩余时间小于某个临界点,就主动异步调用刷新方法,更新本地token。这样一来,只要服务端gateway拦截到token失效的请求,直接要求重新登录就行。关键点在于引入一个定时器。

在TypeScript里,定时器主要通过setIntervalsetTimeout实现。这里用setInterval,按指定间隔重复执行某段逻辑。

setInterval被调用时,它从被调用那一刻开始计时,每隔指定毫秒数就执行一次。所以这个定时器应该在登录成功后启动。

1.2.1 定义定时器类

通过这个类,MyTimer.start方法启动setInterval,每隔delay毫秒检查一次,判断当前token剩余时间是否小于minCheck,如果小于,就异步刷新。

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

export class MyTimer {
  private timerId: any | null = null;
  
   start(delay: number, minCheck : number): void {
     this.timerId = setInterval(async () => {
       const currentToken = window.sessionStorage.getItem('token');
       console.log("timer++++")
       if (currentToken) {
         let expirationTime = 0;
         expirationTime = getExpirationTime(currentToken);
         const timeRemaining = expirationTime - Date.now();
         if (timeRemaining <= minCheck) {
           await refresh();
         }
       } else {
         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
 }

1.2.2 修改Login点击事件

只关注新增的定时器部分,其他是权限和token的常规存储逻辑。

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

const submit = () => {
  if (validate()) {
    login(formData)
      .then((data: UserInfoRes) => {
        if (data) {
          const token = data.token;
          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
  }
}

1.2.3 测试

照理说应该没问题,能正确解析token。但一跑就报错了:

InvalidTokenError: Invalid token specified: invalid json for part #2

换成jwt.verify()用密钥解码也不行,页面都加载不出来。

SpringBoot实现无感刷新token的具体方法

翻了不少资料后总结出原因:后端token是用jjwt库生成的,不同库的底层编码实现有差异,导致a库加密的token无法被b库解密。

找到了病根,解决方案就清晰了:要么用与jjwt逻辑一致的库来解码,要么换个思路——服务端下发token时,直接把过期时间也带上。前端省去解码这一步,问题迎刃而解。下面引出最终版本。

1.3 最终定时器版本

1.3.1 服务端修改

1.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();
}

1.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接口返回时也带上新的过期时间。

1.3.2 修改监控器类MyTimer

最终版本的类包含三个属性:timerId(定时器唯一ID)、delay(执行间隔)、minCheck(过期时间阈值)。采用单例模式,全局导出一份实例便于管理。token的过期时间直接从服务端获取,和当前时间比较即可。

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

class MyTimer {
    private timerId: any | null = null;
    private delay: number;
    private minCheck: number;

    private static instance: MyTimer;

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

    private constructor() {
      this.delay = 30000;
      this.minCheck = 60000;
    }

    start(): void {
      this.timerId = setInterval(async () => {
        const currentToken = window.sessionStorage.getItem('token');
        console.log("timer++++",currentToken)
        if (currentToken) {
          const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string
          const expirationTime = parseInt(tokenExpireStr, 10);
          const timeRemaining = expirationTime - Date.now();
          console.log("ttime sub++++",timeRemaining)
          if (timeRemaining <= this.minCheck) {
            try{
              await refresh();
            } catch (error) {
              console.error('刷新失败:', error);
              window.sessionStorage.removeItem('isAuthenticated')
              window.sessionStorage.removeItem('token')
              window.sessionStorage.removeItem('refreshToken')
              Message.error("token refresh got some problem, please login")
              window.location.href = '/auth/login';
            }
          }
        } else {
          Message.error("token invalidate, please login")
          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(){
    myFilterInstance.stop();
    myFilterInstance.start();
}

1.3.3 onPageRender 的使用

注意最后这个方法。测试中发现,通过导航栏切换页面时,定时器会被kill掉,导致token无法刷新,新请求会报错。因此最好在每个页面挂载时调用onPageRender,重启定时器,确保token一直有“人”照看。

import { onPageRender } from '@/utils/tokenMonitor'

window.addEventListener('load', () => {
  onPageRender();
});

1.3.4 测试

最终的测试结果如下(可以结合代码中的输出语句来看):

  • 红色框框是监控器触发后输出的日志,每次都会比对token的过期时间是否小于阈值。刷新后还会用新的过期时间继续比较。

一旦小于阈值(这里设置1分钟 = 60000ms),就进入refresh逻辑,和上面讲的一样。这样就能保证每次请求携带的token大概率都是最新的。客户端实现的思路到这里就完整讲完了。

SpringBoot实现无感刷新token的具体方法

2. 服务端实现

这种思路是:在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()){
     return chain.filter(exchange.mutate().request().build());
}

3. 怎么选择

3.1 服务端实现的好处

  • 安全性更高: token刷新逻辑在服务端,敏感信息不暴露给客户端。
  • 客户端更轻量: 无需关心刷新逻辑,减少了维护成本。
  • 集中管理: 所有用户的刷新统一在服务端处理,方便调整。
  • 解决一致性问题: 避免多客户端间的token状态不一致。

3.2 客户端实现的好处

  • 即时性好: 客户端实时监控token有效性,触发刷新,保证操作流畅。
  • 离线支持: 适合需要离线访问或长时间不与服务端通信的场景。
  • 更灵活: 可以根据用户行为动态调整刷新策略。
  • 分担服务端压力: 大量用户同时刷新时,压力分散到客户端。

不同场景下,选择也不同。在高精度、高安全要求的系统中,服务端刷新更稳妥;而在移动端或简单Web应用中,客户端方案能有效分担服务器压力。具体选哪个,还得看实际需求。

本文转载于:https://www.jb51.net/program/364791h39.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注