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:
parent
2a287480b6
commit
edaed04a03
@ -1,4 +1,8 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Config } from '../conf/config';
|
||||||
import api from './client';
|
import api from './client';
|
||||||
|
import { clearAuthTokens } from './utils/token';
|
||||||
|
import { createSingleFlight } from './utils/tokenRefresh';
|
||||||
|
|
||||||
export const AuthApi = {
|
export const AuthApi = {
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
@ -14,10 +18,19 @@ export const AuthApi = {
|
|||||||
},
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
const refresh_token = localStorage.getItem('refresh_token');
|
const refresh_token = localStorage.getItem('refresh_token');
|
||||||
if (!refresh_token) return null;
|
if (!refresh_token) {
|
||||||
const { data } = await api.post('/auth/refresh', { 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('access_token', data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:refreshed'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления токена', error);
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
logout() {
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -18,4 +18,29 @@ api.interceptors.request.use((config) => {
|
|||||||
return 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;
|
export default api;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { refreshTokens } from './auth';
|
||||||
import api from './client';
|
import api from './client';
|
||||||
|
|
||||||
let jobs = [];
|
let jobs = [];
|
||||||
@ -51,6 +52,7 @@ export function startExportUpdates() {
|
|||||||
let retry;
|
let retry;
|
||||||
let watchdog;
|
let watchdog;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
let authRetried = false;
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const url = new URL(`${api.defaults.baseURL}/ws/export`, window.location.origin);
|
const url = new URL(`${api.defaults.baseURL}/ws/export`, window.location.origin);
|
||||||
@ -63,6 +65,7 @@ export function startExportUpdates() {
|
|||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
socket.send(JSON.stringify({ token: localStorage.getItem('access_token') }));
|
socket.send(JSON.stringify({ token: localStorage.getItem('access_token') }));
|
||||||
armWatchdog();
|
armWatchdog();
|
||||||
|
authRetried = false;
|
||||||
};
|
};
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
@ -77,7 +80,20 @@ export function startExportUpdates() {
|
|||||||
};
|
};
|
||||||
socket.onclose = (event) => {
|
socket.onclose = (event) => {
|
||||||
clearTimeout(watchdog);
|
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++));
|
retry = setTimeout(connect, Math.min(30000, 2000 * 2 ** attempts++));
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { getTokenExp } from './tokenRefresh.js';
|
||||||
|
|
||||||
export const saveAuthTokens = (data) => {
|
export const saveAuthTokens = (data) => {
|
||||||
const { access_token, refresh_token, expires_in } = data;
|
const { access_token, refresh_token, expires_in } = data;
|
||||||
|
|
||||||
@ -16,41 +18,21 @@ export const getAuthToken = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isTokenExpired = () => {
|
export const isTokenExpired = () => {
|
||||||
try {
|
const token = getAuthToken();
|
||||||
const token = getAuthToken();
|
if (!token) {
|
||||||
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);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exp = getTokenExp(token);
|
||||||
|
if (exp === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return exp < now;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAuthTokens = () => {
|
export const clearAuthTokens = () => {
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: отсутствует функционал обновления токена. добавить логику когда сделают на бэке. пока просто очистка LS
|
|
||||||
export const updateToken = () => {
|
|
||||||
clearAuthTokens();
|
|
||||||
};
|
|
||||||
|
|||||||
32
web/src/api/utils/tokenRefresh.js
Normal file
32
web/src/api/utils/tokenRefresh.js
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
63
web/src/api/utils/tokenRefresh.test.js
Normal file
63
web/src/api/utils/tokenRefresh.test.js
Normal 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);
|
||||||
|
});
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { CircularProgress } from '@mui/material';
|
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
|
||||||
import AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
|
import AuditLogsPage from '../pages/AdminPanel/AuditLogsPage/AuditLogsPage.jsx';
|
||||||
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
|
import SspsPage from '../pages/AdminPanel/SppsPage/SppsPage';
|
||||||
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
|
import UsersPage from '../pages/AdminPanel/UsersPage/UsersPage';
|
||||||
@ -20,13 +19,10 @@ import { useAuth } from './context/AuthProvider';
|
|||||||
import { Layout } from './layout/Layout';
|
import { Layout } from './layout/Layout';
|
||||||
|
|
||||||
export const PrivateRoute = () => {
|
export const PrivateRoute = () => {
|
||||||
const { isAuthenticated, loading } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace state={{ from: location }} />;
|
||||||
<CircularProgress />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace />;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppRoutes = () => {
|
export const AppRoutes = () => {
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import { CircularProgress } from '@mui/material';
|
import { CircularProgress } from '@mui/material';
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { AuthApi } from '../../api/auth';
|
import { toast } from 'react-toastify';
|
||||||
|
import { AuthApi, refreshTokens } from '../../api/auth';
|
||||||
import { UsersApi } from '../../api/users';
|
import { UsersApi } from '../../api/users';
|
||||||
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
|
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
|
||||||
|
import { getTokenExp, msUntilRefresh } from '../../api/utils/tokenRefresh';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const token = getAuthToken();
|
const refreshTimerRef = useRef(null);
|
||||||
return !!token && !isTokenExpired();
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@ -36,12 +36,37 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [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) => {
|
const login = async (username, password) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await AuthApi.login(username, password);
|
const data = await AuthApi.login(username, password);
|
||||||
saveAuthTokens(data);
|
saveAuthTokens(data);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
scheduleProactiveRefresh();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userData = await UsersApi.me();
|
const userData = await UsersApi.me();
|
||||||
@ -63,6 +88,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
|
clearRefreshTimer();
|
||||||
AuthApi.logout();
|
AuthApi.logout();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -72,8 +98,48 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
loadUser();
|
loadUser();
|
||||||
@ -86,19 +152,41 @@ export const AuthProvider = ({ children }) => {
|
|||||||
setIsAuthenticated(newIsAuthenticated);
|
setIsAuthenticated(newIsAuthenticated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAuthRefreshed = () => {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
scheduleProactiveRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
const handleAuthExpired = () => {
|
const handleAuthExpired = () => {
|
||||||
|
clearRefreshTimer();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
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:changed', handleAuthChange);
|
||||||
|
window.addEventListener('auth:refreshed', handleAuthRefreshed);
|
||||||
window.addEventListener('auth:expired', handleAuthExpired);
|
window.addEventListener('auth:expired', handleAuthExpired);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('auth:changed', handleAuthChange);
|
window.removeEventListener('auth:changed', handleAuthChange);
|
||||||
|
window.removeEventListener('auth:refreshed', handleAuthRefreshed);
|
||||||
window.removeEventListener('auth:expired', handleAuthExpired);
|
window.removeEventListener('auth:expired', handleAuthExpired);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
clearRefreshTimer();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [clearRefreshTimer, scheduleProactiveRefresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser }}>
|
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser }}>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { refreshTokens } from '../../../api/auth';
|
||||||
|
|
||||||
export const useWebSocket = (url, onMessage) => {
|
export const useWebSocket = (url, onMessage) => {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@ -7,6 +8,7 @@ export const useWebSocket = (url, onMessage) => {
|
|||||||
const wsRef = useRef(null);
|
const wsRef = useRef(null);
|
||||||
const reconnectTimeoutRef = useRef(null);
|
const reconnectTimeoutRef = useRef(null);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
|
const authRetryAttemptedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isConnectedRef.current = isConnected;
|
isConnectedRef.current = isConnected;
|
||||||
@ -25,6 +27,7 @@ export const useWebSocket = (url, onMessage) => {
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
reconnectAttempts.current = 0;
|
reconnectAttempts.current = 0;
|
||||||
|
authRetryAttemptedRef.current = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@ -47,9 +50,27 @@ export const useWebSocket = (url, onMessage) => {
|
|||||||
console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean);
|
console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|
||||||
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации
|
// Не переподключаемся если закрыто intentionally (disconnect())
|
||||||
if (event.code === 1008 || event.code === 1000) {
|
if (event.code === 1000) {
|
||||||
console.log('Connection closed intentionally or auth error, not reconnecting');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { useAuth } from '../app/context/AuthProvider';
|
||||||
import { isTestMode } from '../conf/config';
|
import { isTestMode } from '../conf/config';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { login, isAuthenticated } = useAuth();
|
const { login, isAuthenticated } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTestMode && isAuthenticated) {
|
if (isTestMode && isAuthenticated) {
|
||||||
@ -21,7 +22,8 @@ export default function LoginPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
navigate('/tasks');
|
const from = location.state?.from;
|
||||||
|
navigate(from ? `${from.pathname}${from.search ?? ''}` : '/tasks', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Неверный логин или пароль');
|
setError('Неверный логин или пароль');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user