首页 / 文章

Express与Axios令牌刷新,逐个请求追踪

使用 Express 后端和 Axios 客户端构建 JWT 访问及刷新流程,然后追踪 401 错误如何转化为一次共享的刷新操作以及透明的重试机制。

发布者:REACTAPP.TOP阅读约 8 分钟1906 词

来源: AXIOS INTERCEPTOR

短期有效的访问令牌会在会话进行到一半时过期,而用户不应察觉到这一情况。本指南将构建一个Express API和一个Axios客户端,然后对一个已过期的请求进行端到端的追踪:

API request
   ↓
Access token
   ↓
Backend
   ↓
401 Unauthorized
   ↓
Axios response interceptor
   ↓
Refresh token
   ↓
New access token
   ↓
Retry original request
   ↓
Return original response

如需了解更多关于并发401错误的信息,请参阅我们的单次刷新机制深度解析。

Express后端

刷新令牌存储在HttpOnly Cookie中;为简化处理,访问令牌则保存在localStorage中。CORS配置允许来自Vite源站的凭证传递。这些密钥仅为演示用途,实际应用中应从环境变量中加载真实值。

import express from "express";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import cors from "cors";

const app = express();

app.use(express.json());
app.use(cookieParser());

app.use(
  cors({
    origin: "http://localhost:5173",
    credentials: true,
  })
);

const ACCESS_SECRET = "access-secret";
const REFRESH_SECRET = "refresh-secret";

登录问题会影响两种令牌

该演示会检查硬编码的凭证,生成15秒有效的访问令牌和7天有效的刷新令牌,并设置cookie。运行之前请删除列表末尾的多余文字行。

app.post("/auth/login", (req, res) => {
  const { email, password } = req.body;
if (
    email !== "asif@gmail.com" ||
    password !== "asif@123"
  ) {
    return res.status(401).json({
      message: "Invalid email or password",
    });
  }
  const user = {
    userId: 1,
    email,
  };
  const accessToken = jwt.sign(
    user,
    ACCESS_SECRET,
    {
      expiresIn: "15s",
    }
  );
  const refreshToken = jwt.sign(
    {
      userId: user.userId,
    },
    REFRESH_SECRET,
    {
      expiresIn: "7d",
    }
  );
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: false, // true in production with HTTPS
    sameSite: "lax",
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  return res.json({
    message: "Login successful",
    accessToken,
    user,
  });
});

access token expires after only 15 seconds.

中间件与受保护路由

authenticate函数会验证Bearer令牌,一旦出现任何问题就会返回401响应。

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
if (!authHeader) {
    return res.status(401).json({
      message: "Access token missing",
    });
  }
  const [type, token] = authHeader.split(" ");
  if (type !== "Bearer" || !token) {
    return res.status(401).json({
      message: "Invalid authorization header",
    });
  }
  try {
    const decoded = jwt.verify(
      token,
      ACCESS_SECRET
    );
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      message: "Access token expired or invalid",
    });
  }
}

有两个路由使用了该功能:

app.get("/profile", authenticate, (req, res) => {
  return res.json({
    message: "Profile fetched successfully",
    user: req.user,
  });
});
app.get("/students", authenticate, (req, res) => {
  return res.json({
    students: [
      {
        id: 1,
        name: "Rahul",
      },
      {
        id: 2,
        name: "Aman",
      },
    ],
  });
});

刷新与登出

刷新功能会验证cookie并返回新的访问令牌,但不会更换刷新令牌。

app.post("/auth/refresh", (req, res) => {
  const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
    return res.status(401).json({
      message: "Refresh token missing",
    });
  }
  try {
    const decoded = jwt.verify(
      refreshToken,
      REFRESH_SECRET
    );
    const user = {
      userId: decoded.userId,
      email: "asif@gmail.com",
    };
    const newAccessToken = jwt.sign(
      user,
      ACCESS_SECRET,
      {
        expiresIn: "15s",
      }
    );
    return res.json({
      accessToken: newAccessToken,
    });
  } catch (error) {
    return res.status(401).json({
      message: "Refresh token expired or invalid",
    });
  }
});

登出功能会清除cookie,之后再启动服务器。

app.post("/auth/logout", (req, res) => {
  res.clearCookie("refreshToken");
return res.json({
    message: "Logged out successfully",
  });
});
app.listen(5000, () => {
  console.log("Server running on http://localhost:5000");
});

Axios客户端

该客户端位于一个模块中:

src/
  api/
    api.ts

withCredentials: true这一参数很重要:如果不设置它,浏览器就不会发送刷新cookie。

import axios, {
  AxiosError,
  InternalAxiosRequestConfig,
} from "axios";

const api = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});

请求拦截器会附加已存储的令牌:

api.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const accessToken =
      localStorage.getItem("accessToken");
if (accessToken) {
      config.headers.Authorization =
        `Bearer ${accessToken}`;
    }
    return config;
  }
);

因此这个调用:

api.get("/profile");

会以如下形式发出:

GET /profile
Authorization: Bearer eyJhbGci...

针对多个401错误仅刷新一次

当多个请求同时失败时:

GET /profile       → 401
GET /students      → 401
GET /notifications → 401
GET /dashboard     → 401

必须避免每次失败都进行刷新:

POST /auth/refresh
POST /auth/refresh
POST /auth/refresh
POST /auth/refresh

相反,所有失败都应该共享同一次刷新操作:

GET /profile       → 401 ─┐
GET /students      → 401 ─┤
GET /notifications → 401 ─┤
GET /dashboard     → 401 ─┘
                           ↓
                    ONE refresh request
                           ↓
                    new access token
                           ↓
              ┌────────────┼────────────┐
              ↓            ↓            ↓
           retry         retry        retry

该工具是一个模块级的承诺对象:

let refreshPromise: Promise<string> | null = null;

refreshAccessToken()仅在不存在该承诺时创建它,并在finally块中将其清除。

let refreshPromise: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = api
      .post("/auth/refresh")
      .then((response) => {
        const newAccessToken =
          response.data.accessToken;
          localStorage.setItem(
          "accessToken",
          newAccessToken
        );
        return newAccessToken;
      })
      .finally(() => {
        refreshPromise = null;
      });
  }
  return refreshPromise;
}

保护逻辑负责执行相关操作:

This is the key:
if (!refreshPromise) {
    refreshPromise = api.post("/auth/refresh");
}

第一个调用者会启动刷新操作;后续的调用者则会

refreshPromise !== null

等待同一个承诺对象完成。

响应拦截器

完整模块会执行401 → 刷新 → 重试的流程,跳过/auth/refresh本身,并为请求添加_retry标记。如果刷新失败,它会清除令牌并重定向到/login。

import axios, {
  AxiosError,
  InternalAxiosRequestConfig,
} from "axios";

const api = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});

// =====================================================
// REFRESH STATE
// =====================================================
let refreshPromise: Promise<string> | null = null;

// =====================================================
// REFRESH ACCESS TOKEN
// =====================================================
async function refreshAccessToken(): Promise<string> {
  /*
   * If another request is already refreshing the token,
   * wait for that same request.
   */
  if (!refreshPromise) {
    refreshPromise = api
      .post("/auth/refresh")
      .then((response) => {
        const newAccessToken =
          response.data.accessToken;
        localStorage.setItem(
          "accessToken",
          newAccessToken
        );
        return newAccessToken;
      })
      .finally(() => {
        /*
         * Allow a future refresh after this one finishes.
         */
        refreshPromise = null;
      });
  }
  return refreshPromise;
}

// =====================================================
// REQUEST INTERCEPTOR
// =====================================================
api.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const accessToken =
      localStorage.getItem("accessToken");
    if (accessToken) {
      config.headers.Authorization =
        `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// =====================================================
// RESPONSE INTERCEPTOR
// =====================================================
api.interceptors.response.use(
  // -----------------------------------------------
  // SUCCESS
  // -----------------------------------------------
  (response) => {
    return response;
  },
  // -----------------------------------------------
  // ERROR
  // -----------------------------------------------
  async (error: AxiosError) => {
    const originalRequest =
      error.config as
        | (InternalAxiosRequestConfig & {
            _retry?: boolean;
          })
        | undefined;
    if (!originalRequest) {
      return Promise.reject(error);
    }
    const isUnauthorized =
      error.response?.status === 401;
    const isRefreshRequest =
      originalRequest.url === "/auth/refresh";
    /*
     * Only refresh once for a request.
     */
    if (
      isUnauthorized &&
      !originalRequest._retry &&
      !isRefreshRequest
    ) {
      originalRequest._retry = true;
      try {
        // Get new access token
        const newAccessToken =
          await refreshAccessToken();
        // Attach new token
        originalRequest.headers.Authorization =
          `Bearer ${newAccessToken}`;
        // Retry original request
        return api(originalRequest);
      } catch (refreshError) {
        /*
         * Refresh token itself failed.
         * User needs to login again.
         */
        localStorage.removeItem("accessToken");
        window.location.href = "/login";
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

export default api;

登录与数据调用

登录功能位于独立的文件中:

src/api/auth.ts
import api from "./api";

export async function login(
  email: string,
  password: string
) {
  const response = await api.post("/auth/login", {
    email,
    password,
  });
  const { accessToken, user } =
    response.data;
  localStorage.setItem(
    "accessToken",
    accessToken
  );
  return user;
}

浏览器会保留刷新 Cookie:

Set-Cookie:
refreshToken=...
HttpOnly

按设计,脚本无法读取该 Cookie。配置文件模块则:

import api from "./api";
export async function getProfile() {
  const response = await api.get("/profile");  return response.data;
}

各个组件从不直接操作令牌:

import { useEffect } from "react";
import { getProfile } from "./api/profile";
function Profile() {
  useEffect(() => {
    getProfile()
      .then((data) => {
        console.log(data);
      })
      .catch((error) => {
        console.error(error);
      });
  }, []);
  return <div>Profile</div>;
}
export default Profile;

追踪过期令牌

在此处登录

10:00:00

然后会收到

accessToken
expires in 15 seconds

在

10:00:20

该组件会调用:

api.get("/profile");

请求拦截器会添加过期的令牌:

localStorage
     ↓
accessToken
     ↓
Authorization header
GET /profile
Authorization: Bearer OLD_TOKEN

服务器正在运行

jwt.verify(OLD_TOKEN)

以及答案:

401 Unauthorized

响应拦截器会检测到

error.response.status === 401

并调用:

await refreshAccessToken();

这样就会自动发送 Cookie

POST /auth/refresh
Cookie: refreshToken=...

原因是:

withCredentials: true

服务器会进行检查

jwt.verify(refreshToken, REFRESH_SECRET)

存在问题

NEW_ACCESS_TOKEN

然后返回:

{
  "accessToken": "NEW_TOKEN"
}

客户端会将其存储起来

localStorage.setItem(
  "accessToken",
  newAccessToken
);

并修改原始请求:

originalRequest.headers.Authorization =
  `Bearer ${newAccessToken}`;
return api(originalRequest);

因此

GET /profile
Authorization: Bearer OLD_TOKEN

会被重新发送为

GET /profile
Authorization: Bearer NEW_TOKEN

并且操作成功:

200 OK

并发与重试保护机制

假设令牌过期时触发了四个请求:

Promise.all([
  api.get("/profile"),
  api.get("/students"),
  api.get("/teachers"),
  api.get("/notifications"),
]);

每个请求都会

401

在没有共享承诺的情况下,会有四次刷新操作:

profile       → 401 → refresh
students      → 401 → refresh
teachers      → 401 → refresh
notifications → 401 → refresh

如果有共享承诺的话

let refreshPromise: Promise<string> | null = null;

这些操作会汇聚在一起:

profile
   ↓
401
   ↓
create refreshPromise
   ↓
POST /auth/refresh
            ↑
            │
students ───┤
401         │
            │
teachers ──┤
401         │
            │
notifications
401         │
            │
            ↓
       same Promise
            ↓
       NEW TOKEN

每次刷新后,每个请求都会重新尝试:

profile       → retry
students      → retry
teachers      → retry
notifications → retry

_retry会终止循环。如果重新尝试的请求仍被拒绝:

GET /profile
     ↓
401
     ↓
refresh
     ↓
new token
     ↓
GET /profile again
     ↓
401

像这样的简单逻辑

if (status === 401) {
   refresh();
   retry();
}

会无限循环:

401
 ↓
refresh
 ↓
retry
 ↓
401
 ↓
refresh
 ↓
retry
 ↓
401
 ↓
refresh
 ↓
...

设置

originalRequest._retry = true;

以及检查

!originalRequest._retry

允许每个请求进行一次重试。

用于刷新的独立客户端

URL检查方式较为脆弱,应保留主客户端

const api = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});

并添加一个没有拦截器的客户端:

const refreshClient = axios.create({
  baseURL: "http://localhost:5000",
  withCredentials: true,
});
refreshPromise = refreshClient
  .post("/auth/refresh")
  .then(...)

刷新后会绕过身份验证拦截器。

整体流程

所有流程都在一个图表中展示:

React
                   │
                   │ api.get()
                   ↓
          ┌─────────────────┐
          │ Request          │
          │ Interceptor      │
          │                 │
          │ Get accessToken │
          │ Add Bearer      │
          └────────┬────────┘
                   │
                   ↓
                Backend
                   │
             ┌─────┴─────┐
             │           │
           200          401
             │           │
             ↓           ↓
          return    Response
                    Interceptor
                         │
                         ↓
                  Is it 401?
                         │
                        YES
                         ↓
                 Already refreshing?
                    /          \
                  YES           NO
                   │             │
                   ↓             ↓
                WAIT         /refresh
                   │             │
                   └──────┬──────┘
                          ↓
                    New access token
                          │
                          ↓
                  Retry original request
                          │
                          ↓
                       Backend
                          │
                          ↓
                         200
                          │
                          ↓
                       React

生产环境注意事项

  • 通过HTTPS使用Secure Cookie。
  • 将访问令牌存储在内存中,而非localStorage中。
  • 轮换并撤销刷新令牌。