- listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи); - rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии — rotated-строки больше не «Неизвестное устройство»; - новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие.
56 lines
2.5 KiB
TypeScript
56 lines
2.5 KiB
TypeScript
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}ч + истёкшие, тик раз в сутки)`);
|
||
}
|