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('Неверный логин или пароль');
}