Апдэйт токена Express і Axios, аналіз запытаў па штучку
Створыце процес адэнту і апдэта JWT з абакентам Express і кліентам Axios, пасля чыгуньце, як код 401 ператвараецца ў спяльны процес апдэта і прозрачную пракушанню.
Крыніцы: AXIOS INTERCEPTOR
Токены доступу з мягкым тэрміном дзейнаўшчыны выгораюць пад час сесіі, і корыстувачы не должны гэтага бачыць. У данай інструкцыі ствараецца API на Express і кліент на 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 дзён, а таксама ствараецца кукі. Перад запускам дэму выдаліце непатрэбны рядок тексту ў канцы спіса.
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",
},
],
});
});
Апдэт і выйшчы з системы
Функцыя апдэта перакантрацуецца кукі і вяртае новы токен даступу, не змінюючы токен апдэтаў.
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",
});
}
});
Функцыя выйшчы з системы чыстае кукі; пасля чаго запускаецца сервер.
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 мае значэнне: без яго браузер ніколі не адправляе кукі для апдэта.
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;
}
Браузер захоўвае кукі з інфармацыяй апцэнавання:
Set-Cookie:
refreshToken=...
HttpOnly
За прызначэнням скрыпты не можуць яе чытаць. Модуль прафіля:
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();
Это автаматычна адправляе кукі
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
Нотаткі для прыменнення
- Ўжываць
Secureкукізы через HTTPS. - Залічыць токены доступу ў памяці, а не у
localStorage.