前端双Token机制无感刷新(二)

前端双Token机制无感刷新原理与关键点详解

本文档深入剖析前端双Token(Access Token + Refresh Token)无感刷新机制的设计原理、完整流程、核心实现细节及常见踩坑点,帮助开发者构建安全且用户体验无中断的鉴权体系。


目录

  1. 为什么需要双Token机制
  2. [Access Token 与 Refresh Token 的角色分工](#Access Token 与 Refresh Token 的角色分工 "#2-access-token-%E4%B8%8E-refresh-token-%E7%9A%84%E8%A7%92%E8%89%B2%E5%88%86%E5%B7%A5")
  3. 完整刷新流程
  4. 核心实现:请求拦截器与响应拦截器
  5. 并发请求处理:如何避免重复刷新
  6. [Token 存储策略](#Token 存储策略 "#6-token-%E5%AD%98%E5%82%A8%E7%AD%96%E7%95%A5")
  7. 安全考量
  8. 与后端的协作约定
  9. 完整代码示例
  10. 常见踩坑与最佳实践

1. 为什么需要双Token机制

1.1 单Token的困境

传统单Token方案存在两难选择:

策略 优点 缺点
Token 有效期短(如 15min) 泄露后影响窗口小 用户频繁掉线,体验差
Token 有效期长(如 7 天) 用户不用频繁登录 一旦泄露,长时间可用

1.2 双Token解决思路

核心思想:将"鉴权"和"续期"两个职责分离到两个不同的Token上。

arduino 复制代码
Access Token  →  短有效期(15min ~ 2h)  →  用于鉴权,每次请求携带
Refresh Token →  长有效期(7d ~ 30d)    →  仅用于换取新的 Access Token

一句话总结: Access Token 过期了,前端用 Refresh Token 静默换一个新的,用户完全无感知。


2. Access Token 与 Refresh Token 的角色分工

2.1 Access Token

makefile 复制代码
定位:    一线战士,直接参与每次战斗
内容:    通常为 JWT,携带用户标识、权限等
有效期:  短(分钟级)
携带方式:Authorization: Bearer <access_token>
暴露频率:每次请求都发送,暴露面大
泄露后果:影响窗口仅数分钟,风险可控

2.2 Refresh Token

复制代码
定位:    后勤补给,不参与战斗,只负责签发新Token
内容:    通常为随机字符串(不透明Token),不含业务信息
有效期:  长(天/周级)
携带方式:仅刷新接口使用,请求体中传递
暴露频率:极低(仅在Access Token过期时使用一次)
泄露后果:可长期伪造身份,需配合其他机制防护

2.3 为什么Refresh Token不能替代Access Token

markdown 复制代码
安全层面:
  - Access Token 随每次请求发送,暴露面极大
  - 如果 Refresh Token 替代 Access Token 高频传输,泄露风险等同单Token长有效期

性能层面:
  - Access Token(JWT)可本地解析校验,无需查库
  - Refresh Token 每次使用必须查库验证是否被撤销
  - 高频查库带来不必要的性能开销

3. 完整刷新流程

3.1 正常请求流程

kotlin 复制代码
┌─────────┐         ┌──────────┐         ┌─────────┐
│  前端    │         │  网关/BFF │         │ 后端服务  │
└────┬────┘         └────┬─────┘         └────┬────┘
     │  请求 + Access    │                    │
     │  Token (有效)     │                    │
     ├──────────────────►│                    │
     │                   │  转发 + Access     │
     │                   │  Token             │
     │                   ├───────────────────►│
     │                   │                    │
     │                   │    200 OK + data   │
     │                   │◄───────────────────┤
     │   200 OK + data   │                    │
     │◄──────────────────┤                    │
     │                   │                    │

3.2 Token过期刷新流程

scss 复制代码
┌─────────┐         ┌──────────┐         ┌─────────┐
│  前端    │         │  网关/BFF │         │ 后端服务  │
└────┬────┘         └────┬─────┘         └────┬────┘
     │  请求 + Access    │                    │
     │  Token (已过期)   │                    │
     ├──────────────────►│                    │
     │                   │  401 Unauthorized  │
     │  401 Unauthorized │◄───────────────────┤
     │◄──────────────────┤                    │
     │                   │                    │
     │  请求 + Refresh   │                    │
     │  Token (刷新接口)  │                    │
     ├──────────────────►│                    │
     │                   ├───────────────────►│
     │                   │  校验 Refresh      │
     │                   │  Token 有效性      │
     │                   │◄───────────────────┤
     │  新 Access Token  │                    │
     │  + 新 Refresh     │                    │
     │  Token (可选)     │                    │
     │◄──────────────────┤                    │
     │                   │                    │
     │  用新 Access      │                    │
     │  Token 重试原请求  │                    │
     ├──────────────────►│                    │
     │                   ├───────────────────►│
     │                   │                    │
     │   200 OK + data   │                    │
     │◄──────────────────┤                    │
     │                   │                    │

3.3 Refresh Token也过期

复制代码
前端发起刷新请求 → 后端返回 401(Refresh Token 也过期)
→ 清除本地 Token → 跳转登录页

4. 核心实现:请求拦截器与响应拦截器

4.1 架构示意

vbscript 复制代码
用户代码
   │
   ▼
请求拦截器(Request Interceptor)
   │  └─ 给每个请求自动注入 Access Token
   ▼
HTTP 请求
   │
   ▼
响应拦截器(Response Interceptor)
   │  ├─ 200:直接返回
   │  ├─ 401:触发刷新逻辑
   │  │     ├─ 成功 → 更新Token → 重试原请求
   │  │     └─ 失败 → 清除Token → 跳转登录
   │  └─ 其他:直接 reject
   ▼
用户代码拿到结果

4.2 关键判断逻辑

markdown 复制代码
拦截器收到 401 后的决策树:

401 状态码
  │
  ├─ 请求URL是否是刷新接口本身?
  │    ├─ 是 → 直接 reject(避免死循环)
  │    └─ 否 → 继续判断
  │
  ├─ 当前是否正在刷新中?
  │    ├─ 是 → 将当前请求加入等待队列,等刷新完成后重试
  │    └─ 否 → 发起刷新请求
  │
  └─ 发起刷新 → 结果?
       ├─ 成功 → 更新本地Token → 重试原请求
       └─ 失败 → 清除Token → 跳转登录

5. 并发请求处理:如何避免重复刷新

5.1 问题场景

假设页面上同时发起了 5 个接口请求,而 Access Token 恰好过期:

yaml 复制代码
时间线:
  t0: 请求A发送 → 401
  t1: 请求B发送 → 401(刷新还未完成)
  t2: 请求C发送 → 401
  t3: 请求D发送 → 401
  t4: 请求E发送 → 401

如果不在拦截器层面做并发控制,会同时发起 5 次刷新请求,造成:

  • 后端压力倍增
  • 多个刷新请求可能互相覆盖Token,导致状态混乱
  • 浪费资源

5.2 解决方案:Promise缓存 + 请求队列

核心思路:用一个全局Promise缓存正在进行的刷新操作,让后续401请求等待同一个刷新Promise完成,而不是各自发起刷新。

css 复制代码
┌───────────────────────────────────────────┐
│              并发请求处理模型               │
├───────────────────────────────────────────┤
│                                           │
│   请求A → 401 → 发起刷新                    │
│                │                          │
│                │  refreshPromise = 刷新请求 │
│                │                          │
│   请求B → 401 → 检测到刷新中                │
│                │                          │
│                │  加入等待队列               │
│                │  return refreshPromise    │
│                │  .then(() => 重试B)       │
│                │                          │
│   请求C → 401 → 同B                        │
│   请求D → 401 → 同B                        │
│   请求E → 401 → 同B                        │
│                │                          │
│   刷新成功 ←───┘                          │
│   更新Token                               │
│   refreshPromise = null                   │
│   逐一重试 B、C、D、E                       │
│                                           │
└───────────────────────────────────────────┘

5.3 伪代码实现

javascript 复制代码
// 全局状态
let isRefreshing = false;
let pendingRequests = []; // 等待刷新完成的请求队列

function addPendingRequest(resolve, reject) {
  pendingRequests.push({ resolve, reject });
}

function resolvePendingRequests() {
  pendingRequests.forEach(({ resolve }) => resolve());
  pendingRequests = [];
}

function rejectPendingRequests(error) {
  pendingRequests.forEach(({ reject }) => reject(error));
  pendingRequests = [];
}

// 响应拦截器核心逻辑
async function handle401(error) {
  const originalRequest = error.config;

  // 1. 避免刷新接口自己死循环
  if (originalRequest.url === '/auth/refresh') {
    return Promise.reject(error);
  }

  // 2. 已经在刷新中,排队等待
  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      addPendingRequest(() => {
        // 用新Token重试原请求
        resolve(axiosInstance(originalRequest));
      });
    });
  }

  // 3. 首次遇到401,发起刷新
  isRefreshing = true;

  try {
    const { accessToken, refreshToken } = await refreshAccessToken();
    // 更新本地Token
    setTokens(accessToken, refreshToken);
    // 唤醒等待队列
    resolvePendingRequests();
    // 重试原请求
    return axiosInstance(originalRequest);
  } catch (refreshError) {
    // 刷新失败,清空Token,跳转登录
    clearTokens();
    rejectPendingRequests(refreshError);
    redirectToLogin();
    return Promise.reject(refreshError);
  } finally {
    isRefreshing = false;
  }
}

6. Token 存储策略

6.1 常见存储方式对比

存储位置 XSS防护 CSRF防护 持久化 适用场景
localStorage ✗ 可被XSS读取 ✓ JS读取不受Cookie影响 对XSS防护有信心时
sessionStorage ✗ 可被XSS读取 仅当前标签页 临时会话场景
Cookie (HttpOnly) ✓ JS不可读 ✗ 需配合CSRF Token 推荐,安全性最高
内存变量 ✓ JS闭包内 ✗ 刷新丢失 辅助存储

6.2 推荐策略

ini 复制代码
Access Token:
  └─ 首选 HttpOnly Cookie(后端 Set-Cookie)
     └─ 备选 内存变量 + sessionStorage 做刷新恢复

Refresh Token:
  └─ 首选 HttpOnly + Secure + SameSite=Strict Cookie
     └─ 路径限制为 /auth/refresh,减少暴露面
ini 复制代码
Set-Cookie: access_token=xxx;
  HttpOnly;          ← JS不可读,防止XSS窃取
  Secure;            ← 仅HTTPS传输
  SameSite=Strict;   ← 防止CSRF
  Path=/;            ← Access Token 所有路径都需要

Set-Cookie: refresh_token=xxx;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/auth/refresh; ← Refresh Token 仅刷新接口路径

7. 安全考量

7.1 Token 泄露后的防护措施

markdown 复制代码
层级防御模型:

第一层:缩短 Access Token 有效期(15min)
       └─ 即使泄露,影响窗口仅15分钟

第二层:Refresh Token 轮换(Rotation)
       └─ 每次刷新后,旧的 Refresh Token 立即失效
       └─ 如果攻击者用旧Token刷新,合法用户的Refresh Token
          也被标记为已用,系统检测到异常 → 全员下线

第三层:Refresh Token 复用检测(Reuse Detection)
       └─ 如果已被使用过的 Refresh Token 再次出现
       └─ 说明有人重放 → 标记该用户的所有 Refresh Token 失效
       └─ 强制重新登录

第四层:设备指纹 + IP变化检测
       └─ Refresh Token 绑定签发时的设备/IP
       └─ 环境变化 → 要求额外验证

7.2 Refresh Token Rotation 详解

这是当前业界最推荐的安全实践:

复制代码
正常流程:
  用户持有 R1(Refresh Token)
  刷新时提交 R1,后端签发 A2 + R2,同时标记 R1 已失效
  下次刷新用 R2,签发 A3 + R3,标记 R2 失效

攻击场景:
  攻击者窃取了 R1
  合法用户使用 R1 刷新 → 获得 A2 + R2,R1 被标记失效
  攻击者用 R1 刷新 → 后端检测到 R1 已被使用(复用检测触发)
  后端标记该用户所有Token无效 → 强制重新登录
  合法用户和攻击者都被踢出 → 合法用户重新登录后恢复正常

8. 与后端的协作约定

8.1 后端需要提供的接口

接口 方法 说明
/auth/login POST 登录,返回 Access Token + Refresh Token
/auth/refresh POST 刷新,接收 Refresh Token,返回新 Access Token(+ 新 Refresh Token)
/auth/logout POST 登出,通知后端废止 Refresh Token

8.2 后端返回的格式约定

json 复制代码
// 登录成功
{
  "accessToken": "eyJhbGciOi...",
  "refreshToken": "dGhpcyBpcyBh...",
  "expiresIn": 3600          // Access Token 有效期(秒)
}

// 刷新成功
{
  "accessToken": "eyJhbGciOi...",
  "refreshToken": "bmV3IHJlZnJl...",  // Token轮换时返回新的
  "expiresIn": 3600
}

// 401 响应(需统一格式便于前端判断)
{
  "code": 401,
  "message": "Token expired"
}

8.3 前后端时间差处理

复制代码
问题:
  后端说 Access Token 有效期 15 分钟
  但客户端时钟慢了 5 分钟 → 认为Token还有效,实际上已过期

方案:
  不要依赖客户端判断Token是否过期
  让服务端通过 401 统一告知前端
  前端被动响应 401 触发刷新,而非主动预估过期时间

备选方案(减少一次 401 往返):
  前端根据 expiresIn 提前刷新(如在过期前 2 分钟主动刷新)
  但必须配合服务端 401 兜底

9. 完整代码示例

9.1 Axios 拦截器完整实现

javascript 复制代码
import axios from 'axios';
import { getAccessToken, setTokens, clearTokens } from './token-store';
import { redirectToLogin } from './router';

// 创建 Axios 实例
const http = axios.create({
  baseURL: '/api',
  timeout: 10000,
});

// ============ 全局刷新状态 ============
let isRefreshing = false;
let pendingRequests = [];

// ============ 请求拦截器:注入 Access Token ============
http.interceptors.request.use(
  (config) => {
    const token = getAccessToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// ============ 响应拦截器:捕获 401 ============
http.interceptors.response.use(
  (response) => response.data,     // 正常返回,直接透传
  async (error) => {
    const originalRequest = error.config;

    // 情况1:不是 401,直接 reject
    if (error.response?.status !== 401) {
      return Promise.reject(error);
    }

    // 情况2:刷新接口本身 401,清除状态跳转登录(避免死循环)
    if (originalRequest.url === '/auth/refresh') {
      clearTokens();
      redirectToLogin();
      return Promise.reject(error);
    }

    // 情况3:已经在刷新中,排队等待
    if (isRefreshing) {
      return new Promise((resolve) => {
        pendingRequests.push(() => {
          // 刷新完成后用新Token重试
          originalRequest.headers.Authorization =
            `Bearer ${getAccessToken()}`;
          resolve(http(originalRequest));
        });
      });
    }

    // 情况4:首次遇到401,发起刷新
    isRefreshing = true;

    try {
      const { accessToken, refreshToken } = await refreshTokens();

      // 更新本地Token
      setTokens(accessToken, refreshToken);

      // 更新当前请求的Token
      originalRequest.headers.Authorization = `Bearer ${accessToken}`;

      // 处理等待队列中的请求
      pendingRequests.forEach((callback) => callback());
      pendingRequests = [];

      // 重试原请求
      return http(originalRequest);
    } catch (refreshError) {
      // 刷新失败:清空Token、清空队列、跳转登录
      pendingRequests = [];
      clearTokens();
      redirectToLogin();
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }
);

// ============ 刷新Token的API调用 ============
async function refreshTokens() {
  const response = await axios.post('/auth/refresh', {
    refreshToken: getRefreshToken(),
  });
  return response.data;
}

export default http;

9.2 Token 存储模块

javascript 复制代码
// token-store.js
const ACCESS_KEY = 'access_token';
const REFRESH_KEY = 'refresh_token';

export function getAccessToken() {
  return localStorage.getItem(ACCESS_KEY);
}

export function getRefreshToken() {
  return localStorage.getItem(REFRESH_KEY);
}

export function setTokens(accessToken, refreshToken) {
  localStorage.setItem(ACCESS_KEY, accessToken);
  if (refreshToken) {
    localStorage.setItem(REFRESH_KEY, refreshToken);
  }
}

export function clearTokens() {
  localStorage.removeItem(ACCESS_KEY);
  localStorage.removeItem(REFRESH_KEY);
}

9.3 主动刷新策略(可选优化)

结合被动 401 响应 + 主动提前刷新,减少一次 401 往返:

javascript 复制代码
// 在每次请求成功后,检查Token剩余有效期
http.interceptors.response.use((response) => {
  const token = getAccessToken();
  if (token && shouldRefreshToken(token)) {
    // 提前异步刷新,不阻塞当前请求
    refreshTokensQuietly();
  }
  return response.data;
});

function shouldRefreshToken(token) {
  const payload = parseJWT(token);
  const now = Date.now() / 1000;
  const expireTime = payload.exp;
  const threshold = 120; // 提前2分钟刷新
  return expireTime - now < threshold;
}

// 静默刷新,失败也不影响用户(等下次401触发被动刷新)
async function refreshTokensQuietly() {
  try {
    const { accessToken, refreshToken } = await refreshTokens();
    setTokens(accessToken, refreshToken);
  } catch {
    // 静默失败,交给401拦截器处理
  }
}

10. 常见踩坑与最佳实践

10.1 踩坑清单

坑点 现象 原因 解决
刷新死循环 页面卡死,请求不断 刷新接口本身也返回401,触发自身刷新 判断 url === '/auth/refresh' 时直接 reject
并发刷新 同时发了N个刷新请求 多个请求同时遇到401,各自触发刷新 isRefreshing 锁 + Promise 队列机制
请求时序错乱 旧Token覆盖新Token 第一个刷新请求返回前,用户操作触发了第二个刷新 使用刷新锁,保证同一时间只有一个刷新
Token丢失后不清除 旧Token残留,请求一直401 刷新失败后没有 clean up 刷新失败必须调用 clearTokens()
页面刷新后空白 用户刷新页面后未登录 Access Token 存在内存中,F5后丢失 Token 持久化存储(localStorage/Cookie)
多标签页Token不同步 TabA刷新Token后TabB还在用旧的 各标签页 localStorage 独立 storage 事件或 BroadcastChannel 同步

10.2 多标签页同步方案

javascript 复制代码
// 使用 BroadcastChannel API 跨标签页同步Token
const channel = new BroadcastChannel('token-sync');

// 刷新Token成功后,通知其他标签页
channel.postMessage({
  type: 'TOKEN_UPDATED',
  accessToken: newAccessToken,
});

// 监听其他标签页的通知
channel.onmessage = (event) => {
  if (event.data.type === 'TOKEN_UPDATED') {
    setTokens(event.data.accessToken);
  }
  if (event.data.type === 'FORCE_LOGOUT') {
    clearTokens();
    redirectToLogin();
  }
};

10.3 最佳实践总结

markdown 复制代码
1. 不要在前端解析JWT判断过期,让服务端401来决定
   (或作为辅助优化,但不能替代服务端判断)

2. Refresh Token 必须配合 Rotation + Reuse Detection
   单靠长有效期 Refresh Token 而没有轮换机制是不安全的

3. 刷新锁(isRefreshing)必不可少
   这是防止并发刷新导致状态混乱的关键

4. 刷新接口的URL要单独判断
   避免刷新接口自身的401再次触发刷新逻辑,形成死循环

5. 谨慎处理框架路由守卫
   在路由守卫中依赖Token状态时,要确保刷新逻辑不会导致守卫死循环

6. 刷新失败后要彻底清理
   清空Token、清空队列、中止所有pending请求、跳转登录页

7. 考虑无网络/网络恢复场景
   断网期间Token过期,网络恢复后重试应能正常触发刷新

8. Token存储遵循最小暴露原则
   Access Token 可用内存变量(配合持久化备份)
   Refresh Token 强烈建议 HttpOnly Cookie

参考阅读:

  • OAuth 2.0 RFC 6749 - Refresh Token 规范
  • Auth0 - Refresh Token Rotation
  • JWT Best Practices (IETF)

相关推荐
zhangxingchao2 小时前
AI Agent 基础问题系统整理:从 LangChain、LangGraph、MCP 到 Agent 架构、记忆、工具调用与评估体系
前端·人工智能·后端
Moment2 小时前
AI 为什么总喜欢写防御性代码?
前端·后端·面试
浑手营销2 小时前
浑手科技案例分享:133个精准询盘短视频玩法
前端·人工智能·科技
IT_陈寒2 小时前
SpringBoot自动配置的坑,差点让我加班到天亮
前端·人工智能·后端
LucianaiB2 小时前
【Dify + EdgeOne】你奶奶也会做一个“智票通”,轻松票据自定义提取+防数据泄露
前端·后端
python在学ing2 小时前
前端-CSS学习笔记
前端·css·python·学习
Bug-制造者3 小时前
【Vue3 实战】全局错误处理体系搭建:实现业务与错误彻底解耦
前端·javascript·vue.js
悟空瞎说3 小时前
# Git 交互式变基:优雅整理提交历史,告别杂乱 PR 记录
前端·git
还有多久拿退休金3 小时前
DragSortTable:一个让我怀疑人生的滚动重置 Bug
前端