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:
@@ -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()}`);
|
||||
|
||||
Reference in New Issue
Block a user