From edaed04a039c1f9e04e36bce4ddaa0869e3ff2ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 30 Oct 2026 17:18:11 +0400 Subject: [PATCH] fix(auth): session no longer drops after access-token expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/api/auth.js | 35 +++++- web/src/api/client.js | 25 +++++ web/src/api/exports.js | 18 ++- web/src/api/utils/token.js | 42 ++----- web/src/api/utils/tokenRefresh.js | 32 ++++++ web/src/api/utils/tokenRefresh.test.js | 63 +++++++++++ web/src/app/Routes.jsx | 12 +- web/src/app/context/AuthProvider.jsx | 106 ++++++++++++++++-- .../RealtimeTable/hooks/useWebSocket.js | 27 ++++- web/src/pages/LoginPage.jsx | 6 +- 10 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 web/src/api/utils/tokenRefresh.js create mode 100644 web/src/api/utils/tokenRefresh.test.js diff --git a/web/src/api/auth.js b/web/src/api/auth.js index c1a0725..a649499 100644 --- a/web/src/api/auth.js +++ b/web/src/api/auth.js @@ -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; + } +}); diff --git a/web/src/api/client.js b/web/src/api/client.js index c2d402e..66421bb 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -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; diff --git a/web/src/api/exports.js b/web/src/api/exports.js index 050097c..efa5918 100644 --- a/web/src/api/exports.js +++ b/web/src/api/exports.js @@ -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++)); }; }; diff --git a/web/src/api/utils/token.js b/web/src/api/utils/token.js index 2ebdc08..4caa975 100644 --- a/web/src/api/utils/token.js +++ b/web/src/api/utils/token.js @@ -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(); -}; diff --git a/web/src/api/utils/tokenRefresh.js b/web/src/api/utils/tokenRefresh.js new file mode 100644 index 0000000..97cb59c --- /dev/null +++ b/web/src/api/utils/tokenRefresh.js @@ -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; + }; +} diff --git a/web/src/api/utils/tokenRefresh.test.js b/web/src/api/utils/tokenRefresh.test.js new file mode 100644 index 0000000..4aca68e --- /dev/null +++ b/web/src/api/utils/tokenRefresh.test.js @@ -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); +}); diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx index 1cf7196..aa66fe7 100644 --- a/web/src/app/Routes.jsx +++ b/web/src/app/Routes.jsx @@ -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) { - ; - } - - return isAuthenticated ? : ; + return isAuthenticated ? : ; }; export const AppRoutes = () => { diff --git a/web/src/app/context/AuthProvider.jsx b/web/src/app/context/AuthProvider.jsx index 2904de2..718e121 100644 --- a/web/src/app/context/AuthProvider.jsx +++ b/web/src/app/context/AuthProvider.jsx @@ -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 ( diff --git a/web/src/components/RealtimeTable/hooks/useWebSocket.js b/web/src/components/RealtimeTable/hooks/useWebSocket.js index 3e91faf..b7c7d93 100644 --- a/web/src/components/RealtimeTable/hooks/useWebSocket.js +++ b/web/src/components/RealtimeTable/hooks/useWebSocket.js @@ -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; } diff --git a/web/src/pages/LoginPage.jsx b/web/src/pages/LoginPage.jsx index 8496950..c92de25 100644 --- a/web/src/pages/LoginPage.jsx +++ b/web/src/pages/LoginPage.jsx @@ -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('Неверный логин или пароль'); }