From 8d11ad3b3339e3d574e3fcc0d8cb9e048c6a5635 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: Tue, 22 Sep 2026 09:01:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BC=D1=83=D1=81=D0=BE=D1=80=D0=B0=20=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D0=B9=20=E2=80=94=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=20isReplaced,=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=20UA/IP,=20=D1=87=D0=B8=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи); - rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии — rotated-строки больше не «Неизвестное устройство»; - новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие. --- server/index.ts | 5 +++ server/services/auth.service.ts | 6 +++- server/storage/users.storage.ts | 5 ++- server/workers/session-cleanup.ts | 55 +++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 server/workers/session-cleanup.ts diff --git a/server/index.ts b/server/index.ts index c1338d0..a85c575 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { startAutomationScheduler } from "./workers/automation-scheduler"; import { startDelayedStatusChangeWorker } from "./workers/delayed-status-change.worker"; import { sendTaskMessage } from "./services/task-message.service"; import { startErrorLogsRetention } from "./workers/error-logs-retention"; +import { startSessionCleanup } from "./workers/session-cleanup"; import { startGpsWorker } from "./gps/worker"; import { storage } from "./storage"; import type { ReminderRecipient } from "@shared/schema"; @@ -1152,6 +1153,10 @@ async function runStartupDataPatches() { startErrorLogsRetention(); log('Error logs retention started'); + // Start session cleanup (daily: replaced-сессии старше 72ч + истёкшие) + startSessionCleanup(); + log('Session cleanup worker started'); + // Start GPS worker (offline detection for tracked assets) startGpsWorker(); log('GPS worker started'); diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index df519df..a296883 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -330,7 +330,9 @@ export class AuthService { appRole: user.appRole }, remember, session.familyId); - // Атомарная ротация: replaced + insert новой сессии в одной транзакции + // Атомарная ротация: replaced + insert новой сессии в одной транзакции. + // userAgent/ipAddress переносим из исходной сессии — иначе rotated-строки + // отображались бы в списке устройств как «Неизвестное устройство». const newSession = await storage.rotateUserSession(session.id, { userId: user.id, refreshTokenHash: hashToken(tokens.refreshToken), @@ -339,6 +341,8 @@ export class AuthService { parentRefreshTokenHash: session.refreshTokenHash, expiresAt, remember, + ipAddress: session.ipAddress, + userAgent: session.userAgent, }); // Access-токен перевыпускаем с sid — для отзыва устройства в моменте diff --git a/server/storage/users.storage.ts b/server/storage/users.storage.ts index b78e263..3d6718d 100644 --- a/server/storage/users.storage.ts +++ b/server/storage/users.storage.ts @@ -353,7 +353,9 @@ export class UsersStorage { }); } - // Активные сессии пользователя (для управления устройствами) + // Активные сессии пользователя (для управления устройствами). + // isReplaced = false: rotated-строки цепочки ротации в список устройств НЕ + // попадают — иначе каждый рефреш токена добавлял в UI «новое устройство». async listUserSessions(userId: number): Promise { return db .select() @@ -361,6 +363,7 @@ export class UsersStorage { .where(and( eq(userSessions.userId, userId), eq(userSessions.isRevoked, false), + eq(userSessions.isReplaced, false), gte(userSessions.expiresAt, new Date()), )) .orderBy(desc(userSessions.createdAt)) diff --git a/server/workers/session-cleanup.ts b/server/workers/session-cleanup.ts new file mode 100644 index 0000000..ab8e09c --- /dev/null +++ b/server/workers/session-cleanup.ts @@ -0,0 +1,55 @@ +import { db, withSuperAdmin } from "../db"; +import { sql } from "drizzle-orm"; +import { logger } from "../utils/logger"; + +/** + * Очистка цепочек ротации и истёкших сессий (таблица user_sessions). + * + * Каждая ротация refresh-токена оставляет replaced-строку — она нужна только + * для детекта кражи (повторное предъявление старого токена → отзыв всей family), + * т.е. имеет смысл лишь в небольшом grace-окне. Раз в сутки удаляем: + * - replaced-строки старше REPLACED_RETENTION_HOURS часов (grace-окно прошло); + * - любые истёкшие сессии (expires_at < now) — они в любом случае мёртвые. + * Без этого таблица и список устройств разрастались на каждом рефреше токена + * (до фильтра isReplaced в listUserSessions UI показывал «новое устройство» + * на каждую ротацию). + * Таймер unref'нут; первый запуск — сразу при старте. + */ + +const log = logger("session-cleanup"); + +const TICK_MS = 24 * 60 * 60 * 1000; // раз в сутки +const REPLACED_RETENTION_HOURS = 72; // grace-окно детекта кражи токена + +let isRunning = false; + +async function cleanup(): Promise { + const result = await withSuperAdmin(() => + db.execute(sql` + DELETE FROM user_sessions + WHERE (is_replaced = true AND created_at < now() - make_interval(hours => ${REPLACED_RETENTION_HOURS})) + OR expires_at < now() + `) + ); + const deleted = (result as { rowCount?: number }).rowCount ?? 0; + if (deleted > 0) { + log.info(`Удалено replaced/истёкших сессий: ${deleted}`); + } else { + log.debug(`Replaced/истёкших сессий для удаления нет`); + } +} + +export function startSessionCleanup(): void { + if (isRunning) return; + isRunning = true; + + const timer = setInterval(() => { + cleanup().catch((err) => log.error("Cleanup error:", err)); + }, TICK_MS); + timer.unref?.(); + + // Первый прогон сразу после старта (в фоне, не блокируя listen) + cleanup().catch((err) => log.error("Initial cleanup error:", err)); + + log.info(`started (replaced старше ${REPLACED_RETENTION_HOURS}ч + истёкшие, тик раз в сутки)`); +}