axios 拦截器无感刷新 jwt token
4 min read
目录

如果只关心前端实现,可以直接跳转到前端部分
或者直接查看仓库 GitHub - TransonQ/express-jwt-demo

网络上有关这方面的文章很多,但是基本都是纯前端核心代码展示,如果是为了解决业务问题,最简单的方案就是复制粘贴然后看效果。但是为了更好的验证和测试,以及更好帮助自己了解前后端分离开发过程中后端同学的视角,才有了这个项目,希望有所帮助。

项目结构

本项目为 monorepo 仓库,使用了 pnpm workspace 管理。

├───📁 backend/
   ├───📁 node_modules/
   └───...
   ├───📁 src/
   └───...
   ├───📄 .env
   ├───📄 README.md
   └───📄 package.json
├───📁 frontend/
   ├───📁 node_modules/
   └───...
   ├───📁 public/
   └───...
   ├───📁 src/
   └───...
   ├───📄 .env
   ├───📄 README.md
   ├───📄 eslint.config.js
   ├───📄 index.html
   ├───📄 package.json
   ├───📄 tsconfig.app.json
   ├───📄 tsconfig.json
   ├───📄 tsconfig.node.json
   └───📄 vite.config.ts
├───📄 .gitignore
├───📄 .prettierrc
├───📄 README.md
├───📄 package.json
├───📄 pnpm-lock.yaml
└───📄 pnpm-workspace.yaml

前端部分

  • 通过 create vite 创建;
  • 路由使用了 react-router v7 版本;
  • 简单的样式使用了 tailwindcss v4 版本;
  • 使用 axios 请求库;
  • 使用 swr 作为上层请求库;
  • 使用 js-cookie 操作浏览器 cookie。

核心代码

axios 实例 src/api/ax.instance.ts

import axios, {
  type AxiosError,
  type AxiosResponse,
  type InternalAxiosRequestConfig,
} from 'axios';
import { CookieKeys, cookies } from '../utils/cookies';
import { postRefrehToken } from './examples.api';

interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
  _retry?: boolean;
}

// 创建axios实例
const ax = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
});

// 请求拦截器
ax.interceptors.request.use(onRequest, onRequestError);

// 响应拦截器
ax.interceptors.response.use(onResponse, onResponseError);

// 公共的请求拦截器
function onRequest(config: CustomAxiosRequestConfig) {
  // handleOtherCustomRequest(config);
  const accessToken = cookies.get(CookieKeys['ACCESS-TOKEN']);
  config.headers['Authorization'] = accessToken;
  return config;
}
// 公共的请求错误拦截器
function onRequestError(error: AxiosError) {
  // handleOtherCustomRequestError(error);
  return Promise.reject(error);
}

// 公共的响应拦截器
function onResponse(response: AxiosResponse) {
  // handleOtherCustomResponse(response);
  return response;
}
// 公共的响应错误拦截器
async function onResponseError(error: AxiosError) {
  const originalRequest = error.config as CustomAxiosRequestConfig;

  if (error.response?.status === 401 && !originalRequest?._retry) {
    originalRequest._retry = true;

    try {
      const refreshToken = cookies.get(CookieKeys['REFRESH-TOKEN']);
      if (!refreshToken) return;
      const axiosResponse = await postRefrehToken(refreshToken);
      const { data } = axiosResponse;

      cookies.set(CookieKeys['ACCESS-TOKEN'], data.accessToken);
      cookies.set(CookieKeys['REFRESH-TOKEN'], data.refreshToken);

      // 重新发起请求
      originalRequest.headers.Authorization = data.accessToken;

      // 返回重新发起请求的结果
      return ax(originalRequest);
    } catch {
      // 自定义处理当刷新 token 接口报错的情况,通常重定向到登录路由。
      window.location.href = '/login';
    }
  }

  // 处理其他类型的错误 ...
  // handleOtherCustomError(error);
  return Promise.reject(error);
}

export { ax };

这里使用一个封装的工具函数 src/utils/cookies.ts 用于维护 cookie 相关的键名:

import Cookies from 'js-cookie';

export const cookies = Cookies;

export enum CookieKeys {
  'ACCESS-TOKEN' = 'jwt_demo_access_token',
  'REFRESH-TOKEN' = 'jwt_demo_refresh_token',
}

后端部分

  • 基于 node@22 ;
  • 使用 body-parser 解析请求体;
  • 使用 cors 解决浏览器跨域的问题;
  • 使用 express 搭建服务器;
  • 使用 jsonwebtoken 生成和解析前后端交互的 access token 和 refresh token。

模拟数据库

为了简单模拟鉴权行为,这里用一个数组模拟数据库。

// 存储 Refresh Token 的模拟数据库
const RefreshTokensDB = [];

登录接口

登录获取 access token 和 refresh token,并且作为响应数据返回。

// 登录接口,生成 AccessToken 和 RefreshToken
app.post('/login', (req, res) => {
  const { username } = req.body;
  if (!username) {
    return res.status(400).json({ error: '用户名是必填的' });
  }

  // 生成 Access Token
  const accessToken = jwt.sign({ username }, ACCESS_TOKEN_SECRET, {
    expiresIn: ACCESS_TOKEN_EXPIRATION,
  });

  // 生成 Refresh Token
  const refreshToken = jwt.sign({ username }, REFRESH_TOKEN_SECRET, {
    expiresIn: REFRESH_TOKEN_EXPIRATION,
  });

  // 存储 Refresh Token
  RefreshTokensDB.push(refreshToken);

  res.json({ accessToken, refreshToken });
});

刷新 token 接口

前端将 refresh_token 作为请求数据请求刷新 token 的接口获取最新的 access token 和 refresh token 。

// 刷新 Access Token 的接口
app.post('/token', (req, res) => {
  const { refresh_token } = req.body;

  if (!refresh_token) {
    return res.status(401).json({ error: '缺少 Refresh Token' });
  }

  if (!RefreshTokensDB.includes(refresh_token)) {
    return res.status(403).json({ error: '无效的 Refresh Token' });
  }

  try {
    // 验证 Refresh Token
    const user = jwt.verify(refresh_token, REFRESH_TOKEN_SECRET); 
    // 生成新的 Access Token
    const accessToken = jwt.sign(
      { username: user.username },
      ACCESS_TOKEN_SECRET,
      { expiresIn: ACCESS_TOKEN_EXPIRATION },
    );
    // 生成新的 Refresh Token
    const refreshToken = jwt.sign(
      { username: user.username },
      REFRESH_TOKEN_SECRET,
      { expiresIn: REFRESH_TOKEN_EXPIRATION },
    );
    // 储存 Refresh Token
    RefreshTokensDB.push(refreshToken);

    res.json({ accessToken, refreshToken });
  } catch (err) {
    res.status(403).json({ error: '无效的 Refresh Token' });
  }
});

鉴权访问接口

模拟登录后且挟带正确的请求 authorization 头才能访问的接口,如果 token 失效将会出现 401 错误。如果 refresh token 也失效了返回 403 错误,则需要重新登陆。

// 测试访问受保护的路由
app.get('/protected', (req, res) => {
  const authHeader = req.headers.authorization;
  const token = authHeader;

  if (!token) {
    return res.status(401).json({ error: '缺少 Access Token' });
  }

  try {
    // 验证 Access Token
    jwt.verify(token, ACCESS_TOKEN_SECRET, (err, user) => {
      if (err) {
        return res.status(401).json({ error: '无效的 Access Token' });
      }
      req.user = user;
      const query = req.query;
      res.json({ message: `欢迎, ${user.username}`, query });
    });
  } catch (err) {
    res.status(403).json({ error: '无效的 Access Token' });
  }
});

扩展阅读

How to JWT vs. Opaque Tokens by sergiodxa