Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/trusted-device.server.ts
T

151 lines
6.2 KiB
TypeScript

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<ReturnType<typeof db.trustedDevice.create>>;
let cachedCookie: ReturnType<typeof createCookie> | 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<TrustedDeviceRow, "revokedAt" | "expiresAt"> | null, now = Date.now()): boolean {
return Boolean(row && row.revokedAt === null && row.expiresAt.getTime() > now);
}
async function readRawTokenFromCookie(request: Request): Promise<string | null> {
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<string> {
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<boolean> {
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<TrustedDeviceRow[]> {
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<boolean> {
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<number> {
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<string> {
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<string> {
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;
}