diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index cf4ea1c..adae878 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -1,5 +1,6 @@ import { useState, useEffect, createContext, useContext, useCallback } from 'react'; import { authService } from '@/services/auth.service'; +import { refreshSession } from '@/lib/queryClient'; import type { User } from '@/types/auth.types'; import { AUTH_FAILURE_EVENT } from '@/lib/authEvents'; @@ -161,15 +162,17 @@ export function useAuthProvider() { }; }, [logout]); - // Keep the session alive in long-lived tabs/PWAs. With a 14-day access token - // this is mostly defensive: it refreshes cookies and prevents the family hard - // cap from expiring while the app is open. + // Keep the session alive in long-lived tabs/PWAs. With a 15-minute access + // token this refreshes cookies and prevents the family hard cap from + // expiring while the app is open. Рефреш идёт через единый refreshSession + // (singleton + межвкладочная дедупликация), иначе параллельные рефреши + // из разных вкладок/хуков попадают под rotation-гонку. useEffect(() => { if (!user) return; const KEEP_ALIVE_MS = 12 * 60 * 60 * 1000; // 12 hours const interval = setInterval(() => { - authService.refreshTokens().catch(() => {}); + void refreshSession(); }, KEEP_ALIVE_MS); let lastFocusRefresh = 0; @@ -179,7 +182,7 @@ export function useAuthProvider() { const now = Date.now(); if (now - lastFocusRefresh < FOCUS_REFRESH_INTERVAL_MS) return; lastFocusRefresh = now; - authService.refreshTokens().catch(() => {}); + void refreshSession(); }; document.addEventListener('visibilitychange', handleVisibilityChange); diff --git a/client/src/hooks/useOfflineSync.ts b/client/src/hooks/useOfflineSync.ts index 99606ba..153065d 100644 --- a/client/src/hooks/useOfflineSync.ts +++ b/client/src/hooks/useOfflineSync.ts @@ -15,7 +15,7 @@ import { performInitialSync, checkServerReachable, } from '@/lib/syncEngine'; -import { queryClient } from '@/lib/queryClient'; +import { queryClient, refreshSession } from '@/lib/queryClient'; import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache'; import type { InboxTaskSnapshot } from '@/lib/tasksCache'; import { sseManager } from '@/lib/sseManager'; @@ -215,14 +215,10 @@ async function tryUploadFile( }); if ((res.status === 401 || res.status === 403) && allowRefresh) { - // Try a single session refresh; if it fails the user must re-authenticate. - const refreshRes = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - credentials: 'include', - }); - if (refreshRes.ok) { + // Try a single session refresh via the shared refreshSession (singleton + + // cross-tab dedup); if it fails the user must re-authenticate. + const refreshResult = await refreshSession(); + if (refreshResult.ok) { return tryUploadFile(file, false); } } @@ -282,13 +278,8 @@ async function trySendAttachmentMessage( }); if ((res.status === 401 || res.status === 403) && allowRefresh) { - const refreshRes = await fetch('/api/auth/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - credentials: 'include', - }); - if (refreshRes.ok) { + const refreshResult = await refreshSession(); + if (refreshResult.ok) { return trySendAttachmentMessage(item, attachments, false); } } diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 7d272ae..b11f552 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -118,12 +118,60 @@ let _refreshPromise: Promise | null = null; type RefreshResult = | { ok: true } - | { ok: false; reason: 'network' | 'unauthorized' }; + | { ok: false; reason: 'transient' | 'unauthorized' }; -async function refreshSession(): Promise { +// Межвкладочная дедупликация рефреша: вкладка-лидер шлёт 'start'/'done', +// остальные вкладки ждут 'done' вместо собственного запроса (cookie-jar общий, +// результат рефреша в другой вкладке действует и здесь). +const AUTH_REFRESH_CHANNEL = 'auth-refresh'; +const CROSS_TAB_WAIT_TIMEOUT_MS = 8000; + +let _channel: BroadcastChannel | null | undefined; // undefined = ещё не инициализирован +let _crossTabWaiter: Promise | null = null; +let _crossTabResolve: ((result: RefreshResult) => void) | null = null; + +function getRefreshChannel(): BroadcastChannel | null { + if (_channel !== undefined) return _channel; + if (typeof BroadcastChannel === 'undefined') { + _channel = null; + return null; + } + try { + _channel = new BroadcastChannel(AUTH_REFRESH_CHANNEL); + _channel.onmessage = (event: MessageEvent) => { + const msg = event.data as { type?: string; result?: RefreshResult } | undefined; + if (msg?.type === 'start' && !_refreshPromise && !_crossTabWaiter) { + _crossTabWaiter = new Promise((resolve) => { + _crossTabResolve = resolve; + setTimeout(() => { + // Лидер не прислал результат (закрылся/упал) — пробуем сами. + _crossTabWaiter = null; + _crossTabResolve = null; + resolve({ ok: false, reason: 'transient' }); + }, CROSS_TAB_WAIT_TIMEOUT_MS); + }); + } else if (msg?.type === 'done' && _crossTabResolve) { + _crossTabResolve(msg.result ?? { ok: false, reason: 'transient' }); + _crossTabWaiter = null; + _crossTabResolve = null; + } + }; + } catch { + _channel = null; + } + return _channel; +} + +export async function refreshSession(): Promise { if (_refreshPromise) return _refreshPromise; + const channel = getRefreshChannel(); + // Другая вкладка уже рефрешит — ждём её результат. + if (_crossTabWaiter) return _crossTabWaiter; + _refreshPromise = (async (): Promise => { + channel?.postMessage({ type: 'start' }); + let result: RefreshResult; try { const res = await fetchWithTimeout( '/api/auth/refresh', @@ -137,22 +185,34 @@ async function refreshSession(): Promise { ); if (res.ok) { - const result = await res.json(); - if (result.success && result.user) { - authService.setCachedUser(result.user); - return { ok: true }; + const data = await res.json(); + if (data.success && data.user) { + authService.setCachedUser(data.user); + result = { ok: true }; + } else { + // 200 без user — аномалия, но не отказ сессии: считаем временным сбоем. + result = { ok: false, reason: 'transient' }; } + } else if (res.status === 401 || res.status === 403 || res.status === 400) { + // 401/403 — сервер явно отклонил сессию; 400 — refresh-cookie + // отсутствует вовсе (истёк по сроку/вычищен). Все три — сессия мертва. + result = { ok: false, reason: 'unauthorized' }; + } else { + // 429 (rate limit), 5xx — временный сбой, сессия может быть жива. + console.warn(`Token refresh got transient HTTP ${res.status}`); + result = { ok: false, reason: 'transient' }; } - // 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'); - return { ok: false, reason: 'network' }; + } else { + console.error('Token refresh failed:', error); } - console.error('Token refresh failed:', error); - return { ok: false, reason: 'unauthorized' }; + // Сетевой сбой/таймаут — сессию не считаем мёртвой. + result = { ok: false, reason: 'transient' }; } + channel?.postMessage({ type: 'done', result }); + return result; })().finally(() => { _refreshPromise = null; }); @@ -160,6 +220,15 @@ async function refreshSession(): Promise { return _refreshPromise; } +// GET /api/auth/me — единственный auth-endpoint, для которого при 401/403 +// разрешена ОДНА попытка refresh + retry (холодный старт с истёкшим +// access-cookie, но живым refresh-cookie). Остальные /api/auth/* (login, +// refresh, logout...) рефрешить нельзя — иначе цикл. +function isRefreshRetryAllowed(url: string): boolean { + if (!url.includes('/api/auth/')) return true; + return url.includes('/api/auth/me'); +} + export async function apiRequest( method: string, url: string, @@ -215,7 +284,7 @@ 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/')) { + if ((res.status === 401 || res.status === 403) && isRefreshRetryAllowed(url)) { const refreshResult = await refreshSession(); if (refreshResult.ok) { const retryRes = await fetchWithTimeout( @@ -226,9 +295,10 @@ export async function apiRequest( await throwIfResNotOk(retryRes); return retryRes; } - 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. + if (refreshResult.reason === 'transient') { + // The session may still be valid; the refresh failed on the network, + // rate limit or a 5xx. 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. @@ -257,8 +327,9 @@ export const getQueryFn: (options: { return null; } - // Пытаемся обновить сессию и повторить запрос (только если это не auth endpoint) - if (!url.includes('/api/auth/')) { + // Пытаемся обновить сессию и повторить запрос (для auth-endpoints — + // только /api/auth/me, ровно одна попытка) + if (isRefreshRetryAllowed(url)) { const refreshResult = await refreshSession(); if (refreshResult.ok) { const retryRes = await fetchWithTimeout(url, { @@ -273,8 +344,9 @@ 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. + if (refreshResult.reason === 'transient') { + // Don't force logout when the refresh request itself failed on the + // network, rate limit or a 5xx. throw new Error('network_error_during_refresh'); } // Refresh failed — session is permanently invalid diff --git a/server/routes/auth.core.routes.ts b/server/routes/auth.core.routes.ts index bccb1f4..b8aa66d 100644 --- a/server/routes/auth.core.routes.ts +++ b/server/routes/auth.core.routes.ts @@ -188,7 +188,11 @@ export function registerAuthCoreRoutes(router: Router): void { } }); } else { - clearAuthCookies(res); + // clearCookies === false: неоднозначный отказ (гонка ротации, successor + // не найден) — cookie не трогаем, у клиента может быть живая winner-cookie. + if (result.clearCookies !== false) { + clearAuthCookies(res); + } res.status(401).json({ success: false, error: result.error }); } } catch (error) { diff --git a/server/routes/shared.ts b/server/routes/shared.ts index 82d0860..4cf65f3 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -270,13 +270,15 @@ export const authLimiter = rateLimit({ }); // Отдельный лимит для обновления токенов — приложение вызывает его автоматически, -// поэтому лимит значительно выше чтобы не мешать нормальной работе +// поэтому лимит значительно выше чтобы не мешать нормальной работе. +// Ключ per-user из JWT (как у apiLimiter): офис за NAT не делит общий бакет. export const refreshLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 120, message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' }, standardHeaders: true, legacyHeaders: false, + keyGenerator: buildRateLimitKey, }); // Общий API лимит — 1500 запросов за 15 минут diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index bd1a7e9..dac85e2 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -33,6 +33,9 @@ export interface AuthResponse { remember?: boolean; message?: string; error?: string; + // false = при отказе не стирать auth-cookie (временная/неоднозначная ситуация, + // у клиента может быть валидная cookie successor-сессии). + clearCookies?: boolean; } export const ACCESS_TOKEN_COOKIE = 'access_token'; @@ -334,6 +337,49 @@ export class AuthService { return { tokens, remember }; } + // Обработка повторного использования уже сменённого (rotated) refresh-токена. + // rotated — successor-сессия, найденная по parentRefreshTokenHash. + // В пределах RACE_TOLERANCE_MS это гонка вкладок/PWA: ротируем активную + // сессию family и выдаём новую пару (200). Если активной сессии нет — + // отказ БЕЗ стирания cookie и БЕЗ отзыва family. После окна — настоящий + // reuse-attack: отзыв всей family. + private async handleRotatedToken(rotated: UserSession): Promise { + if (rotated.isRevoked) { + console.warn(`[Auth] Refresh denied: rotated session ${rotated.id} is revoked (family ${rotated.familyId})`); + return { success: false, error: 'Недействительный или истекший токен' }; + } + + const rotatedAt = rotated.createdAt ? new Date(rotated.createdAt).getTime() : 0; + if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) { + // В grace-окне: plain-токены в БД не хранятся (шаг 0.6), вернуть «тот же» + // refresh нельзя — вместо этого активная сессия ротируется и клиент + // получает новую пару. + const activeSession = await storage.getActiveSessionInFamily(rotated.familyId); + if (activeSession) { + const activeUser = await storage.getUserWithOrganization(activeSession.userId); + if (activeUser && activeUser.isActive) { + const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser); + return { + success: true, + user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any, + remember, + tokens, + }; + } + } + // Successor есть, но активной сессии не нашлось — неоднозначная ситуация. + // Cookie не трогаем и family не отзываем: у клиента может быть валидная + // cookie winner-запроса, он повторит рефреш ею. + console.warn(`[Auth] Refresh in grace window but no active session in family ${rotated.familyId}`); + return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; + } + + // Reuse detected (за пределами grace-окна): revoke the whole family. + console.warn(`[Auth] Refresh token reuse detected for family ${rotated.familyId}, rotated session ${rotated.id}`); + await storage.revokeSessionFamily(rotated.familyId); + return { success: false, error: 'Недействительный или истекший токен' }; + } + async refreshToken(refreshToken: string): Promise { return withSuperAdmin(async () => { try { @@ -342,32 +388,13 @@ export class AuthService { // Token not found as current — maybe it was already rotated (race or reuse). if (!session) { const rotatedSession = await storage.getUserSessionByParentToken(refreshToken); - if (rotatedSession && !rotatedSession.isRevoked) { - const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0; - // Within grace period: rotate the current active session of the same family. - // Plain-токены в БД не хранятся (шаг 0.6), поэтому вернуть «тот же» refresh - // нельзя — вместо этого активная сессия ротируется и клиент получает новую пару. - if (Date.now() - rotatedAt < RACE_TOLERANCE_MS) { - const activeSession = await storage.getActiveSessionInFamily(rotatedSession.familyId); - if (activeSession) { - const activeUser = await storage.getUserWithOrganization(activeSession.userId); - if (activeUser && activeUser.isActive) { - const { tokens, remember } = await this.rotateFamilySession(activeSession, activeUser); - return { - success: true, - user: { ...activeUser, appRole: activeUser.appRole, permissions: await storage.getAppRolePermissions(activeUser.appRole) } as any, - remember, - tokens, - }; - } - } - } - // Reuse detected: revoke the whole family. - console.warn(`[Auth] Refresh token reuse detected for family ${rotatedSession.familyId}, rotating session ${rotatedSession.id}`); - await storage.revokeSessionFamily(rotatedSession.familyId); + if (rotatedSession) { + return this.handleRotatedToken(rotatedSession); } + // Токен вообще неизвестен — cookie не стираем (у клиента может оставаться + // живой access-cookie), family-ревока не для чего вызывать. console.warn('[Auth] Refresh denied: token not found as current or rotated'); - return { success: false, error: 'Недействительный или истекший токен' }; + return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; } if (session.isRevoked) { @@ -375,8 +402,16 @@ export class AuthService { return { success: false, error: 'Недействительный или истекший токен' }; } if (session.isReplaced) { - console.warn(`[Auth] Refresh denied: session ${session.id} is already replaced (family ${session.familyId})`); - return { success: false, error: 'Недействительный или истекший токен' }; + // Гонка ротации: этот токен уже заменён, но его hash в БД сохранён — + // ищем successor-сессию и решаем по grace-окну. + const successor = await storage.getUserSessionByParentToken(refreshToken); + if (successor) { + return this.handleRotatedToken(successor); + } + // Successor не найден (например, запись не дочитана репликой/удалена) — + // 401 БЕЗ стирания cookie и БЕЗ revoke family. + console.warn(`[Auth] Refresh denied: session ${session.id} is replaced but no successor found (family ${session.familyId})`); + return { success: false, error: 'Недействительный или истекший токен', clearCookies: false }; } if (session.expiresAt < new Date()) { console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`); diff --git a/tests/token-security.test.ts b/tests/token-security.test.ts index 2dca2d2..24edee6 100644 --- a/tests/token-security.test.ts +++ b/tests/token-security.test.ts @@ -261,6 +261,126 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken); }); + it('grace period: повтор СТАРОГО токена, replaced-сессия найдена по hash → successor ротируется, новая пара, без revoke', async () => { + setupExistingUser(); + const oldPlain = 'old-plain-race'; + // Старая сессия всё ещё находится по refreshTokenHash (markSessionReplaced + // hash не очищает) и помечена isReplaced + const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); + // Successor-сессия, созданная winner-запросом чуть раньше (в пределах окна) + const successor = makeSession('new-plain-race', { id: 11, createdAt: new Date() }); + + mockStorage.getUserSessionByToken.mockResolvedValue(replaced); + mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); + mockStorage.getActiveSessionInFamily.mockResolvedValue(successor); + mockStorage.markSessionReplaced.mockResolvedValue(undefined); + + const result = await authService.refreshToken(oldPlain); + + expect(result.success).toBe(true); + expect(result.tokens?.refreshToken).toBeTruthy(); + // Ротируется successor (активная сессия family), family НЕ отзывается + expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(successor.id, result.tokens!.refreshToken); + expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); + expect(result.clearCookies).toBeUndefined(); + }); + + it('replaced-сессия без successor: 401 БЕЗ стирания cookie и БЕЗ revoke family', async () => { + setupExistingUser(); + const oldPlain = 'old-plain-no-successor'; + const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); + + mockStorage.getUserSessionByToken.mockResolvedValue(replaced); + mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); + + const result = await authService.refreshToken(oldPlain); + + expect(result.success).toBe(false); + expect(result.clearCookies).toBe(false); + expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); + expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); + }); + + it('reuse attack: повтор старого токена ПОСЛЕ grace-окна → revoke family (isReplaced-ветка)', async () => { + setupExistingUser(); + const oldPlain = 'old-plain-after-window'; + const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); + const successor = makeSession('new-plain-after-window', { + id: 11, + createdAt: new Date(Date.now() - 10 * 60 * 1000), // 10 минут назад — окно истекло + }); + + mockStorage.getUserSessionByToken.mockResolvedValue(replaced); + mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); + mockStorage.revokeSessionFamily.mockResolvedValue(undefined); + + const result = await authService.refreshToken(oldPlain); + + expect(result.success).toBe(false); + expect(mockStorage.revokeSessionFamily).toHaveBeenCalledWith('family-1'); + expect(mockStorage.markSessionReplaced).not.toHaveBeenCalled(); + }); + + it('route: повтор в grace-окне → 200 и новая пара токенов + cookie', async () => { + const app = buildApp(); + setupExistingUser(); + const oldPlain = 'old-plain-route'; + const replaced = makeSession(oldPlain, { id: 10, isReplaced: true }); + const successor = makeSession('new-plain-route', { id: 11, createdAt: new Date() }); + + mockStorage.getUserSessionByToken.mockResolvedValue(replaced); + mockStorage.getUserSessionByParentToken.mockResolvedValue(successor); + mockStorage.getActiveSessionInFamily.mockResolvedValue(successor); + mockStorage.markSessionReplaced.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: oldPlain }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.tokens?.refreshToken).toBeTruthy(); + // Сервер выставил новые auth-cookie + const setCookie = res.headers['set-cookie']; + expect(setCookie).toBeTruthy(); + expect(String(setCookie)).toContain('access_token='); + expect(String(setCookie)).toContain('refresh_token='); + expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); + }); + + it('route: отказ без successor не стирает cookie, при revoked-сессии — стирает', async () => { + const app = buildApp(); + setupExistingUser(); + + // Случай 1: replaced без successor → 401 без set-cookie (cookie не трогаем) + mockStorage.getUserSessionByToken.mockResolvedValue( + makeSession('p1', { id: 10, isReplaced: true }), + ); + mockStorage.getUserSessionByParentToken.mockResolvedValue(undefined); + + const res1 = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: 'p1' }); + + expect(res1.status).toBe(401); + expect(res1.headers['set-cookie']).toBeUndefined(); + expect(mockStorage.revokeSessionFamily).not.toHaveBeenCalled(); + + // Случай 2: revoked-сессия → 401 со стиранием cookie + mockStorage.getUserSessionByToken.mockResolvedValue( + makeSession('p2', { id: 12, isRevoked: true }), + ); + + const res2 = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: 'p2' }); + + expect(res2.status).toBe(401); + const cleared = String(res2.headers['set-cookie']); + expect(cleared).toContain('access_token=;'); + expect(cleared).toContain('refresh_token=;'); + }); + it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => { mockStorage.revokeSession.mockResolvedValue(undefined);