每日一博 - 基于 Access Token 和 Refresh Token实现无感刷新

基于双Token实现无感刷新的方案解析

在这里插入图片描述

双 Token 原理概述

  • Access Token:短期有效(如 30 分钟),用于接口鉴权,存放用户身份与权限信息。
  • Refresh Token:长期有效(如 7 天),仅用于在 Access Token 过期后“换取”新的两个 Token。
  • 无感刷新:用户在 Access Token 过期后,客户端自动携带 Refresh Token 请求刷新,服务器验证 Refresh Token 后下发新的 Access/Refresh Token,用户无须重新登录。

登录与颁发流程

  1. 客户端提交用户名/密码 → 服务端验证。

  2. 验证通过后:

    • 创建 Access Token,设置 30 分钟过期;
    • 创建 Refresh Token,设置 7 天过期;
  3. 将两者封装返回给客户端:

    {
      "accessToken": "eyJhbGci…",
      "accessExpire": 162,
      "refreshToken": "eyJhbGc…",
      "refreshExpire": 162}
    

访问拦截与刷新流程

接口访问

  • 客户端在每次请求 Authorization: Bearer <accessToken>

  • 服务端拦截器解析 Access Token:

    • 未过期 → 正常放行;
    • 已过期 → 返回特定状态码 401/511。

无感刷新

  • 客户端拦截到 401/511 → 同步或异步调用刷新接口

    GET /auth/refresh
    Authorization: Bearer <refreshToken>
    
  • 服务端验证 Refresh Token:

    • 合法且未过期 → 重新生成 Access & Refresh Token 并返回;
    • 非法或已过期 → 返回 403 → 客户端跳转登录。
  • 客户端更新本地存储中的两个 Token,并重试原请求。


Fake Code

过滤器(Gateway 或 Spring MVC)

@Component
public class AuthFilter implements Filter {
    @Autowired private JwtUtil jwt;
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest  request  = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;
        String auth = request.getHeader("Authorization");
        if (auth != null && auth.startsWith("Bearer ")) {
            String token = auth.substring(7);
            if (!jwt.isValid(token)) {
                // Access Token 过期,返回 511
                response.setStatus(511);
                return;
            }
        }
        chain.doFilter(req, res);
    }
}

刷新控制器

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired private JwtUtil jwt;
    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@RequestHeader("Authorization") String auth) {
        String refresh = auth.substring(7);
        if (!jwt.isValidRefreshToken(refresh)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        UserInfo info = jwt.parseRefreshToken(refresh);
        String newAccess  = jwt.createAccessToken(info);
        String newRefresh = jwt.createRefreshToken(info);
        return ResponseEntity.ok(Map.of(
            "accessToken",   newAccess,
            "accessExpire",  jwt.getExpire(newAccess),
            "refreshToken",  newRefresh,
            "refreshExpire", jwt.getExpire(newRefresh)
        ));
    }
}

客户端 Axios 拦截

axios.interceptors.response.use(
  res => res,
  async err => {
    if (err.response?.status === 511) {
      const refresh = sessionStorage.getItem("refreshToken")!;
      const resp = await axios.post("/auth/refresh", null, {
        headers: { Authorization: `Bearer ${refresh}` }
      });
      const { accessToken, refreshToken } = resp.data;
      sessionStorage.setItem("accessToken", accessToken);
      sessionStorage.setItem("refreshToken", refreshToken);
      // 重试原请求
      err.config.headers["Authorization"] = "Bearer " + accessToken;
      return axios(err.config);
    }
    return Promise.reject(err);
  }
);

安全性与性能考虑

  • 防重放:Refresh Token 一旦使用即作废,服务端持久化黑名单或单次使用策略。
  • 漏洞防护:将 Refresh Token 存放在 HttpOnly Cookie 中,避免 XSS 泄露。
  • 并发刷新:客户端应保证同一时刻只有一次刷新调用,避免请求风暴。
  • 性能监控:统计刷新接口调用量,异常时及时报警。

深入思考

无感刷新 Token(Silent Refresh)指在用户不感知的情况下,自动更新访问令牌以维持登录状态。本质是用短生命周期的 Access Token 做日常鉴权,用长期有效的 Refresh Token 去刷新 Access Token。要实现无感刷新需要解决以下核心问题:

  • 1:刷新逻辑放在客户端还是服务端?
  • 2:Access Token 过期后,如何得知过期时间?
  • 3:刷新后如何将原请求继续发送并返回调用方?

方案分类

V1 客户端实现

初始版本

思路:每次请求时由 Gateway 过滤器拦截,检测 Access Token 是否过期;过期则返回自定义状态码(如 511),前端拦截到 511 后发起刷新请求,再重发原请求。

Fake Code : Gateway 过滤器(Spring Cloud Gateway)
@Component
public class CustomAccessFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = JWTHelper.getToken(exchange.getRequest().getHeaders().getFirst(TOKEN_HEADER));
        if (token != null && JWTHelper.isOutDate(token)) {
            // 不是刷新接口,则返回 511
            if (!exchange.getRequest().getURI().getPath().equals(REFRESH_URI)) {
                return ResponseUtils.out(exchange, ResultData.fail(511, "Need Refresh"));
            }
        }
        return chain.filter(exchange);
    }
    // ...
}

判断过期使用 jjwt 解析异常捕捉:

public static boolean isOutDate(String token) {
    try {
        Date exp = Jwts.parser()
                       .setSigningKey(signKey)
                       .parseClaimsJws(token)
                       .getBody().getExpiration();
        return exp.before(new Date());
    } catch (JwtException e) {
        return true;
    }
}
Axios 响应拦截器
service.interceptors.response.use(
  resp => resp.data.data,
  async error => {
    const status = error.response?.status;
    if (status === 511) {
      const newToken = await refresh();
      if (newToken) {
        window.sessionStorage.setItem('token', newToken);
        error.config.headers['Authorization'] = 'Bearer ' + newToken;
        return service(error.config);
      }
    }
    return Promise.reject(error);
  }
);

刷新方法 refresh() 需绕过自身拦截器,直接用原生 Axios 发请求,防止死循环。

问题
  • 刷新是异步的,原请求已经结束,第二次请求结果无法传回调用处。
  • 页面上一秒发多次请求时,还可能并发触发多次刷新。

V2 改进版本:定时器提前刷新

思路:在用户登录后启动定时器,周期性检查 Access Token 剩余时长,当低于阈值时(如 1 分钟),提前异步刷新。

定时器类

export class MyTimer {
  private timerId: any = null;
  start(delay: number, minCheck: number) {
    this.timerId = setInterval(async () => {
      const token = sessionStorage.getItem('token');
      const exp = getExpirationTime(token!);
      if (exp - Date.now() <= minCheck) {
        await refresh();
      }
    }, delay);
  }
  stop() { clearInterval(this.timerId); }
}
function getExpirationTime(token: string): number {
  return jwtDecode<{ exp: number }>(token).exp * 1000;
}

在登录成功后启动:

login().then(data => {
  sessionStorage.setItem('token', data.token);
  const timer = new MyTimer();
  timer.start(30_000, 60_000);
});

问题与思考

  • 前端依赖 jwt-decode 库解析,与后端生成库可能不兼容,导致解码失败。
  • 依赖前端解析不够安全、可靠。

V3最终版本:服务端携带过期时间 + 全局单例定时器

服务器端修改

在颁发 Token 时,同时返回其过期时间戳:

String token = ...;
Date exp = JWTHelper.getExpirationDate(token);
map.put("token", token);
map.put("tokenExpire", exp.getTime());
map.put("refreshToken", refreshToken);

单例定时器实现

class MyTimer {
  private timerId: any = null;
  private delay = 30_000;
  private minCheck = 60_000;
  private static instance: MyTimer;
  static getInstance() {
    return this.instance || (this.instance = new MyTimer());
  }
  start() {
    this.timerId = setInterval(async () => {
      const exp = +sessionStorage.getItem('tokenExpire' )!;
      if (exp - Date.now() <= this.minCheck) {
        try { await refresh(); }
        catch { 
          sessionStorage.clear(); 
          window.location.href = '/auth/login'; 
        }
      }
    }, this.delay);
  }
  stop() { clearInterval(this.timerId); }
}
export const tokenMonitor = MyTimer.getInstance();

在每个页面渲染时调用,确保定时器不中断:

window.addEventListener('load', () => tokenMonitor.stop(), tokenMonitor.start());

服务器端实现(集中式)

另一种思路是在 Gateway 端完成刷新并重写原请求:

Mono<Boolean> refreshed = WebClient.create()
  .get().uri(authServer + "/refresh?...").retrieve()
  .bodyToMono(Boolean.class)
  .doOnNext(ok -> { if (ok) exchange.getRequest()
      .mutate().header("Authorization", newToken).build(); })
  .map(ok -> ok);
if (refreshed.block()) {
  return chain.filter(exchange);
}

优势:隐藏刷新逻辑,客户端无需感知。缺陷:复杂度高,需要同步阻塞或重构 Reactive 逻辑。


方案对比与选型建议

方案优势劣势
客户端响应拦截刷新简单、灵活异步时难以回传原请求结果
客户端定时器刷新连续性好、无感需前端维护定时器,兼容性和安全问题
服务端集中式刷新隐藏复杂度、安全实现复杂、需要拦截并重写请求

选型建议

  • 高安全、高一致性:优先考虑服务端集中式刷新。
  • 简单 Web 应用/移动端:客户端定时器方案足够,并可分担服务器压力。

总结与最佳实践

  • 统一返回:服务端在颁发 Token 时携带过期时间,前端直接使用该字段,无需解码。
  • 全局单例:客户端定时器使用单例模式,全局统一管理,防止多实例冲突。
  • 页面无感:在页面加载时启动监控,确保跨路由无缝刷新。
  • 异常降级:刷新失败应清理状态并跳回登录,以免死循环。
  • 性能监控:可统计刷新调用次数并报警,避免频繁刷新带来额外压力。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值