feat(auth): access 24ч, jti против гонок ротации, транзакционная ротация, отзыв сессий по sid
This commit is contained in:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user