Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/sessionStore.server.ts
T

159 lines
6.2 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { cachified, clearCacheKey } from "~/utils/cache.server";
import { db } from "~/utils/db.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 SESSION_CACHE_TTL_MS = 60 * 1000; // kurzer Cache vor dem Postgres-SoT
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastSeenAt hoechstens alle 5min fortschreiben
export type UserSessionRow = Awaited<ReturnType<typeof db.userSession.create>>;
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 }> {
const rawToken = input.rawToken ?? generateSessionToken();
const now = Date.now();
const session = await db.userSession.create({
data: {
userId: input.userId,
tokenHash: hashSessionToken(rawToken),
expiresAt: input.expiresAt ?? new Date(now + DEFAULT_SESSION_TTL_MS),
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 };
}
// Liefert die aktive Session zum Cookie-Token oder null (revoked/abgelaufen/unbekannt). Der
// Cache wird bei Revoke gezielt invalidiert; die Ablauf-Pruefung laeuft zusaetzlich live gegen now.
export async function getActiveSessionByToken(rawToken: string | undefined | null): Promise<UserSessionRow | null> {
if (!rawToken) {
return null;
}
const tokenHash = hashSessionToken(rawToken);
const session = await cachified<UserSessionRow | null>({
key: sessionCacheKey(tokenHash),
ttl: SESSION_CACHE_TTL_MS,
getFreshValue: async () => db.userSession.findUnique({ where: { tokenHash } }),
});
if (!isActive(session)) {
return null;
}
return session;
}
// 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 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> {
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> {
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;
}
// 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 session = await db.userSession.findUnique({ where: { tokenHash }, select: { id: true, revokedAt: true } });
if (!session || 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> {
const result = await db.userSession.updateMany({
where: { id: sessionId, userId, revokedAt: null },
data: { trustedAt: new Date() },
});
return result.count > 0;
}