双 Token 机制下的无感刷新(Refresh Token)后端实现

AccessToken 过期的三种实践场景

假设 AccessToken 有效期 30 分钟,RefreshToken 有效期 30 天。

场景 1:关闭网页,短时间内(如 5 分钟)又打开

  • 表现 :直接进入。
  • 原理 :前端从存储中读取 AccessToken 发送请求。后端 Filter 发现 Token 签名正确且未过期(只过了 5 分钟),校验通过,用户无需重新登录。

场景 2:关闭网页,过了一天又打开

  • 表现 :页面闪烁一下(自动刷新),正常进入。
  • 原理:
  1. 前端发送旧 AccessToken,后端返回 401 Unauthorized。
  2. 前端 Axios 拦截器捕获 401,自动读取 RefreshToken 调用刷新接口。
  3. 后端验证 RefreshToken 有效,返回新 AccessToken。
  4. 前端用新 Token 重新发起业务请求,用户感知不到重新登录的过程。

场景 3:打开网页并持续浏览超过 30 分钟

  • 表现 :无感续期。
  • 原理:
  • 在第 31 分钟时,发出的业务请求会因 AccessToken 过期报 401。
  • 前端拦截器发起"静默刷新",获取新 Token 并继续完成刚才的操作。
  • 用户在操作过程中几乎无察觉,流程与场景 2 类似。


深度解析:双 Token 机制下的无感刷新(Refresh Token)后端实现

1. 为什么要设计刷新令牌?

在 JWT 架构中,AccessToken 通常设置较短的有效期(如 30 分钟),目的是降低泄露后的风险。但如果直接让用户每 30 分钟登录一次,体验会崩溃。
刷新令牌(RefreshToken) 的存在,就是为了在不暴露用户账号密码的前提下,实现"令牌续期",平衡安全与体验。


2. 后端核心实现流程

基于代码逻辑,后端处理 refresh_token 请求时遵循以下严谨步骤:

第一步:合法性与归属权校验

  • 令牌存在性 :首先通过字符串查询数据库/Redis,确认该 refreshToken 是由系统颁发的。
  • 客户端匹配 :校验请求携带的 clientId 是否与令牌记录中的编号一致。

安全要点:防止攻击者拿着 A 系统的刷新令牌去请求 B 系统的访问令牌(多租户/多应用场景下的关键防御)。

第二步:旧令牌的"彻底清理"(幂等性保证)

  • 双写删除 :在生成新令牌前,系统会查出该 refreshToken 关联的所有旧 accessToken。
  • 同步清理 :同时从 MySQL 和 Redis 中删除这些旧访问令牌。

目的:确保一个刷新令牌在同一时刻只对应一个有效的访问令牌,防止产生孤儿 Token 占用空间或造成安全隐患。

第三步:过期判定与自毁

  • 逻辑检查 :判断 refreshToken 的 expiresTime 是否早于当前时间。
  • 过期处理 :一旦发现刷新令牌也过期了,后端会物理删除 该刷新令牌,并抛出 UNAUTHORIZED 异常。

结果:此时前端拦截器会引导用户跳转至登录页,完成"30天"后的重新认证。

第四步:新令牌的"以旧换新"

  • 调用创建方法,生成全新的 accessToken,并重新存入缓存,返回给前端。

3. 核心代码

java 复制代码
    @Override
    @Transactional(rollbackFor = Exception.class)
    public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
        // 查询访问令牌
        OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
        if (refreshTokenDO == null) {
            throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "无效的刷新令牌");
        }

        // 校验 Client 匹配
        OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);
        if (ObjectUtil.notEqual(clientId, refreshTokenDO.getClientId())) {
            throw exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), "刷新令牌的客户端编号不正确");
        }

        // 移除相关的访问令牌
        List<OAuth2AccessTokenDO> accessTokenDOs = oauth2AccessTokenMapper.selectListByRefreshToken(refreshToken);
        if (CollUtil.isNotEmpty(accessTokenDOs)) {
            oauth2AccessTokenMapper.deleteByIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId));
            oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken));
        }

        // 已过期的情况下,删除刷新令牌
        if (DateUtils.isExpired(refreshTokenDO.getExpiresTime())) {
            oauth2RefreshTokenMapper.deleteById(refreshTokenDO.getId());
            throw exception0(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "刷新令牌已过期");
        }

        // 创建访问令牌
        return createOAuth2AccessToken(refreshTokenDO, clientDO);
    }

4. 技术亮点分析(面试/博客加分项)

1. 事务管理 (@Transactional)

在刷新过程中涉及"删旧"与"增新"两个数据库操作,必须保证原子性。如果新令牌生成失败,旧令牌的删除动作应该回滚。

2. 缓存与持久化双校验

  • MySQL 负责持久化存储,作为"真值来源"。
  • Redis 负责高性能校验,供 TokenAuthenticationFilter 快速读取。
    这种设计既保证了系统在高并发下的响应速度,又保证了数据不丢失。

3. 安全防范:令牌轮转 (Token Rotation)

虽然此处的 refreshToken 是复用的,但每次刷新都会注销掉之前所有的 accessToken。这在一定程度上防止了令牌被截获后的长期滥用。


5. 总结:前端如何配合?

后端逻辑写得再好,也需要前端的**响应拦截器(Response Interceptor)**配合:

  1. 监控到业务接口返回 401(AccessToken 过期)。
  2. 进入"静默重试"队列,调用后端的 refresh-token 接口。
  3. 获取新 Token 后,自动替换本地存储,并原路重发刚才失败的业务请求。
相关推荐
全栈弄潮儿15 分钟前
Python实战第9期:文件操作
后端·python·编程语言
神魔皆以血嗜20 分钟前
Java 实现简单状态机(订单状态流转)
java·后端
jiushidt24 分钟前
在 DBeaver 中为 SQLite 启用 SpatiaLite
数据库·sqlite
zz在掘金3370828 分钟前
MongoDB 脚本速查
后端
随性而行36029 分钟前
企业微信 API 如何实现业务消息自动通知?接口应用场景解析
java·人工智能·后端·微信·机器人·企业微信
Mav29 分钟前
[个人学习记录]从零构建高性能 LLM 推理网关:长连接优雅停机(Graceful Drain)、K8s 探针联动与 Context 底层哲学
后端
团子哒哒31 分钟前
变量、常量和数据类型
后端
zz在掘金3370832 分钟前
Redis 脚本速查
后端
初见月36 分钟前
适配器模式详解+对象透传治理
后端
码士集团小青43 分钟前
Google 开源了啥,让 AI Agent 碰数据库不再是定时炸弹
数据库·oracle