fix(auth): список сессий без мусора ротаций — фильтр isReplaced, перенос UA/IP, чистка старых
- listUserSessions: + isReplaced = false — иначе каждый рефреш токена добавлял в список устройств «новую сессию» (цепочка ротации видна только для детекта кражи); - rotateFamilySession: userAgent/ipAddress переносятся из исходной сессии — rotated-строки больше не «Неизвестное устройство»; - новый worker session-cleanup (тик раз в сутки + прогон при старте): удаляет replaced-сессии старше 72ч (grace-окно детекта кражи) и истёкшие.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 — для отзыва устройства в моменте
|
||||
|
||||
@@ -353,7 +353,9 @@ export class UsersStorage {
|
||||
});
|
||||
}
|
||||
|
||||
// Активные сессии пользователя (для управления устройствами)
|
||||
// Активные сессии пользователя (для управления устройствами).
|
||||
// isReplaced = false: rotated-строки цепочки ротации в список устройств НЕ
|
||||
// попадают — иначе каждый рефреш токена добавлял в UI «новое устройство».
|
||||
async listUserSessions(userId: number): Promise<UserSession[]> {
|
||||
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))
|
||||
|
||||
55
server/workers/session-cleanup.ts
Normal file
55
server/workers/session-cleanup.ts
Normal file
@@ -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<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}ч + истёкшие, тик раз в сутки)`);
|
||||
}
|
||||
Reference in New Issue
Block a user