159 lines
6.2 KiB
TypeScript
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;
|
|
}
|