双token无感刷新

325 阅读5分钟

什么是token?

JSON Web Token 入门教程 - 阮一峰的网络日志

你是否曾被突然弹出的“登录已过期”提示打断工作节奏?是否厌倦了在重要操作时被迫重新认证的糟糕体验?

本文揭秘的双Token无感刷新机制,正是解决这些痛点的终极方案。


双 Token 无感刷新机制详解:从原理到代码实现

为什么需要双token?

传统的基于单 Token 的认证方案在 Token 过期时会给用户带来突然的中断,体验很差。而 双 Token 无感刷新机制 正是为了解决这一痛点而设计的方案。我将结合一段完整的 Koa 后端代码,讲解这一机制的实现原理。

一、传统单 Token 认证

// utils/jwt.js - 传统的verify中间件
function verify() {
  return async(ctx, next) => {
    const token = ctx.headers.authorization; // 1. 从请求头获取Token
    if (token) {
      try {
        const decoded = jwt.verify(token, '666'); // 2. 校验Token
        if (decoded.id) {
          ctx.userId=decoded.id; // 3. 校验成功,将用户信息注入上下文
          await next();
        }
      } catch (error) { // 4. 校验失败(过期或篡改)
        ctx.status = 401; // 5. 返回401状态码
        ctx.body = { code: '0', msg: '登录失效' };
      }
    } else { // 6. 根本没有Token
      ctx.status = 401;
      ctx.body = { code: '0', msg: '请重新登录' };
    }
  }
}

问题:

  1. 前端在请求头中携带 Authorization
  2. 后端校验 Token 有效性。
  3. 若有效,继续处理请求;若过期或无效,则返回 401
  4. 前端收到 401,必须强制跳转到登录页面。
  5. 当用户正在操作时,Token 突然过期,会立刻被中断,体验非常不友好。

二、双 Token 方案的核心

为了解决这个问题,可以引入两个 Token:

  • Access Token (短 Token):用于访问受保护资源,生命周期短(如1小时)。
  • Refresh Token (长 Token):专用于在 Access Token 过期后,获取新的双 Token,生命周期长(如7天)。

其核心工作流程如下图所示,它清晰地展示了两个 Token 如何协同工作以实现“无感”刷新:

1. 登录:双 Token

在登录接口中,不再只返回一个 Token,而是同时生成并返回两个 Token。

// controllers/index.js - 登录接口
router.post("/login", async (ctx) => {
  let { username, password } = ctx.request.body;
  // ... 数据库验证逻辑 ...
  if (res.length) { // 验证成功
    let data = { // 准备存入Token的数据
      id: res[0].id,
      username: res[0].username,
      nickname: res[0].nickname,
      create_time: res[0].create_time,
    };
    // 核心代码:生成两种Token
    const access_token = sign(data, "1h"); // 短Token,1小时后过期
    const refresh_token = sign(data, "7d"); // 长Token,7天后过期

    ctx.body = {
      code: "1",
      msg: "登录成功",
      data: data,
      access_token: access_token,  // 发给前端
      refresh_token: refresh_token, // 发给前端
    };
  }
});

前端任务:登录成功后,必须将 access_tokenrefresh_token 安全地存储在本地(例如 localStoragesessionStorage)。

2. 无感刷新的关键:Refresh 接口

这是实现无感刷新的最关键部分。它接收一个旧的 Refresh Token,验证通过后,返回一套全新的双 Token。

// controllers/index.js - 刷新Token接口
router.post("/refresh", (ctx) => {
  // 1. 从请求体中获取长Token
  const { refresh_token } = ctx.request.body;

  // 2. 使用专用函数验证长Token
  const decoded = refreshVerify(refresh_token);

  if (decoded.id) { // 3. 验证通过
    const data = { // 从旧Token中解析出的用户数据
      id: decoded.id,
      username: decoded.username,
      nickname: decoded.nickname,
      create_time: decoded.create_time,
    };
    // 4. 生成全新的双Token
    const new_access_token = sign(data, "1h");
    const new_refresh_token = sign(data, "7d");

    ctx.body = {
      code: "1",
      msg: "token刷新成功",
      access_token: new_access_token,
      refresh_token: new_refresh_token,
    };
  } else {
    // 5. 长Token也过期或无效了
    ctx.status = 416; // 定义一个特殊的状态码,表示Refresh Token失效
    ctx.body = {
      code: "0",
      msg: "登录失效",
    };
  }
});

3. 专用的长 Token 验证函数

注意,刷新接口使用了 refreshVerify 函数,而不是普通的 verify验证token函数 。这是因为刷新接口不应该被 verify 拦截。

// utils/jwt.js - 专用的RefreshToken验证函数
function refreshVerify(token) {
  try {
    const decoded = jwt.verify(token, '666');
    if (decoded.id) {
      return decoded; // 验证成功,返回解码后的数据
    }
  } catch (error) {
    return false; // 验证失败,返回false
  }
}

三、前端部分

   const originalRequest = res.config;

      // 重新请求新的短 token和长 token
      const refresh_token = localStorage.getItem("refresh_token");
      if (refresh_token) {
        axios
          .post("/user/refresh", {
            refresh_token: refresh_token,
          })
          .then((res) => {
            if (res.code === "1") {
              localStorage.setItem("access_token", res.access_token);
              localStorage.setItem("refresh_token", res.refresh_token);
              // 将之前没有发送成功的请求再次发送
              // 更新原始请求的 Authorization 头
              originalRequest.headers.Authorization = res.access_token;
              // 重新发送原始请求
              return axios(originalRequest);
            }
          });

1. 保存原始请求

const originalRequest = res.config;
  • 当请求因 Token 过期失败时,保存原始请求的配置信息
  • 这包含了请求的 URL、方法、参数等重要信息
  • 目的是在获取新 Token 后能够重新发送这个请求

2. 获取 Refresh Token

const refresh_token = localStorage.getItem("refresh_token");
  • 从本地存储中获取长时效的 Refresh Token
  • Refresh Token
  • 它是用来获取新的 Access Token 的凭证

3. 发起刷新请求

axios.post("/user/refresh", {
  refresh_token: refresh_token,
})
  • 向服务器的 /user/refresh 端点发送 POST 请求
  • 携带当前有效的 Refresh Token 作为凭证
  • 服务器会验证这个 Token 并返回一对新的 Access Token 和 Refresh Token

4. 处理刷新响应

.then((res) => {
  if (res.code === "1") {
    localStorage.setItem("access_token", res.access_token);
    localStorage.setItem("refresh_token", res.refresh_token);
  • 检查服务器返回的响应码,通常 "1" 表示成功
  • 将新的 Access Token 和 Refresh Token 保存到本地存储
  • 这样后续的请求就可以使用新的 Token

5. 更新请求头并重试

originalRequest.headers.Authorization = res.access_token;
return axios(originalRequest);
  • 使用新的 Access Token 更新原始请求的认证头
  • 重新发送之前失败的请求
  • 这样用户不会感知到 Token 刷新的过程,实现了"无感"刷新

总结:

  • 响应拦截器:在 Axios 或 Fetch 的响应拦截器中,监听 401 状态码。
  • 防止并发:设置一个标志(如 isRefreshing)和一个请求队列,防止多个请求同时触发刷新。 发起刷新请求:当收到 401,检查是否正在刷新。如果没有,则用存储的 refresh_token 调用 /user/refresh 接口。
  • 处理刷新结果
    • 成功:将新的 access_tokenrefresh_token 保存到本地,并重试之前所有失败的请求。
    • 失败(收到416):清除所有本地 Token,跳转到登录页。
  • 替换Token重试:刷新成功后,用新的 access_token 替换原请求的 Authorization 头,重新发送请求。

结语:告别频繁登录

现在就开始实践吧!  文中的完整代码示例已经为你铺平了道路,从后端签发到前端拦截,从并发控制到错误处理,让你的应用告别登录中断。