From af8920f9207cc928750c85200e54078bf9750222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 10 Jul 2026 10:04:12 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=BA?= =?UTF-8?q?=D0=B8=D0=B4=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D0=B5=D1=82=D0=B5=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5=20refresh,=20access=20toke?= =?UTF-8?q?n=2030=20=D0=B4=D0=BD=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - queryClient: refreshSession возвращает reason (network/unauthorized); при network-ошибке не делаем logout, а бросаем network_error_during_refresh. - useAuth: checkAuth и refreshUser не сбрасывают сессию при network-ошибке во время refresh, переводят в офлайн-режим. - auth.service: remember берётся из сессии, race tolerance 5 минут. - access token lifetime унифицирован до 30 дней по умолчанию, cookie maxAge теперь совпадает с JWT expiry (было 15 минут fallback). --- .env.example | 2 +- client/src/hooks/useAuth.ts | 21 +++++++++++++--- client/src/lib/authEvents.ts | 14 +++++++++-- client/src/lib/queryClient.ts | 43 ++++++++++++++++++++++----------- server/services/auth.service.ts | 4 +-- server/utils/jwt.ts | 2 +- 6 files changed, 62 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index c5b7793..841d4b8 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ JWT_SUPERADMIN_SECRET=your-superadmin-secret-here # JWT_BOT_SECRET=your-bot-secret-here # Token expiry (optional — these are the defaults) -# JWT_ACCESS_EXPIRES=14d +# JWT_ACCESS_EXPIRES=30d # JWT_REFRESH_EXPIRES_REMEMBER=90d # JWT_REFRESH_EXPIRES_SESSION=30d # JWT_SUPERADMIN_EXPIRES=1h diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index 4f1d5cd..cf4ea1c 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -68,11 +68,14 @@ export function useAuthProvider() { } } catch (error) { // Network failure: keep offline session alive if we have a cached user. + // A failed refresh because the laptop/phone was asleep or had no signal + // must NOT be treated as a permanent logout. if ( error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError') || !navigator.onLine || - (error instanceof Error && error.message === 'AUTH_CHECK_TIMEOUT') + (error instanceof Error && error.message === 'AUTH_CHECK_TIMEOUT') || + (error instanceof Error && error.message === 'network_error_during_refresh') ) { enterOfflineSession(cachedUser); return; @@ -141,7 +144,9 @@ export function useAuthProvider() { }, []); useEffect(() => { - const handleAuthFailure = () => { + const handleAuthFailure = (event: Event) => { + const detail = (event as CustomEvent).detail; + console.warn('[Auth] Unauthorized, redirecting to login:', detail); logout().finally(() => { const currentPath = window.location.pathname + window.location.search; const isAuthPage = currentPath.startsWith('/login') || currentPath === '/'; @@ -198,8 +203,16 @@ export function useAuthProvider() { } } catch (error) { console.error('User refresh failed:', error); - setUser(null); - authService.clearCachedUser(); + // Don't clear the session if we just couldn't reach the server. + const isNetworkError = + error instanceof TypeError || + (error instanceof DOMException && error.name === 'AbortError') || + !navigator.onLine || + (error instanceof Error && error.message === 'network_error_during_refresh'); + if (!isNetworkError) { + setUser(null); + authService.clearCachedUser(); + } } }; diff --git a/client/src/lib/authEvents.ts b/client/src/lib/authEvents.ts index fac9a4b..19ee488 100644 --- a/client/src/lib/authEvents.ts +++ b/client/src/lib/authEvents.ts @@ -1,5 +1,15 @@ export const AUTH_FAILURE_EVENT = 'auth:unauthorized'; -export function dispatchAuthFailure(): void { - window.dispatchEvent(new CustomEvent(AUTH_FAILURE_EVENT)); +export function dispatchAuthFailure(reason?: string): void { + // Capture the reason and the current navigation context for easier debugging + // of unexpected logouts on background tabs/PWAs. + const detail = { + reason: reason ?? 'unknown', + url: window.location.href, + timestamp: new Date().toISOString(), + }; + if (process.env.NODE_ENV !== 'production') { + console.warn('[Auth] dispatchAuthFailure:', detail); + } + window.dispatchEvent(new CustomEvent(AUTH_FAILURE_EVENT, { detail })); } diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 4c163b4..7d272ae 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -114,12 +114,16 @@ export async function fetchWithTimeout( } // Singleton refresh promise — prevents concurrent refresh races -let _refreshPromise: Promise | null = null; +let _refreshPromise: Promise | null = null; -async function refreshSession(): Promise { +type RefreshResult = + | { ok: true } + | { ok: false; reason: 'network' | 'unauthorized' }; + +async function refreshSession(): Promise { if (_refreshPromise) return _refreshPromise; - _refreshPromise = (async (): Promise => { + _refreshPromise = (async (): Promise => { try { const res = await fetchWithTimeout( '/api/auth/refresh', @@ -136,17 +140,19 @@ async function refreshSession(): Promise { const result = await res.json(); if (result.success && result.user) { authService.setCachedUser(result.user); - return true; + return { ok: true }; } } + // Server explicitly rejected the session. + return { ok: false, reason: 'unauthorized' }; } catch (error) { if (isNetworkFailure(error)) { console.warn('Token refresh skipped — no network connection or timeout'); - } else { - console.error('Token refresh failed:', error); + return { ok: false, reason: 'network' }; } + console.error('Token refresh failed:', error); + return { ok: false, reason: 'unauthorized' }; } - return false; })().finally(() => { _refreshPromise = null; }); @@ -210,8 +216,8 @@ export async function apiRequest( // If we got 401/403, try to refresh the session and retry once. if ((res.status === 401 || res.status === 403) && !url.includes('/api/auth/')) { - const refreshSuccess = await refreshSession(); - if (refreshSuccess) { + const refreshResult = await refreshSession(); + if (refreshResult.ok) { const retryRes = await fetchWithTimeout( url, { method, headers, body, credentials: "include" }, @@ -220,8 +226,13 @@ export async function apiRequest( await throwIfResNotOk(retryRes); return retryRes; } - // Refresh failed — session is permanently invalid - dispatchAuthFailure(); + if (refreshResult.reason === 'network') { + // The session may still be valid; we just couldn't reach the server. + // Don't force a logout — propagate a generic error so the caller can retry. + throw new Error('network_error_during_refresh'); + } + // Server explicitly rejected the session — permanent logout. + dispatchAuthFailure('refresh_rejected_by_server'); throw new UnauthorizedError(); } @@ -248,8 +259,8 @@ export const getQueryFn: (options: { // Пытаемся обновить сессию и повторить запрос (только если это не auth endpoint) if (!url.includes('/api/auth/')) { - const refreshSuccess = await refreshSession(); - if (refreshSuccess) { + const refreshResult = await refreshSession(); + if (refreshResult.ok) { const retryRes = await fetchWithTimeout(url, { credentials: "include", }, 10000); @@ -262,8 +273,12 @@ export const getQueryFn: (options: { await throwIfResNotOk(retryRes); return await retryRes.json(); } + if (refreshResult.reason === 'network') { + // Don't force logout when the refresh request itself failed on the network. + throw new Error('network_error_during_refresh'); + } // Refresh failed — session is permanently invalid - dispatchAuthFailure(); + dispatchAuthFailure('refresh_rejected_by_server'); throw new UnauthorizedError(); } } diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index dbd15ac..df1d266 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -48,7 +48,7 @@ function isSecureCookie(): boolean { } export function setAuthCookies(res: Response, tokens: TokenPair, remember: boolean): void { - const accessMaxAge = parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '15m') * 1000; + const accessMaxAge = parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '30d') * 1000; const refreshMaxAge = (remember ? REMEMBER_LIFETIME_DAYS : SESSION_LIFETIME_DAYS) * 24 * 60 * 60 * 1000; res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, { @@ -319,7 +319,7 @@ export class AuthService { tokens: { accessToken, refreshToken: activeSession.refreshToken, - expiresIn: parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '15m'), + expiresIn: parseExpiryToSeconds(process.env.JWT_ACCESS_EXPIRES || '30d'), familyId: activeSession.familyId, } }; diff --git a/server/utils/jwt.ts b/server/utils/jwt.ts index e0873f0..bf44c83 100644 --- a/server/utils/jwt.ts +++ b/server/utils/jwt.ts @@ -31,7 +31,7 @@ const REFRESH_TOKEN_SECRET = requireSecret('JWT_REFRESH_SECRET'); const SUPERADMIN_TOKEN_SECRET = requireSecret('JWT_SUPERADMIN_SECRET'); const BOT_TOKEN_SECRET = process.env.JWT_BOT_SECRET || ACCESS_TOKEN_SECRET; -const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '14d'; +const ACCESS_TOKEN_EXPIRY = process.env.JWT_ACCESS_EXPIRES || '30d'; const REFRESH_TOKEN_EXPIRY_REMEMBER = process.env.JWT_REFRESH_EXPIRES_REMEMBER || '90d'; const REFRESH_TOKEN_EXPIRY_SESSION = process.env.JWT_REFRESH_EXPIRES_SESSION || '30d'; const SUPERADMIN_TOKEN_EXPIRY = process.env.JWT_SUPERADMIN_EXPIRES || '1h';