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