import { createHash, randomBytes } from "node:crypto"; import { createCookie } from "react-router"; import { loadRequiredWebConfig } from "@toolctrl/config"; import { db } from "~/utils/db.server"; import { buildSessionDeviceContext } from "~/services/auth/sessionLifecycle.server"; // D1 (Trust-Device) — "diesem Geraet vertrauen" beim 2FA-Login. Der Marker UEBERLEBT den Logout // (deshalb NICHT die per-Session `UserSession.trustedAt`), ist aber WIDERRUFBAR: ein eigenes, langlebiges // Cookie (`RJ_trusted_device`) traegt einen opaken Zufalls-Token, persistiert wird nur dessen SHA-256-Hash // in der Tabelle `TrustedDevice`. Autoritaet ist die Tabelle (Revoke einzeln / "ueberall vergessen" ueber // Settings > Sicherheit) -> erfuellt den DoD-Screen "Trust-Device-Revoke" (R-P2a). Das Cookie ist nur der // Traeger; ohne passende, aktive Zeile gilt das Geraet als NICHT vertrauenswuerdig. export const TRUSTED_DEVICE_COOKIE_NAME = "RJ_trusted_device"; export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage const TRUSTED_DEVICE_TOKEN_BYTES = 32; const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastUsedAt hoechstens alle 5min fortschreiben export type TrustedDeviceRow = Awaited>; let cachedCookie: ReturnType | null = null; function getTrustedDeviceCookie() { if (cachedCookie) { return cachedCookie; } const webConfig = loadRequiredWebConfig(); cachedCookie = createCookie(TRUSTED_DEVICE_COOKIE_NAME, { httpOnly: true, sameSite: "lax", path: "/", secure: webConfig.nodeEnv === "production", maxAge: TRUSTED_DEVICE_MAX_AGE_SECONDS, secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [], }); return cachedCookie; } function generateTrustedDeviceToken(): string { return randomBytes(TRUSTED_DEVICE_TOKEN_BYTES).toString("base64url"); } function hashTrustedDeviceToken(rawToken: string): string { return createHash("sha256").update(rawToken).digest("hex"); } function isActive(row: Pick | null, now = Date.now()): boolean { return Boolean(row && row.revokedAt === null && row.expiresAt.getTime() > now); } async function readRawTokenFromCookie(request: Request): Promise { try { const parsed = await getTrustedDeviceCookie().parse(request.headers.get("Cookie")); return typeof parsed === "string" && parsed.length > 0 ? parsed : null; } catch { return null; } } /** * Markiert das aktuelle Geraet fuer `userId` als vertrauenswuerdig: legt die Tabellen-Zeile mit * (pseudonymisiertem) Geraete-Kontext an und gibt den Set-Cookie-String mit dem rohen Token zurueck. */ export async function rememberDeviceForUser({ request, userId, now = new Date() }: { request: Request; userId: string; now?: Date }): Promise { const rawToken = generateTrustedDeviceToken(); const context = await buildSessionDeviceContext(request); await db.trustedDevice.create({ data: { userId, tokenHash: hashTrustedDeviceToken(rawToken), expiresAt: new Date(now.getTime() + TRUSTED_DEVICE_MAX_AGE_SECONDS * 1000), ipHash: context.ipHash, userAgent: context.userAgent, approxLocation: context.approxLocation, }, }); return getTrustedDeviceCookie().serialize(rawToken); } /** * Prueft, ob der Request ein vertrauenswuerdiges Geraet fuer `userId` traegt: Cookie-Token -> Hash -> * aktive Tabellen-Zeile mit passender userId. Best-effort: jeder Fehler gilt als "nicht vertraut". * Schreibt lastUsedAt gedrosselt fort. */ export async function isTrustedDevice(request: Request, userId: string, now: Date = new Date()): Promise { try { const rawToken = await readRawTokenFromCookie(request); if (!rawToken) { return false; } const row = await db.trustedDevice.findUnique({ where: { tokenHash: hashTrustedDeviceToken(rawToken) } }); if (!row || row.userId !== userId || !isActive(row, now.getTime())) { return false; } if (now.getTime() - row.lastUsedAt.getTime() >= TOUCH_THROTTLE_MS) { await db.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: now } }).catch(() => undefined); } return true; } catch { return false; } } /** Aktive vertrauenswuerdige Geraete eines Nutzers (Geraete-Liste in Settings > Sicherheit). */ export async function listTrustedDevices(userId: string): Promise { return db.trustedDevice.findMany({ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } }, orderBy: { lastUsedAt: "desc" }, }); } /** Widerruft genau ein eigenes vertrauenswuerdiges Geraet (Eigentuemerschaft ueber userId gesichert). */ export async function revokeTrustedDevice({ userId, deviceId }: { userId: string; deviceId: string }): Promise { const result = await db.trustedDevice.updateMany({ where: { id: deviceId, userId, revokedAt: null }, data: { revokedAt: new Date() }, }); return result.count > 0; } /** "Alle Geraete vergessen": widerruft alle aktiven Trust-Marker des Nutzers. */ export async function revokeAllTrustedDevices(userId: string): Promise { const result = await db.trustedDevice.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }); return result.count; } /** * Vergisst das aktuelle Geraet: widerruft die zum Cookie gehoerende Zeile (falls vorhanden) und gibt * den Set-Cookie-String zurueck, der den Trust-Marker im Browser loescht. */ export async function forgetCurrentTrustedDevice(request: Request): Promise { const rawToken = await readRawTokenFromCookie(request); if (rawToken) { await db.trustedDevice.updateMany({ where: { tokenHash: hashTrustedDeviceToken(rawToken), revokedAt: null }, data: { revokedAt: new Date() } }).catch(() => undefined); } return clearTrustedDeviceCookie(); } /** Set-Cookie-String, der den Trust-Marker im Browser loescht (ohne DB-Zugriff). */ export async function clearTrustedDeviceCookie(): Promise { return getTrustedDeviceCookie().serialize("", { maxAge: 0, expires: new Date(0) }); } /** Test-Naht: erzwingt eine Neuauflage des Cookies (z.B. nach geaenderter Config im Test). */ export function __resetTrustedDeviceCookieForTests() { cachedCookie = null; }