Strona główna / Artykuły / Odnowa tokenów Express i Axios, śledzenie żądań pojedynczo

Odnowa tokenów Express i Axios, śledzenie żądań pojedynczo

Stwórz proces dostępu i odnowienia tokena JWT przy użyciu backendu Express oraz klienta Axios, a następnie prześledź, jak błąd 401 przekształca się w wspólną operację odnowienia oraz transparentną próbę ponowną.

1906 słów

Tokeny dostępu o krótkim czasie trwania wygasają w trakcie sesji, a użytkownicy nie powinni tego zauważyć. Ten przewodnik tworzy API Express oraz klienta Axios, a następnie śledzi jeden wygasły żądanie od początku do końca:

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

Aby dowiedzieć się więcej na temat jednoczesnych błędów 401, zapoznaj się z naszym artykułem dotyczącym szczegółowego omówienia mechanizmu single-flight refresh.

Tło serwerowe Express

Tokén odnowienia znajduje się w cookie’u HttpOnly; token dostępu jest przechowywany w localStorage ze względu na prostotę. CORS umożliwia użycie danych uwierzytelniających z domeny Vite. Podane wartości są tylko demonstracyjne; w rzeczywistych aplikacjach należy używać prawdziwych wartości z środowiska.

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";

Problemy z logowaniem dotyczą obu tokenów

Demo sprawdza ustalone uprzednio dane logowania, podpisuje token dostępu ważny 15 sekund oraz token odnowienia ważny 7 dni i ustawia ciasteczko. Przed uruchomieniem usuń dodatkowy wiersz tekstu na końcu listy.

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.

Middleware i chronione trasy

authenticate weryfikuje token Bearer i zwraca błąd 401 w przypadku jakichkolwiek problemów.

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",
    });
  }
}

Dwie trasy go wykorzystują:

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",
      },
    ],
  });
});

Odnowienie i wylogowanie

Funkcja odnowienia weryfikuje ciasteczko i zwraca nowy token dostępu, nie rotując przy tym tokena odnowienia.

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",
    });
  }
});

Wylogowanie usuwa ciasteczko; następnie uruchamia się serwer.

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");
});

Klient Axios

Klient znajduje się w jednym module:

src/
  api/
    api.ts

withCredentials: true ma znaczenie: bez niego przeglądarka nigdy nie wysyła ciasteczka odnowienia.

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

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

Interceptor żądań dołącza przechowywany token:

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

Zatem ta wywołanie:

api.get("/profile");

wygląda tak:

GET /profile
Authorization: Bearer eyJhbGci...

Odświeżanie raz dla wielu błędów 401

Gdy kilka żądań zawiedzie jednocześnie:

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

trzeba unikać odświeżania po każdym niepowodzeniu:

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

Zamiast tego wszystkie niepowodzenia powinny korzystać z jednego odświeżenia:

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

Narzędzie to obietnica na poziomie modułu:

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

refreshAccessToken() tworzy ją tylko wtedy, gdy nie istnieje, i usuwa ją w bloku 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;
}

Guard wykonuje tę pracę:

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

Pierwszy wywołujący uruchamia odświeżanie; późniejsze znajdują

refreshPromise !== null

i czekają na tę samą obietnicę.

Interceptor odpowiedzi

Pełny moduł dodaje sekwencję 401 → odświeżenie → ponowna próba, pomijając sam plik /auth/refresh i oznaczając żądania tagiem _retry. Jeśli odświeżenie się nie powiedzie, usuwa token i przekierowuje na /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;

Logowanie i wywołania danych

Logowanie znajduje się w osobnym pliku:

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;
}

Przeglądarka przechowuje ciasteczko odświeżenia z:

Set-Cookie:
refreshToken=...
HttpOnly

Zgodnie z projektem skrypty nie mogą go odczytać. Moduł profilu:

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

Komponenty nigdy nie dotykają tokenów:

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;

Śledzenie wygasłego tokena

Zaloguj się na

10:00:00

i otrzymaj

accessToken
expires in 15 seconds

W momencie

10:00:20

komponent wywołuje:

api.get("/profile");

Interceptor żądań dodaje przestarzały token:

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

Server działa

jwt.verify(OLD_TOKEN)

i odpowiedzi:

401 Unauthorized

Interceptor odpowiedzi wykrywa

error.response.status === 401

i wywołuje:

await refreshAccessToken();

To automatycznie wysyła plik cookie

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

z powodu:

withCredentials: true

Serwer sprawdza

jwt.verify(refreshToken, REFRESH_SECRET)

problemy

NEW_ACCESS_TOKEN

i zwraca:

{
  "accessToken": "NEW_TOKEN"
}

Klient go przechowuje

localStorage.setItem(
  "accessToken",
  newAccessToken
);

i modyfikuje oryginalną prośbę:

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

Zatem

GET /profile
Authorization: Bearer OLD_TOKEN

jest wysyłany ponownie jako

GET /profile
Authorization: Bearer NEW_TOKEN

i odnosi sukces:

200 OK

Konkurencja i mechanizm ponawiania prób

Załóżmy, że po wygaśnięciu tokena wysyłane są cztery prośby:

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

Każda otrzymuje

401

Bez wspólnego obietnicy – cztery odświeżenia:

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

Z

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

one się łączą:

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

i każda prośba jest ponawiana po jednym odświeżeniu:

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

_retry zatrzymuje pętle. Jeśli ponowiona prośba nadal zostanie odrzucona:

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

naïwna logika taka jak

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

cykluje wiecznie:

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

Ustawianie

originalRequest._retry = true;

i sprawdzanie

!originalRequest._retry

zezwala na jedną próbę ponowną na prośbę.

Odrębny klient do odświeżania

Weryfikacja URL jest krucha. Zachowaj głównego klienta

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

i dodaj jednego bez interceptorów:

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

Odświeżenie omija wtedy interceptorzy autoryzacji.

Pełny obraz sytuacji

Cały proces w jednym diagramie:

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

Uwagi dotyczące produkcji

  • Używaj plików cookie Secure przez HTTPS.
  • Zachowuj tokeny dostępu w pamięci, a nie w localStorage.
  • Zmieniaj i anuluj tokeny odświeżania.
  • Literatura pokrewna