183 lines
7.0 KiB
TypeScript
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);
|
|
}
|