262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
import { clearCacheKey } from "~/utils/cache.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
import { resolveUserSessionRlsIdentity } from "@hivegrid/db/rls-resolvers.server";
|
|
|
|
// R-P2a S4 — serverseitige Sessions. Postgres ist Source-of-Truth (Tabelle UserSession),
|
|
// davor ein kurzlebiger Cache (LRU heute, Redis-swappable per cache.server, AD-4). Das
|
|
// Login-Cookie traegt einen opaken Zufalls-Token; persistiert wird nur dessen SHA-256-Hash.
|
|
|
|
const SESSION_TOKEN_BYTES = 32;
|
|
const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage, deckt das Cookie-Max-Age
|
|
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastSeenAt hoechstens alle 5min fortschreiben
|
|
|
|
export type UserSessionRow = Awaited<ReturnType<typeof db.userSession.create>>;
|
|
export type ActiveUserSessionOwner = {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
avatar: string | null;
|
|
phone: string | null;
|
|
defaultTenantId: string | null;
|
|
createdAt: Date;
|
|
locale: string | null;
|
|
lightOrDarkMode: string | null;
|
|
active: boolean;
|
|
pendingDeletion: boolean;
|
|
pseudonymized: boolean;
|
|
};
|
|
export type ActiveUserSessionRow = UserSessionRow & { user: ActiveUserSessionOwner };
|
|
|
|
type Identified = { userId: string; sessionId: string };
|
|
|
|
export function generateSessionToken(): string {
|
|
return randomBytes(SESSION_TOKEN_BYTES).toString("base64url");
|
|
}
|
|
|
|
export function hashSessionToken(rawToken: string): string {
|
|
return createHash("sha256").update(rawToken).digest("hex");
|
|
}
|
|
|
|
function sessionCacheKey(tokenHash: string): string {
|
|
return `user-session:${tokenHash}`;
|
|
}
|
|
|
|
function isActive(session: Pick<UserSessionRow, "revokedAt" | "expiresAt"> | null, now = Date.now()): boolean {
|
|
return Boolean(session && session.revokedAt === null && session.expiresAt.getTime() > now);
|
|
}
|
|
|
|
export type CreateSessionInput = {
|
|
userId: string;
|
|
// Wenn nicht gesetzt, wird ein Token generiert. Der rohe Token gehoert ins Cookie.
|
|
rawToken?: string;
|
|
expiresAt?: Date;
|
|
// AD-22: gehashte IP (hashIp), NICHT Klartext.
|
|
ipHash?: string | null;
|
|
userAgent?: string | null;
|
|
deviceLabel?: string | null;
|
|
approxLocation?: string | null;
|
|
browserFingerprint?: string | null;
|
|
trusted?: boolean;
|
|
};
|
|
|
|
export async function createUserSession(input: CreateSessionInput): Promise<{ session: UserSessionRow; rawToken: string }> {
|
|
return withUserRlsContext(input.userId, () => createUserSessionScoped(input));
|
|
}
|
|
|
|
async function createUserSessionScoped(input: CreateSessionInput): Promise<{ session: UserSessionRow; rawToken: string }> {
|
|
const rawToken = input.rawToken ?? generateSessionToken();
|
|
const now = Date.now();
|
|
const expiresAt = input.expiresAt ?? new Date(now + DEFAULT_SESSION_TTL_MS);
|
|
const session = await db.userSession.create({
|
|
data: {
|
|
userId: input.userId,
|
|
tokenHash: hashSessionToken(rawToken),
|
|
expiresAt,
|
|
ipHash: input.ipHash ?? null,
|
|
userAgent: input.userAgent ?? null,
|
|
deviceLabel: input.deviceLabel ?? null,
|
|
approxLocation: input.approxLocation ?? null,
|
|
browserFingerprint: input.browserFingerprint ?? null,
|
|
trustedAt: input.trusted ? new Date(now) : null,
|
|
},
|
|
});
|
|
return { session, rawToken };
|
|
}
|
|
|
|
// Authorization reads always hit the source of truth. Positive cross-request
|
|
// caching would leave revoked sessions usable until the cache TTL elapsed.
|
|
export async function getActiveSessionByToken(rawToken: string | undefined | null): Promise<ActiveUserSessionRow | null> {
|
|
if (!rawToken) {
|
|
return null;
|
|
}
|
|
const tokenHash = hashSessionToken(rawToken);
|
|
const identity = await resolveUserSessionRlsIdentity(tokenHash);
|
|
if (!identity) {
|
|
return null;
|
|
}
|
|
return runWithRlsContext({ userId: identity.userId }, () => getActiveSessionByTokenScoped(tokenHash, identity.resourceId));
|
|
}
|
|
|
|
async function getActiveSessionByTokenScoped(tokenHash: string, expectedSessionId: string): Promise<ActiveUserSessionRow | null> {
|
|
const session = await db.userSession.findUnique({
|
|
where: { tokenHash },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
avatar: true,
|
|
phone: true,
|
|
defaultTenantId: true,
|
|
createdAt: true,
|
|
locale: true,
|
|
lightOrDarkMode: true,
|
|
active: true,
|
|
pendingDeletion: true,
|
|
pseudonymized: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!session || session.id !== expectedSessionId || !isActive(session)) {
|
|
return null;
|
|
}
|
|
// Existing sessions must stop authorizing immediately when the account is
|
|
// disabled, enters deletion, or has already been pseudonymized.
|
|
if (!session.user.active || session.user.pendingDeletion || session.user.pseudonymized) {
|
|
return null;
|
|
}
|
|
return session as ActiveUserSessionRow;
|
|
}
|
|
|
|
// Schreibt lastSeenAt fort, aber gedrosselt (hoechstens alle TOUCH_THROTTLE_MS), um den
|
|
// Hot-Path nicht mit Writes zu belasten.
|
|
export async function touchSession(session: Pick<UserSessionRow, "id" | "lastSeenAt" | "tokenHash">): Promise<void> {
|
|
const now = Date.now();
|
|
if (now - session.lastSeenAt.getTime() < TOUCH_THROTTLE_MS) {
|
|
return;
|
|
}
|
|
await db.userSession.update({ where: { id: session.id }, data: { lastSeenAt: new Date(now) } });
|
|
clearCacheKey(sessionCacheKey(session.tokenHash));
|
|
}
|
|
|
|
export async function listActiveUserSessions(userId: string): Promise<UserSessionRow[]> {
|
|
return withUserRlsContext(userId, () =>
|
|
db.userSession.findMany({
|
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
orderBy: { lastSeenAt: "desc" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Revoke einzeln (Geraete-Liste). Verifiziert die Eigentuemerschaft ueber userId, damit ein
|
|
// Nutzer nur eigene Sessions beenden kann, und invalidiert den Cache.
|
|
export async function revokeUserSession({ userId, sessionId }: Identified): Promise<boolean> {
|
|
return withUserRlsContext(userId, () => revokeUserSessionScoped({ userId, sessionId }));
|
|
}
|
|
|
|
async function revokeUserSessionScoped({ userId, sessionId }: Identified): Promise<boolean> {
|
|
const session = await db.userSession.findFirst({ where: { id: sessionId, userId }, select: { id: true, tokenHash: true, revokedAt: true } });
|
|
if (!session || session.revokedAt !== null) {
|
|
return false;
|
|
}
|
|
await db.userSession.update({ where: { id: session.id }, data: { revokedAt: new Date() } });
|
|
clearCacheKey(sessionCacheKey(session.tokenHash));
|
|
return true;
|
|
}
|
|
|
|
// "Ueberall abmelden": alle aktiven Sessions des Nutzers ausser der aktuellen beenden.
|
|
export async function revokeAllUserSessionsExcept({ userId, sessionId }: Identified): Promise<number> {
|
|
return withUserRlsContext(userId, () => revokeAllUserSessionsExceptScoped({ userId, sessionId }));
|
|
}
|
|
|
|
async function revokeAllUserSessionsExceptScoped({ userId, sessionId }: Identified): Promise<number> {
|
|
const sessions = await db.userSession.findMany({
|
|
where: { userId, revokedAt: null, id: { not: sessionId } },
|
|
select: { id: true, tokenHash: true },
|
|
});
|
|
if (sessions.length === 0) {
|
|
return 0;
|
|
}
|
|
const now = new Date();
|
|
await db.userSession.updateMany({
|
|
where: { userId, revokedAt: null, id: { not: sessionId } },
|
|
data: { revokedAt: now },
|
|
});
|
|
for (const session of sessions) {
|
|
clearCacheKey(sessionCacheKey(session.tokenHash));
|
|
}
|
|
return sessions.length;
|
|
}
|
|
|
|
// Used when an external identity loses an allowed group. Revoking every
|
|
// server-side row makes the decision visible to all web replicas immediately.
|
|
export async function revokeAllUserSessionsForUser(userId: string): Promise<number> {
|
|
return withUserRlsContext(userId, () => revokeAllUserSessionsForUserScoped(userId));
|
|
}
|
|
|
|
async function revokeAllUserSessionsForUserScoped(userId: string): Promise<number> {
|
|
const sessions = await db.userSession.findMany({
|
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
select: { tokenHash: true },
|
|
});
|
|
if (sessions.length === 0) {
|
|
return 0;
|
|
}
|
|
const result = await db.userSession.updateMany({
|
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
for (const session of sessions) {
|
|
clearCacheKey(sessionCacheKey(session.tokenHash));
|
|
}
|
|
return result.count;
|
|
}
|
|
|
|
// Revoke ueber den Cookie-Token (Logout): beendet genau die aktuelle Session.
|
|
export async function revokeSessionByToken(rawToken: string | undefined | null): Promise<boolean> {
|
|
if (!rawToken) {
|
|
return false;
|
|
}
|
|
const tokenHash = hashSessionToken(rawToken);
|
|
const identity = await resolveUserSessionRlsIdentity(tokenHash);
|
|
if (!identity) {
|
|
return false;
|
|
}
|
|
return runWithRlsContext({ userId: identity.userId }, () => revokeSessionByTokenScoped(tokenHash, identity.resourceId));
|
|
}
|
|
|
|
async function revokeSessionByTokenScoped(tokenHash: string, expectedSessionId: string): Promise<boolean> {
|
|
const session = await db.userSession.findUnique({ where: { tokenHash }, select: { id: true, revokedAt: true } });
|
|
if (!session || session.id !== expectedSessionId || session.revokedAt !== null) {
|
|
return false;
|
|
}
|
|
await db.userSession.update({ where: { id: session.id }, data: { revokedAt: new Date() } });
|
|
clearCacheKey(sessionCacheKey(tokenHash));
|
|
return true;
|
|
}
|
|
|
|
// Trust-Device (S5/2FA): markiert eine eigene Session als vertrauenswuerdig.
|
|
export async function markSessionTrusted({ userId, sessionId }: Identified): Promise<boolean> {
|
|
return withUserRlsContext(userId, async () => {
|
|
const result = await db.userSession.updateMany({
|
|
where: { id: sessionId, userId, revokedAt: null },
|
|
data: { trustedAt: new Date() },
|
|
});
|
|
return result.count > 0;
|
|
});
|
|
}
|
|
|
|
function withUserRlsContext<T>(userId: string, operation: () => T): T {
|
|
if (getRlsExecutionContext()?.userId) {
|
|
return operation();
|
|
}
|
|
return runWithRlsContext({ userId }, operation);
|
|
}
|