当前位置: 欣欣网 > 码农

SpringBoot 如何做到无感刷新 Token?

2024-07-08码农

大家好,我是磊哥。

1. 前言

面试遇到一个鉴权认证服务器问题,其中有个问题就是token的无感刷新。Token无感刷新是一种在用户不感知的情况下自动更新访问令牌(Token)的机制,以维持用户的登录状态。

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

  • Q1: 是要在服务器端实现还是能在客户端实现?

  • Q2: token过期后无法解析,怎么获取到其中的过期时间?

  • Q3: 无感刷新即是需要在获取到新token后重发原来的request请求,并将二次请求的结果返回给原调用者,如何实现?

  • 下面我就对上面这些问题给出我自己的拙见,希望能对读者有所帮助😁

    2. 客户端实现

    2.1 初始版本

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

    过期则返回一个特定的状态码(可以自定义也可以用HTTPStatus)告诉客户端当前token失效

    如果你近期准备面试跳槽,建议在ddkk.com在线刷题,涵盖 一万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题,还有市面上最全的技术五百套,精品系列教程,免费提供。

    没过期则放行,继续原本的业务逻辑

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

    2.1.1 服务器端gateway实现拦截器

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

    @Component
    public classMyAccessFilterimplementsGlobalFilterOrdered
    {
    @Override
    public Mono<Void> 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
    publicintgetOrder(){
    //数值越小 优先级越高
    return Ordered.LOWEST_PRECEDENCE;
    }
    }







    2.1.1.1 问题Q2解决

    正常情况下解析的token会报错,那么就在解析的时候拦截错误,如果catch 到 JwtException ,此时就认为该token无效已经过期了返回true

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

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

    2.1.2 axios拦截器

    在拦截器中,我们使用判断响应码,如果是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){
    case401// 无权限
    authStore.reset() // 清空store中的权限数据
    window.sessionStorage.removeItem('isAuthenticated')
    window.sessionStorage.removeItem('token')
    window.sessionStorage.removeItem('refreshToken')
    message = 'token 失效,请重新登录'
    // 跳转到登录页
    window.location.href = '/auth/login';
    break;
    case511// 当前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.3 refresh刷新token方法实现

    这里实现是重新用axios原生发异步请求,而不是使用在 request.ts 中导出的请求方法(因为里面定义了请求拦截器,每次请求之前都会取出token并放到请求头,这就又变成请求头中携带的token无效了,导致重复发送刷新请求进入死循环,所以不能这样做)

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

    exportasyncfunctionrefresh() : 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太长了,所以拆分两张图片更容易看一点,从左边的图开始分析

    在发起第一次请求后,后端gateway拦截器报错 511 (是不是就是对应上面case 511 此时应该用 refresh token 刷新)

    in ?>> 进来refresh方法的逻辑,成功打印出 refreshToken 以suvm-EgQ结尾(是不是跟上面 refreshToken 相同)

    紧接着就是 输出 刷新token成功 此时返回的是刷新后的token,将其覆盖新的token并重新发送请求

    到这里左图分析完毕,进入右图的分析(肯定有读者疑惑你这黄色的warn咋不讲)别急这块我会和右图的红色error一起讲解

    紧接上面,用新的token发送请求,此时在请求拦截器处捕获到的token是不是就是更新好的 以 V0dYcMA 结尾,而 refreshToken 则以 suvm-EgQ 结尾(得出结论 refreshToken 用做刷新,但本身并不刷新)

    此时捕获到 Uncaught error status 511 这不就是我们一开始的报错吗? 其实就是这样的,原来的按钮点击事件调用 getAllUser 方法已经结束!!! 返回的结果是error 即是这里的511(把左右三个有颜色的块拼起来一起看就懂了)而由于refresh方法是异步调用的所以其执行顺序穿插在其中

    最后返回结果可以看到已经没有上面注意部分提到的>>>>>输出内容,令通过更新好的token发送二次请求得到的结果记作data,此时的data已经不能返回原来的 getAllUser 方法调用处,因为原来的方法已经结束, 通俗点话说就是这样的二次调用结果毫无意义,用户还是需要刷新网页或者二次点击以获取资源

    这就是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  classMyTimer{
    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!!')
    returnfalse
    }
    }



    2.2.3 测试

    按理来说测试时候应该没有问题,能正确解析token,而实际运行时候却报错,无法正确解析token报错

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

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

    半天这token解析不了就很奇怪了,后面在网上查阅资料的过程中总结出来,由于后端生成的token是通过jjwt这个依赖实现的,对于不同的库底层的编码实现逻辑会有差异导致a库加密生成的token并不能完全被b库的方法来解密

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

    如果你近期准备面试跳槽,建议在ddkk.com在线刷题,涵盖 一万+ 道 Java 面试题,几乎覆盖了所有主流技术面试题,还有市面上最全的技术五百套,精品系列教程,免费提供。

    2.3 最终定时器版本(实现可以直接看这里)

    2.3.1 服务器端修改

    2.3.1.1 根据token获取其过期时间

    // 获取当前token过期时间 这里不判断是否过期因为是通过了过期判断才进来的
    publicstatic Date getExpirationDate(String token){
    if(StringUtil.isBlank(token))
    returnnull;
    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

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

    timerId: 定时器的唯一ID

    delay: 定时器执行的间隔时间

    minCheck: 判断token过期时间是否小于该值,小于则需执行 refresh() 方法来刷新token。

    同时使用单例模式全局导出唯一的实例方便管理,对于上面的token无法解析问题,直接从服务器端获取token的过期时间expire然后与当前时间比较就好啦。

    import { refresh } from "@/api/system/auth/index"
    classMyTimer{
    private timerId: any | null = null;
    private delay: number; //执行间隔时间
    private minCheck: number; //判断token过期时间是否小于该值
    privatestatic instance: MyTimer;
    publicstaticgetInstance(): MyTimer {
    if (!MyTimer.instance) {
    MyTimer.instance = new MyTimer();
    }
    return MyTimer.instance;
    }
    privateconstructor(){
    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.3 onPageRender 使用

    需要注意最后一个方法 onPageRender ,由于在测试中发现当通过导航栏访问的页面情况下会导致定时器给kill掉了,无法刷新token,发送新请求的时候就会报错,所以最好的方法是在每个页面上添加 onPageRender 方法,该方法也很简单就是重启一下定时器,只要给定时器刷新token就能解决上面的问题

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

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

    2.3.4 测试

    根据最终的测试结果(下图,读者可以结合代码中输出语句来看)

    可以看到红色的框框就是进入监控器输出的内容,每次进入都会比对token的过期时间判断是否小于阈值(刷新完后还会用新的过期时间继续比较)

    当小于阈值(这里设置1min = 60000ms)则进入refresh逻辑,这个就是上面讲到的内容,一样样的,这样就保证每次刷新携带的token大概率都是最新的!!!😁到此客户端实现功能已经全部讲完啦

    3. 服务器端实现

    这种实现方法是在gateway处做拦截判断当前的token是否过期,如果过期则通过WebClient携带 refreshToken 异步发起请求到认证服务器更新,下面代码实现了发起请求到获取数据的过程,但是没有实现原来请求的再发送(偷个懒,后面再来填坑)

    // 向认证服务器发送请求,获取新的token
     Mono<ResultData> 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刷新可以更好地控制和保护token的安全性,避免将敏感信息暴露给客户端

    减少客户端逻辑: 客户端无需过多关注token刷新逻辑,降低了客户端的复杂性和维护成本。

    集中管理: 所有用户的token刷新逻辑集中在服务器端,方便统一管理和调整。

    解决一致性问题: 用户端刷新token可能导致不同客户端之间的状态不一致,比如一个设备刷新了token而另一个设备未刷新,可能会出现异常情况。

    而在客户端实现的好处又如下:

    即时性: 客户端自动监控可以实现实时监测token的有效性,并及时触发刷新,确保用户操作的流畅性和体验。

    离线支持: 对于需要离线访问或长时间不与服务器通信的应用场景,客户端自动监控可以更好地处理token失效情况。

    灵活性: 某些特定场景下,客户端可能更容易实现对token状态的监控和处理,例如需要根据用户行为动态调整token刷新策略等。

    减轻服务器压力: 用户端刷新token可以减少服务器负担,尤其对于大量用户同时刷新token时,可分散处理压力。

    可见在不同的场景下实现的方法有所不同,要根据实际需求来决定,往往在一些高精度高安全性的系统中适合在服务器端做token的刷新,其他场景(例如移动端应用或简单的 Web 应用等)下可以尝试客户端实现的方法分担服务器压力

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

    🔥 磊哥私藏精品 热门推荐 🔥