Перейти к содержанию

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-данными