fix(auth): живая сессия на устройстве — рефреш на холодном старте, grace-окно ротации, без логаута при временных ошибках
- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
выдаёт новую пару от successor вместо deny; cookie больше не стираются при
stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
(transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
|
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
|
||||||
import { authService } from '@/services/auth.service';
|
import { authService } from '@/services/auth.service';
|
||||||
|
import { refreshSession } from '@/lib/queryClient';
|
||||||
import type { User } from '@/types/auth.types';
|
import type { User } from '@/types/auth.types';
|
||||||
import { AUTH_FAILURE_EVENT } from '@/lib/authEvents';
|
import { AUTH_FAILURE_EVENT } from '@/lib/authEvents';
|
||||||
|
|
||||||
@@ -161,15 +162,17 @@ export function useAuthProvider() {
|
|||||||
};
|
};
|
||||||
}, [logout]);
|
}, [logout]);
|
||||||
|
|
||||||
// Keep the session alive in long-lived tabs/PWAs. With a 14-day access token
|
// Keep the session alive in long-lived tabs/PWAs. With a 15-minute access
|
||||||
// this is mostly defensive: it refreshes cookies and prevents the family hard
|
// token this refreshes cookies and prevents the family hard cap from
|
||||||
// cap from expiring while the app is open.
|
// expiring while the app is open. Рефреш идёт через единый refreshSession
|
||||||
|
// (singleton + межвкладочная дедупликация), иначе параллельные рефреши
|
||||||
|
// из разных вкладок/хуков попадают под rotation-гонку.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
const KEEP_ALIVE_MS = 12 * 60 * 60 * 1000; // 12 hours
|
const KEEP_ALIVE_MS = 12 * 60 * 60 * 1000; // 12 hours
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
authService.refreshTokens().catch(() => {});
|
void refreshSession();
|
||||||
}, KEEP_ALIVE_MS);
|
}, KEEP_ALIVE_MS);
|
||||||
|
|
||||||
let lastFocusRefresh = 0;
|
let lastFocusRefresh = 0;
|
||||||
@@ -179,7 +182,7 @@ export function useAuthProvider() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastFocusRefresh < FOCUS_REFRESH_INTERVAL_MS) return;
|
if (now - lastFocusRefresh < FOCUS_REFRESH_INTERVAL_MS) return;
|
||||||
lastFocusRefresh = now;
|
lastFocusRefresh = now;
|
||||||
authService.refreshTokens().catch(() => {});
|
void refreshSession();
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
performInitialSync,
|
performInitialSync,
|
||||||
checkServerReachable,
|
checkServerReachable,
|
||||||
} from '@/lib/syncEngine';
|
} from '@/lib/syncEngine';
|
||||||
import { queryClient } from '@/lib/queryClient';
|
import { queryClient, refreshSession } from '@/lib/queryClient';
|
||||||
import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache';
|
import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache';
|
||||||
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
|
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
|
||||||
import { sseManager } from '@/lib/sseManager';
|
import { sseManager } from '@/lib/sseManager';
|
||||||
@@ -215,14 +215,10 @@ async function tryUploadFile(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if ((res.status === 401 || res.status === 403) && allowRefresh) {
|
if ((res.status === 401 || res.status === 403) && allowRefresh) {
|
||||||
// Try a single session refresh; if it fails the user must re-authenticate.
|
// Try a single session refresh via the shared refreshSession (singleton +
|
||||||
const refreshRes = await fetch('/api/auth/refresh', {
|
// cross-tab dedup); if it fails the user must re-authenticate.
|
||||||
method: 'POST',
|
const refreshResult = await refreshSession();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (refreshResult.ok) {
|
||||||
body: JSON.stringify({}),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (refreshRes.ok) {
|
|
||||||
return tryUploadFile(file, false);
|
return tryUploadFile(file, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,13 +278,8 @@ async function trySendAttachmentMessage(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if ((res.status === 401 || res.status === 403) && allowRefresh) {
|
if ((res.status === 401 || res.status === 403) && allowRefresh) {
|
||||||
const refreshRes = await fetch('/api/auth/refresh', {
|
const refreshResult = await refreshSession();
|
||||||
method: 'POST',
|
if (refreshResult.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (refreshRes.ok) {
|
|
||||||
return trySendAttachmentMessage(item, attachments, false);
|
return trySendAttachmentMessage(item, attachments, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,12 +118,60 @@ let _refreshPromise: Promise<RefreshResult> | null = null;
|
|||||||
|
|
||||||
type RefreshResult =
|
type RefreshResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; reason: 'network' | 'unauthorized' };
|
| { ok: false; reason: 'transient' | 'unauthorized' };
|
||||||
|
|
||||||
async function refreshSession(): Promise<RefreshResult> {
|
// Межвкладочная дедупликация рефреша: вкладка-лидер шлёт '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<RefreshResult> | 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<RefreshResult>((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<RefreshResult> {
|
||||||
if (_refreshPromise) return _refreshPromise;
|
if (_refreshPromise) return _refreshPromise;
|
||||||
|
|
||||||
|
const channel = getRefreshChannel();
|
||||||
|
// Другая вкладка уже рефрешит — ждём её результат.
|
||||||
|
if (_crossTabWaiter) return _crossTabWaiter;
|
||||||
|
|
||||||
_refreshPromise = (async (): Promise<RefreshResult> => {
|
_refreshPromise = (async (): Promise<RefreshResult> => {
|
||||||
|
channel?.postMessage({ type: 'start' });
|
||||||
|
let result: RefreshResult;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout(
|
const res = await fetchWithTimeout(
|
||||||
'/api/auth/refresh',
|
'/api/auth/refresh',
|
||||||
@@ -137,22 +185,34 @@ async function refreshSession(): Promise<RefreshResult> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const result = await res.json();
|
const data = await res.json();
|
||||||
if (result.success && result.user) {
|
if (data.success && data.user) {
|
||||||
authService.setCachedUser(result.user);
|
authService.setCachedUser(data.user);
|
||||||
return { ok: true };
|
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) {
|
} catch (error) {
|
||||||
if (isNetworkFailure(error)) {
|
if (isNetworkFailure(error)) {
|
||||||
console.warn('Token refresh skipped — no network connection or timeout');
|
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(() => {
|
})().finally(() => {
|
||||||
_refreshPromise = null;
|
_refreshPromise = null;
|
||||||
});
|
});
|
||||||
@@ -160,6 +220,15 @@ async function refreshSession(): Promise<RefreshResult> {
|
|||||||
return _refreshPromise;
|
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(
|
export async function apiRequest(
|
||||||
method: string,
|
method: string,
|
||||||
url: 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 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();
|
const refreshResult = await refreshSession();
|
||||||
if (refreshResult.ok) {
|
if (refreshResult.ok) {
|
||||||
const retryRes = await fetchWithTimeout(
|
const retryRes = await fetchWithTimeout(
|
||||||
@@ -226,9 +295,10 @@ export async function apiRequest(
|
|||||||
await throwIfResNotOk(retryRes);
|
await throwIfResNotOk(retryRes);
|
||||||
return retryRes;
|
return retryRes;
|
||||||
}
|
}
|
||||||
if (refreshResult.reason === 'network') {
|
if (refreshResult.reason === 'transient') {
|
||||||
// The session may still be valid; we just couldn't reach the server.
|
// The session may still be valid; the refresh failed on the network,
|
||||||
// Don't force a logout — propagate a generic error so the caller can retry.
|
// 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');
|
throw new Error('network_error_during_refresh');
|
||||||
}
|
}
|
||||||
// Server explicitly rejected the session — permanent logout.
|
// Server explicitly rejected the session — permanent logout.
|
||||||
@@ -257,8 +327,9 @@ export const getQueryFn: <T>(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пытаемся обновить сессию и повторить запрос (только если это не auth endpoint)
|
// Пытаемся обновить сессию и повторить запрос (для auth-endpoints —
|
||||||
if (!url.includes('/api/auth/')) {
|
// только /api/auth/me, ровно одна попытка)
|
||||||
|
if (isRefreshRetryAllowed(url)) {
|
||||||
const refreshResult = await refreshSession();
|
const refreshResult = await refreshSession();
|
||||||
if (refreshResult.ok) {
|
if (refreshResult.ok) {
|
||||||
const retryRes = await fetchWithTimeout(url, {
|
const retryRes = await fetchWithTimeout(url, {
|
||||||
@@ -273,8 +344,9 @@ export const getQueryFn: <T>(options: {
|
|||||||
await throwIfResNotOk(retryRes);
|
await throwIfResNotOk(retryRes);
|
||||||
return await retryRes.json();
|
return await retryRes.json();
|
||||||
}
|
}
|
||||||
if (refreshResult.reason === 'network') {
|
if (refreshResult.reason === 'transient') {
|
||||||
// Don't force logout when the refresh request itself failed on the network.
|
// 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');
|
throw new Error('network_error_during_refresh');
|
||||||
}
|
}
|
||||||
// Refresh failed — session is permanently invalid
|
// Refresh failed — session is permanently invalid
|
||||||
|
|||||||
@@ -188,7 +188,11 @@ export function registerAuthCoreRoutes(router: Router): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
clearAuthCookies(res);
|
// clearCookies === false: неоднозначный отказ (гонка ротации, successor
|
||||||
|
// не найден) — cookie не трогаем, у клиента может быть живая winner-cookie.
|
||||||
|
if (result.clearCookies !== false) {
|
||||||
|
clearAuthCookies(res);
|
||||||
|
}
|
||||||
res.status(401).json({ success: false, error: result.error });
|
res.status(401).json({ success: false, error: result.error });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -270,13 +270,15 @@ export const authLimiter = rateLimit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Отдельный лимит для обновления токенов — приложение вызывает его автоматически,
|
// Отдельный лимит для обновления токенов — приложение вызывает его автоматически,
|
||||||
// поэтому лимит значительно выше чтобы не мешать нормальной работе
|
// поэтому лимит значительно выше чтобы не мешать нормальной работе.
|
||||||
|
// Ключ per-user из JWT (как у apiLimiter): офис за NAT не делит общий бакет.
|
||||||
export const refreshLimiter = rateLimit({
|
export const refreshLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 120,
|
max: 120,
|
||||||
message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' },
|
message: { error: 'Слишком много запросов обновления токена. Попробуйте позже.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
keyGenerator: buildRateLimitKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Общий API лимит — 1500 запросов за 15 минут
|
// Общий API лимит — 1500 запросов за 15 минут
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface AuthResponse {
|
|||||||
remember?: boolean;
|
remember?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
// false = при отказе не стирать auth-cookie (временная/неоднозначная ситуация,
|
||||||
|
// у клиента может быть валидная cookie successor-сессии).
|
||||||
|
clearCookies?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||||
@@ -334,6 +337,49 @@ export class AuthService {
|
|||||||
return { tokens, remember };
|
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<AuthResponse> {
|
||||||
|
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<AuthResponse> {
|
async refreshToken(refreshToken: string): Promise<AuthResponse> {
|
||||||
return withSuperAdmin(async () => {
|
return withSuperAdmin(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -342,32 +388,13 @@ export class AuthService {
|
|||||||
// Token not found as current — maybe it was already rotated (race or reuse).
|
// Token not found as current — maybe it was already rotated (race or reuse).
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
|
const rotatedSession = await storage.getUserSessionByParentToken(refreshToken);
|
||||||
if (rotatedSession && !rotatedSession.isRevoked) {
|
if (rotatedSession) {
|
||||||
const rotatedAt = rotatedSession.createdAt ? new Date(rotatedSession.createdAt).getTime() : 0;
|
return this.handleRotatedToken(rotatedSession);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
// Токен вообще неизвестен — cookie не стираем (у клиента может оставаться
|
||||||
|
// живой access-cookie), family-ревока не для чего вызывать.
|
||||||
console.warn('[Auth] Refresh denied: token not found as current or rotated');
|
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) {
|
if (session.isRevoked) {
|
||||||
@@ -375,8 +402,16 @@ export class AuthService {
|
|||||||
return { success: false, error: 'Недействительный или истекший токен' };
|
return { success: false, error: 'Недействительный или истекший токен' };
|
||||||
}
|
}
|
||||||
if (session.isReplaced) {
|
if (session.isReplaced) {
|
||||||
console.warn(`[Auth] Refresh denied: session ${session.id} is already replaced (family ${session.familyId})`);
|
// Гонка ротации: этот токен уже заменён, но его hash в БД сохранён —
|
||||||
return { success: false, error: 'Недействительный или истекший токен' };
|
// ищем 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()) {
|
if (session.expiresAt < new Date()) {
|
||||||
console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`);
|
console.warn(`[Auth] Refresh denied: session ${session.id} expired at ${session.expiresAt.toISOString()}`);
|
||||||
|
|||||||
@@ -261,6 +261,126 @@ describe('refresh-токены: выпуск по хэшу (plain не сохр
|
|||||||
expect(mockStorage.markSessionReplaced).toHaveBeenCalledWith(activeSession.id, result.tokens!.refreshToken);
|
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 () => {
|
it('отзыв сессии при logout: revokeSession вызывается с plain-токеном (хэширует storage)', async () => {
|
||||||
mockStorage.revokeSession.mockResolvedValue(undefined);
|
mockStorage.revokeSession.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user