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

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);
}