JWT Interceptors и authStore — полный код¶
API-клиент с interceptors¶
Файл: frontend/src/api/client.ts
import axios from 'axios';
import { useAuthStore } from '../stores/auth';
const baseURL = import.meta.env.VITE_API_URL || '/api';
export const apiClient = axios.create({
baseURL,
headers: { 'Content-Type': 'application/json' },
});
// Request interceptor: добавляет Bearer token к каждому з��просу
apiClient.interceptors.request.use((config) => {
const { accessToken } = useAuthStore.getState();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Флаг и очередь для предотвращения параллельных refresh-запросов
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}> = [];
function processQueue(error: unknown) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(undefined);
}
});
failedQueue = [];
}
// Response interceptor: при 401 — refresh token, при неудаче — logout
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Не перехватывать: не-401, повторные попытки, сам refresh/login
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/login')) {
return Promise.reject(error);
}
// Если уже идёт refresh — ставим запрос в очередь
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => apiClient(originalRequest));
}
originalRequest._retry = true;
isRefreshing = true;
const { refreshToken, setTokens, logout } = useAuthStore.getState();
// Нет refresh token — сразу logout
if (!refreshToken) {
await logout();
isRefreshing = false;
return Promise.reject(error);
}
try {
// Используем чистый axios (не apiClient) чтобы избежать рекурсии interceptors
const { data } = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
});
setTokens(data.access_token, data.refresh_token);
processQueue(null);
return apiClient(originalRequest); // Повторяем оригинальный запрос
} catch (refreshError) {
processQueue(refreshError);
await logout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
authStore (Zustand)¶
Файл: frontend/src/stores/auth.ts
import { create } from 'zustand';
import type { User } from '../types/api';
import { clearLocalData } from '../offline/services/localData';
import { clearFeedSessionCache } from '../utils/feedSessionCache';
const TOKEN_KEY = 'plechiki_access_token';
const REFRESH_KEY = 'plechiki_refresh_token';
const USER_KEY = 'plechiki_user';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: User | null;
isAuthenticated: boolean;
setTokens: (access: string, refresh?: string) => void;
setUser: (user: User | null) => void;
logout: () => Promise<void>;
hydrate: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
// Сохранение токенов в state и localStorage
setTokens: (access, refresh) => {
localStorage.setItem(TOKEN_KEY, access);
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
set({
accessToken: access,
refreshToken: refresh ?? null,
isAuthenticated: true,
});
},
// Сохранение/удаление профиля пользователя
setUser: (user) => {
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(USER_KEY);
}
set({ user });
},
// Полный выход: очистка токенов, localStorage, offline-данных, кеша ленты
logout: async () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
set({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
});
try {
await clearLocalData();
} catch (error) {
console.warn('[Auth] Failed to clear local offline data on logout', error);
}
clearFeedSessionCache();
},
// Восстановление состояния из localStorage (вызывается при старте приложения)
hydrate: () => {
const accessToken = localStorage.getItem(TOKEN_KEY);
const refreshToken = localStorage.getItem(REFRESH_KEY);
const userJson = localStorage.getItem(USER_KEY);
let user: User | null = null;
if (userJson) {
try {
user = JSON.parse(userJson);
} catch {
localStorage.removeItem(USER_KEY);
}
}
set({
accessToken,
refreshToken,
user,
isAuthenticated: !!accessToken,
});
},
}));
Архитектурные решения¶
| Решение | Обоснование |
|---|---|
| Zustand (не Context) | Доступ к state вне React-дерева (getState() в interceptors) |
| localStorage (не httpOnly cookie) | Необходимость доступа из JS для interceptors; SPA без SSR |
| Очередь failedQueue | Предотвращение race condition при параллельных 401 |
| Отдельный axios.post для refresh | Исключение рекурсии interceptors |
| clearLocalData() при logout | Очистка IndexedDB с offline-данными |