Files
iistwin/server/workers/session-cleanup.ts
Ильяс Султанов 8d11ad3b33 fix(auth): список сессий без мусора ротаций — фильтр isReplaced, перенос UA/IP, чистка старых
- listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял
  в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи);
- rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии —
  rotated-строки больше не «Неизвестное устройство»;
- новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет
  replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие.
2026-09-22 09:01:59 +03:00

56 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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}ч + истёкшие, тик раз в сутки)`);
}