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

183 lines
7.0 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { createCookie } from "react-router";
import { loadRequiredWebConfig, resolveHostOnlyCookiePolicy } from "@hivegrid/config";
import { db } from "~/utils/db.server";
import { buildSessionDeviceContext } from "~/services/auth/sessionLifecycle.server";
import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { resolveTrustedDeviceRlsIdentity } from "@hivegrid/db/rls-resolvers.server";
// The trust-device marker survives logout, stays revocable via TrustedDevice,
// and stores only the SHA-256 hash of the opaque cookie token in the database.
export const TRUSTED_DEVICE_COOKIE_NAME = "__Host-hivegrid_trusted_device";
export const LOCAL_TRUSTED_DEVICE_COOKIE_NAME = "hivegrid_trusted_device";
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
const TRUSTED_DEVICE_TOKEN_BYTES = 32;
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // throttle lastUsedAt updates to every 5 minutes
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();
const policy = resolveHostOnlyCookiePolicy({
nodeEnv: webConfig.nodeEnv,
routing: webConfig.routing,
secureName: TRUSTED_DEVICE_COOKIE_NAME,
localName: LOCAL_TRUSTED_DEVICE_COOKIE_NAME,
maxAge: TRUSTED_DEVICE_MAX_AGE_SECONDS,
});
cachedCookie = createCookie(policy.name, {
httpOnly: policy.httpOnly,
sameSite: policy.sameSite,
path: policy.path,
secure: policy.secure,
maxAge: policy.maxAge,
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;
}
}
/**
* Marks the current device as trusted for `userId`, persists only the
* pseudonymized device context, and returns the raw token in a Set-Cookie value.
*/
export async function rememberDeviceForUser({ request, userId, now = new Date() }: { request: Request; userId: string; now?: Date }): Promise<string> {
return withUserRlsContext(userId, () => rememberDeviceForUserScoped({ request, userId, now }));
}
async function rememberDeviceForUserScoped({ request, userId, now }: { 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);
}
/**
* Resolves the cookie token to an active row owned by `userId`. This is
* best-effort and fail-closed; `lastUsedAt` writes are throttled.
*/
export async function isTrustedDevice(request: Request, userId: string, now: Date = new Date()): Promise<boolean> {
return withUserRlsContext(userId, () => isTrustedDeviceScoped(request, userId, now));
}
async function isTrustedDeviceScoped(request: Request, userId: string, now: 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;
}
}
/** Lists active trusted devices for the security settings screen. */
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
return withUserRlsContext(userId, () =>
db.trustedDevice.findMany({
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
orderBy: { lastUsedAt: "desc" },
}),
);
}
/** Revokes one trusted device after enforcing user ownership in the query. */
export async function revokeTrustedDevice({ userId, deviceId }: { userId: string; deviceId: string }): Promise<boolean> {
return withUserRlsContext(userId, async () => {
const result = await db.trustedDevice.updateMany({
where: { id: deviceId, userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count > 0;
});
}
/** Revokes every active trusted-device marker owned by the user. */
export async function revokeAllTrustedDevices(userId: string): Promise<number> {
return withUserRlsContext(userId, async () => {
const result = await db.trustedDevice.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
});
}
/** Revokes the current marker, if present, and clears its browser cookie. */
export async function forgetCurrentTrustedDevice(request: Request): Promise<string> {
const rawToken = await readRawTokenFromCookie(request);
if (rawToken) {
const tokenHash = hashTrustedDeviceToken(rawToken);
const identity = await resolveTrustedDeviceRlsIdentity(tokenHash).catch(() => null);
if (identity) {
await runWithRlsContext({ userId: identity.userId }, () =>
db.trustedDevice.updateMany({
where: { id: identity.resourceId, tokenHash, userId: identity.userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
).catch(() => undefined);
}
}
return clearTrustedDeviceCookie();
}
/** Returns a Set-Cookie value that clears the marker without a database read. */
export async function clearTrustedDeviceCookie(): Promise<string> {
return getTrustedDeviceCookie().serialize("", { maxAge: 0, expires: new Date(0) });
}
/** Test seam for rebuilding the cookie after configuration changes. */
export function __resetTrustedDeviceCookieForTests() {
cachedCookie = null;
}
function withUserRlsContext<T>(userId: string, operation: () => T): T {
if (getRlsExecutionContext()?.userId) {
return operation();
}
return runWithRlsContext({ userId }, operation);
}