fix(auth): session no longer drops after access-token expiry

Access-токен жил 60 минут без обновления: фронт не слал response-interceptor
на 401, refresh-токен лежал в localStorage неиспользуемым, WebSocket-сокеты
(форма, статусы выгрузки) не переподключались после закрытия с auth-кодом,
а после редиректа на /login пользователь терял исходную страницу.

- tokenRefresh.js: чистые getTokenExp/msUntilRefresh/createSingleFlight (TDD).
- auth.js: refreshTokens — обновление токена вне интерсепторов api (без
  рекурсии), централизованно чистит токены и шлёт auth:expired при неудаче.
- client.js: response-interceptor повторяет запрос после обновления токена.
- AuthProvider: при старте и по 'auth:expired'/'auth:refreshed' не выкидывает
  из системы, пока жив refresh; проактивный таймер обновления + visibilitychange.
- Routes/LoginPage: PrivateRoute сохраняет исходный location, вход возвращает
  туда же вместо /tasks; убрана мёртвая ветка loading.
- useWebSocket/exports: переподключение после 1008/4401 с одной попыткой
  refreshTokens() перед тем как сдаться.

Issue: #248 (AURORA-1877, эпик AURORA-1876, Критичный приоритет)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-30 17:18:11 +04:00
parent 2a287480b6
commit edaed04a03
10 changed files with 311 additions and 55 deletions

View File

@ -1,4 +1,8 @@
import axios from 'axios';
import { Config } from '../conf/config';
import api from './client';
import { clearAuthTokens } from './utils/token';
import { createSingleFlight } from './utils/tokenRefresh';
export const AuthApi = {
async login(username, password) {
@ -14,10 +18,19 @@ export const AuthApi = {
},
async refresh() {
const refresh_token = localStorage.getItem('refresh_token');
if (!refresh_token) return null;
const { data } = await api.post('/auth/refresh', { refresh_token });
if (!refresh_token) {
throw new Error('Нет refresh-токена');
}
// "Голый" axios, в обход клиента api и его интерсепторов — иначе
// 401 на /auth/refresh сам же запустит повторное обновление токена.
const { data } = await axios.post(`${Config.API_URL}/api/v1/auth/refresh`, { refresh_token });
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
try {
window.dispatchEvent(new CustomEvent('auth:refreshed'));
} catch (error) {
console.error('Ошибка обновления токена', error);
}
return data;
},
logout() {
@ -30,3 +43,21 @@ export const AuthApi = {
}
},
};
// Пока обновление токена в полёте, все одновременные вызыватели получают
// один и тот же Promise — иначе параллельные 401 запускают по refresh-запросу каждый.
// Неудача означает, что сессию больше не восстановить: токены чистим и шлём
// auth:expired централизованно здесь, чтобы не дублировать это в каждом вызывающем месте.
export const refreshTokens = createSingleFlight(async () => {
try {
return await AuthApi.refresh();
} catch (error) {
clearAuthTokens();
try {
window.dispatchEvent(new CustomEvent('auth:expired'));
} catch (dispatchError) {
console.error('Ошибка при оповещении об истечении сессии', dispatchError);
}
throw error;
}
});

View File

@ -18,4 +18,29 @@ api.interceptors.request.use((config) => {
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config;
const status = error.response?.status;
const isAuthEndpoint = config?.url?.includes('/auth/login') || config?.url?.includes('/auth/refresh');
if (status !== 401 || !config || config._retry || isAuthEndpoint) {
return Promise.reject(error);
}
config._retry = true;
try {
// Динамический импорт разрывает цикл client.js <-> auth.js на этапе загрузки модулей.
const { refreshTokens } = await import('./auth');
const data = await refreshTokens();
config.headers.Authorization = `Bearer ${data.access_token}`;
return api(config);
} catch {
// Очистку токенов и auth:expired при неудаче уже выполнил refreshTokens.
return Promise.reject(error);
}
},
);
export default api;

View File

@ -1,3 +1,4 @@
import { refreshTokens } from './auth';
import api from './client';
let jobs = [];
@ -51,6 +52,7 @@ export function startExportUpdates() {
let retry;
let watchdog;
let attempts = 0;
let authRetried = false;
const connect = () => {
if (stopped) return;
const url = new URL(`${api.defaults.baseURL}/ws/export`, window.location.origin);
@ -63,6 +65,7 @@ export function startExportUpdates() {
socket.onopen = () => {
socket.send(JSON.stringify({ token: localStorage.getItem('access_token') }));
armWatchdog();
authRetried = false;
};
socket.onmessage = (event) => {
if (stopped) return;
@ -77,7 +80,20 @@ export function startExportUpdates() {
};
socket.onclose = (event) => {
clearTimeout(watchdog);
if (stopped || event.code === 4401) return;
if (stopped) return;
if (event.code === 4401) {
if (authRetried) return;
authRetried = true;
// Токен в localStorage мог истечь к моменту подключения — обновляем один раз и пробуем снова.
refreshTokens()
.then(() => {
if (!stopped) connect();
})
.catch(() => {
/* auth:expired уже отправлен из refreshTokens. */
});
return;
}
retry = setTimeout(connect, Math.min(30000, 2000 * 2 ** attempts++));
};
};

View File

@ -1,3 +1,5 @@
import { getTokenExp } from './tokenRefresh.js';
export const saveAuthTokens = (data) => {
const { access_token, refresh_token, expires_in } = data;
@ -16,41 +18,21 @@ export const getAuthToken = () => {
};
export const isTokenExpired = () => {
try {
const token = getAuthToken();
if (!token) {
return true;
}
// TODO: с бэка сейчас приходит expired_in: null. Если поправят, то это логика будет не нужна. Пока что оставляю так
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
const payload = JSON.parse(jsonPayload);
const now = Math.floor(Date.now() / 1000);
// для проверки времени жизни токена
const expirationDate = new Date(payload.exp * 1000);
console.log('Токен истекает:', expirationDate.toLocaleString('ru-RU'));
return payload.exp < now;
} catch (err) {
console.error('Ошибка при разборе токена:', err.message);
const token = getAuthToken();
if (!token) {
return true;
}
const exp = getTokenExp(token);
if (exp === null) {
return true;
}
const now = Math.floor(Date.now() / 1000);
return exp < now;
};
export const clearAuthTokens = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
};
// TODO: отсутствует функционал обновления токена. добавить логику когда сделают на бэке. пока просто очистка LS
export const updateToken = () => {
clearAuthTokens();
};

View File

@ -0,0 +1,32 @@
export function getTokenExp(token) {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
const payload = JSON.parse(jsonPayload);
return typeof payload.exp === 'number' ? payload.exp : null;
} catch {
return null;
}
}
export function msUntilRefresh(exp, nowMs, skewSec = 120) {
const refreshAtMs = (exp - skewSec) * 1000;
return Math.max(0, refreshAtMs - nowMs);
}
export function createSingleFlight(fn) {
let inFlight = null;
return (...args) => {
if (!inFlight) {
inFlight = fn(...args).finally(() => {
inFlight = null;
});
}
return inFlight;
};
}

View File

@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSingleFlight, getTokenExp, msUntilRefresh } from './tokenRefresh.js';
function makeToken(payload) {
const base64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
return `${base64url({ alg: 'HS256', typ: 'JWT' })}.${base64url(payload)}.signature`;
}
test('getTokenExp reads the exp claim from a valid token', () => {
const token = makeToken({ sub: 'user', exp: 1700000000 });
assert.equal(getTokenExp(token), 1700000000);
});
test('getTokenExp returns null for a malformed token', () => {
assert.equal(getTokenExp('not-a-jwt'), null);
assert.equal(getTokenExp(''), null);
assert.equal(getTokenExp(null), null);
});
test('msUntilRefresh schedules a refresh skewSec before expiry', () => {
const nowMs = 1700000000 * 1000;
const exp = 1700000000 + 600; // expires in 10 minutes
assert.equal(msUntilRefresh(exp, nowMs, 120), 480000); // (600 - 120) * 1000
});
test('msUntilRefresh clamps to 0 for an already-expired token', () => {
const nowMs = 1700000000 * 1000;
const exp = 1700000000 - 60; // expired a minute ago
assert.equal(msUntilRefresh(exp, nowMs, 120), 0);
});
test('createSingleFlight shares one in-flight call across concurrent callers', async () => {
let calls = 0;
let resolveFn;
const fn = () =>
new Promise((resolve) => {
calls++;
resolveFn = resolve;
});
const singleFlight = createSingleFlight(fn);
const first = singleFlight();
const second = singleFlight();
resolveFn('result');
assert.equal(await first, 'result');
assert.equal(await second, 'result');
assert.equal(calls, 1);
});
test('createSingleFlight calls fn again after the previous call failed', async () => {
let calls = 0;
const fn = () => {
calls++;
return calls === 1 ? Promise.reject(new Error('fail')) : Promise.resolve('ok');
};
const singleFlight = createSingleFlight(fn);
await assert.rejects(singleFlight(), /fail/);
assert.equal(await singleFlight(), 'ok');
assert.equal(calls, 2);
});

View File

@ -1,5 +1,4 @@
import { CircularProgress } from '@mui/material';
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
@ -20,13 +19,10 @@ import { useAuth } from './context/AuthProvider';
import { Layout } from './layout/Layout';
export const PrivateRoute = () => {
const { isAuthenticated, loading } = useAuth();
const { isAuthenticated } = useAuth();
const location = useLocation();
if (loading) {
<CircularProgress />;
}
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace />;
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace state={{ from: location }} />;
};
export const AppRoutes = () => {

View File

@ -1,18 +1,18 @@
import { CircularProgress } from '@mui/material';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { AuthApi } from '../../api/auth';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { AuthApi, refreshTokens } from '../../api/auth';
import { UsersApi } from '../../api/users';
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
import { getTokenExp, msUntilRefresh } from '../../api/utils/tokenRefresh';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(() => {
const token = getAuthToken();
return !!token && !isTokenExpired();
});
const [isAuthenticated, setIsAuthenticated] = useState(false);
const refreshTimerRef = useRef(null);
const loadUser = useCallback(async () => {
if (!isAuthenticated) {
@ -36,12 +36,37 @@ export const AuthProvider = ({ children }) => {
}
}, [isAuthenticated]);
const clearRefreshTimer = useCallback(() => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
}, []);
const scheduleProactiveRefresh = useCallback(() => {
clearRefreshTimer();
const token = getAuthToken();
const exp = token ? getTokenExp(token) : null;
if (exp === null) {
return;
}
refreshTimerRef.current = setTimeout(
() => {
refreshTokens().catch(() => {
// неудачу обработает auth:expired, который шлёт response-interceptor клиента
});
},
msUntilRefresh(exp, Date.now()),
);
}, [clearRefreshTimer]);
const login = async (username, password) => {
setLoading(true);
try {
const data = await AuthApi.login(username, password);
saveAuthTokens(data);
setIsAuthenticated(true);
scheduleProactiveRefresh();
try {
const userData = await UsersApi.me();
@ -63,6 +88,7 @@ export const AuthProvider = ({ children }) => {
const logout = async () => {
try {
clearRefreshTimer();
AuthApi.logout();
setIsAuthenticated(false);
setUser(null);
@ -72,8 +98,48 @@ export const AuthProvider = ({ children }) => {
};
useEffect(() => {
setLoading(false);
}, []);
let cancelled = false;
const initAuth = async () => {
const token = getAuthToken();
if (!token) {
setLoading(false);
return;
}
if (!isTokenExpired()) {
if (!cancelled) {
setIsAuthenticated(true);
scheduleProactiveRefresh();
}
setLoading(false);
return;
}
// Access-токен просрочен, но раз есть refresh — пробуем обновить незаметно,
// прежде чем решать, что пользователь не авторизован.
try {
await refreshTokens();
if (!cancelled) {
setIsAuthenticated(true);
scheduleProactiveRefresh();
}
} catch (error) {
console.error('Не удалось обновить токен при загрузке:', error);
// Очистку токенов и событие auth:expired (обработчик ниже выставит isAuthenticated=false) уже выполнил refreshTokens.
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
initAuth();
return () => {
cancelled = true;
};
}, [scheduleProactiveRefresh]);
useEffect(() => {
loadUser();
@ -86,19 +152,41 @@ export const AuthProvider = ({ children }) => {
setIsAuthenticated(newIsAuthenticated);
};
const handleAuthRefreshed = () => {
setIsAuthenticated(true);
scheduleProactiveRefresh();
};
const handleAuthExpired = () => {
clearRefreshTimer();
setIsAuthenticated(false);
setUser(null);
toast.error('Сессия истекла, войдите снова');
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
return;
}
const token = getAuthToken();
if (token && !isTokenExpired()) {
scheduleProactiveRefresh();
}
};
window.addEventListener('auth:changed', handleAuthChange);
window.addEventListener('auth:refreshed', handleAuthRefreshed);
window.addEventListener('auth:expired', handleAuthExpired);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('auth:changed', handleAuthChange);
window.removeEventListener('auth:refreshed', handleAuthRefreshed);
window.removeEventListener('auth:expired', handleAuthExpired);
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearRefreshTimer();
};
}, []);
}, [clearRefreshTimer, scheduleProactiveRefresh]);
return (
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser }}>

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { refreshTokens } from '../../../api/auth';
export const useWebSocket = (url, onMessage) => {
const [isConnected, setIsConnected] = useState(false);
@ -7,6 +8,7 @@ export const useWebSocket = (url, onMessage) => {
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttempts = useRef(0);
const authRetryAttemptedRef = useRef(false);
useEffect(() => {
isConnectedRef.current = isConnected;
@ -25,6 +27,7 @@ export const useWebSocket = (url, onMessage) => {
setIsConnected(true);
setError(null);
reconnectAttempts.current = 0;
authRetryAttemptedRef.current = false;
};
ws.onmessage = (event) => {
@ -47,9 +50,27 @@ export const useWebSocket = (url, onMessage) => {
console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean);
setIsConnected(false);
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации
if (event.code === 1008 || event.code === 1000) {
console.log('Connection closed intentionally or auth error, not reconnecting');
// Не переподключаемся если закрыто intentionally (disconnect())
if (event.code === 1000) {
console.log('Connection closed intentionally, not reconnecting');
return;
}
// Сервер закрыл с ошибкой авторизации — токен в localStorage мог успеть истечь
// к моменту (пере)подключения. Пробуем обновить его один раз и подключиться заново;
// sendLogin на RealtimeContext сам возьмёт новый токен из localStorage.
if (event.code === 1008) {
if (authRetryAttemptedRef.current) {
console.log('Auth error persists after token refresh, not reconnecting');
return;
}
authRetryAttemptedRef.current = true;
console.log('Auth error, refreshing token before reconnecting');
refreshTokens()
.then(() => connect())
.catch((err) => {
console.error('Token refresh failed, giving up on reconnect:', err);
});
return;
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../app/context/AuthProvider';
import { isTestMode } from '../conf/config';
@ -9,6 +9,7 @@ export default function LoginPage() {
const [error, setError] = useState('');
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (isTestMode && isAuthenticated) {
@ -21,7 +22,8 @@ export default function LoginPage() {
e.preventDefault();
try {
await login(username, password);
navigate('/tasks');
const from = location.state?.from;
navigate(from ? `${from.pathname}${from.search ?? ''}` : '/tasks', { replace: true });
} catch (err) {
setError('Неверный логин или пароль');
}