feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid

This commit is contained in:
2026-09-21 18:13:46 +03:00
parent 2f9d049718
commit c957a70260
8 changed files with 221 additions and 31 deletions

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
import crypto from "crypto";
import { hashToken } from "../utils/jwt";
import { invalidateSessionRevocationCache } from "../utils/sessionRevocation";
import { userStatusesStorage } from "./user-statuses.storage";
import { invalidateAccessibleTasksForUser } from "../utils/cache";
@@ -339,11 +340,52 @@ export class UsersStorage {
.where(eq(userSessions.id, sessionId));
}
// Атомарная ротация: старая сессия заменяется + создаётся новая в одной транзакции.
// Без этого при сбое вставки оставалась replaced-сессия без successor («no successor found»).
async rotateUserSession(sessionId: number, newSession: InsertUserSession): Promise<UserSession> {
return db.transaction(async (tx) => {
await tx
.update(userSessions)
.set({ isReplaced: true, replacedByTokenHash: newSession.refreshTokenHash, replacedByToken: null })
.where(eq(userSessions.id, sessionId));
const [row] = await tx.insert(userSessions).values(newSession).returning();
return row;
});
}
// Активные сессии пользователя (для управления устройствами)
async listUserSessions(userId: number): Promise<UserSession[]> {
return db
.select()
.from(userSessions)
.where(and(
eq(userSessions.userId, userId),
eq(userSessions.isRevoked, false),
gte(userSessions.expiresAt, new Date()),
))
.orderBy(desc(userSessions.createdAt))
.limit(50);
}
async getUserSessionById(id: number): Promise<UserSession | undefined> {
const [session] = await db.select().from(userSessions).where(eq(userSessions.id, id));
return session || undefined;
}
async revokeSessionById(id: number): Promise<void> {
await db
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.id, id));
invalidateSessionRevocationCache(id);
}
async revokeSession(refreshToken: string): Promise<void> {
await db
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.refreshTokenHash, hashToken(refreshToken)));
invalidateSessionRevocationCache();
}
async revokeSessionFamily(familyId: string): Promise<void> {
@@ -351,6 +393,7 @@ export class UsersStorage {
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.familyId, familyId));
invalidateSessionRevocationCache();
}
async revokeAllUserSessions(userId: number): Promise<void> {
@@ -358,6 +401,7 @@ export class UsersStorage {
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.userId, userId));
invalidateSessionRevocationCache();
}
/** Physical deletion kept for cleanup operations. */