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:
2026-09-17 21:59:51 +03:00
parent c1d5b1cbee
commit d0112657b8
7 changed files with 295 additions and 68 deletions

View File

@@ -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<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> {
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()}`);