Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,425 @@
|
||||
import { createCookieSessionStorage, redirect, Session } from "react-router";
|
||||
import { loadRequiredWebConfig, resolveSessionCookiePolicy } from "@toolctrl/config";
|
||||
import { getMyTenants, getTenant } from "./db/tenants.db.server";
|
||||
import { randomBytes, timingSafeEqual } from "crypto";
|
||||
import { getUser } from "./db/users.db.server";
|
||||
import { getActiveSessionByToken } from "~/services/auth/sessionStore.server";
|
||||
import { endServerSession } from "~/services/auth/sessionLifecycle.server";
|
||||
import { createScopedLogger } from "./logger.server";
|
||||
|
||||
const sessionLogger = createScopedLogger("session");
|
||||
|
||||
export const IMPERSONATION_SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export type ImpersonationSessionValidationReason =
|
||||
| "not_impersonating"
|
||||
| "missing_actor"
|
||||
| "missing_started_at"
|
||||
| "malformed_started_at"
|
||||
| "future_started_at"
|
||||
| "stale_started_at"
|
||||
| "wrong_actor"
|
||||
| "valid";
|
||||
|
||||
export type ImpersonationSessionValidation = {
|
||||
valid: boolean;
|
||||
reason: ImpersonationSessionValidationReason;
|
||||
actorUserId?: string;
|
||||
startedAt?: string;
|
||||
};
|
||||
|
||||
export type UserSession = {
|
||||
userId: string;
|
||||
lightOrDarkMode: string;
|
||||
lng: string;
|
||||
csrf?: string;
|
||||
cookies: { category: string; allowed: boolean }[];
|
||||
metrics?: {
|
||||
enabled: boolean;
|
||||
logToConsole: boolean;
|
||||
saveToDatabase: boolean;
|
||||
ignoreUrls: string[];
|
||||
};
|
||||
impersonatingFromUserId?: string;
|
||||
impersonatingStartedAt?: string;
|
||||
theme?: string;
|
||||
/**
|
||||
* Phase 01 / #27 — set at login time when the password the user just
|
||||
* submitted no longer satisfies the current password policy. Drives the
|
||||
* dashboard soft-banner. Never gates the login itself.
|
||||
*/
|
||||
passwordPolicyOutdated?: boolean;
|
||||
/**
|
||||
* R-P2a S4b — opaker Token der serverseitigen Session (UserSession-Zeile). Additiv: das Cookie
|
||||
* bleibt primaerer Auth-Mechanismus; ist der Token gesetzt, honoriert requireUserId Revoke/
|
||||
* "ueberall abmelden". Alte Cookies ohne Token bleiben unberuehrt.
|
||||
*/
|
||||
sessionToken?: string;
|
||||
/**
|
||||
* D1 (2FA-Login-Gate) — Zwischenzustand zwischen Passwort-OK und TOTP-Verifikation. Traegt das
|
||||
* Cookie `pendingMfaUserId` (NICHT `userId`!), hat der Nutzer das Passwort bestanden, aber noch
|
||||
* keinen zweiten Faktor geliefert. `requireUserId` liest weiterhin nur `userId` und gewaehrt
|
||||
* deshalb KEINEN Zugriff, solange nur der Pending-Zustand gesetzt ist. `pendingMfaRedirectTo`
|
||||
* haelt das rohe redirectTo, damit die Verify-Route nach Erfolg ans Ziel weiterleiten kann.
|
||||
*/
|
||||
pendingMfaUserId?: string;
|
||||
pendingMfaRedirectTo?: string;
|
||||
};
|
||||
|
||||
export const PENDING_MFA_USER_ID_KEY = "pendingMfaUserId";
|
||||
export const PENDING_MFA_REDIRECT_KEY = "pendingMfaRedirectTo";
|
||||
|
||||
export type SetLoggedUserResult = {
|
||||
userId: string;
|
||||
defaultTenantId: string;
|
||||
/**
|
||||
* The tenant resolved during setLoggedUser, if any. Returned so callers can
|
||||
* avoid a second `getTenant(userSession.defaultTenantId)` round-trip
|
||||
* immediately after sign-in / impersonation.
|
||||
*/
|
||||
defaultTenant: Awaited<ReturnType<typeof getTenant>> | null;
|
||||
};
|
||||
|
||||
export async function setLoggedUser(user: { id: string; email: string; defaultTenantId: string | null }): Promise<SetLoggedUserResult> {
|
||||
if (user.defaultTenantId) {
|
||||
const tenant = await getTenant(user.defaultTenantId);
|
||||
if (tenant) {
|
||||
return {
|
||||
userId: user.id,
|
||||
defaultTenantId: tenant.id,
|
||||
defaultTenant: tenant,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const userTenants = await getMyTenants(user.id);
|
||||
const fallback = userTenants[0] ?? null;
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
defaultTenantId: fallback?.id ?? "",
|
||||
defaultTenant: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createImpersonationStartedAt(now: Date = new Date()) {
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
export function validateImpersonationSessionState(
|
||||
state: { impersonatingFromUserId?: unknown; impersonatingStartedAt?: unknown; userId?: unknown },
|
||||
options: { now?: Date; expectedActorUserId?: string } = {}
|
||||
): ImpersonationSessionValidation {
|
||||
const actorUserId = typeof state.impersonatingFromUserId === "string" ? state.impersonatingFromUserId : undefined;
|
||||
const startedAt = typeof state.impersonatingStartedAt === "string" ? state.impersonatingStartedAt : undefined;
|
||||
|
||||
if (!actorUserId && !startedAt) {
|
||||
return { valid: false, reason: "not_impersonating" };
|
||||
}
|
||||
if (!actorUserId) {
|
||||
return { valid: false, reason: "missing_actor" };
|
||||
}
|
||||
if (options.expectedActorUserId && actorUserId !== options.expectedActorUserId) {
|
||||
return { valid: false, reason: "wrong_actor" };
|
||||
}
|
||||
if (!startedAt) {
|
||||
return { valid: false, reason: "missing_started_at" };
|
||||
}
|
||||
|
||||
const issuedAtMs = Date.parse(startedAt);
|
||||
if (!Number.isFinite(issuedAtMs)) {
|
||||
return { valid: false, reason: "malformed_started_at" };
|
||||
}
|
||||
|
||||
const nowMs = options.now?.getTime() ?? Date.now();
|
||||
if (issuedAtMs > nowMs) {
|
||||
return { valid: false, reason: "future_started_at" };
|
||||
}
|
||||
if (nowMs - issuedAtMs > IMPERSONATION_SESSION_TTL_MS) {
|
||||
return { valid: false, reason: "stale_started_at" };
|
||||
}
|
||||
|
||||
return { valid: true, reason: "valid", actorUserId, startedAt };
|
||||
}
|
||||
|
||||
const webConfig = loadRequiredWebConfig();
|
||||
|
||||
export const storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...resolveSessionCookiePolicy({
|
||||
nodeEnv: webConfig.nodeEnv,
|
||||
routing: webConfig.routing,
|
||||
name: "RJ_session",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
}),
|
||||
secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [],
|
||||
},
|
||||
});
|
||||
|
||||
export function getUserSession(request: Request) {
|
||||
return storage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function getUserInfo(request: Request): Promise<UserSession> {
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId") ?? null;
|
||||
const lightOrDarkMode = session.get("lightOrDarkMode") ?? "";
|
||||
const lng = ["de", "en"].includes(session.get("lng") ?? "") ? session.get("lng") : "de";
|
||||
const csrf = session.get("csrf");
|
||||
const metrics = session.get("metrics") ?? { enabled: false, logToConsole: false, saveToDatabase: false, ignoreUrls: [] };
|
||||
const cookies = session.get("cookies") ?? [];
|
||||
const impersonatingFromUserId = session.get("impersonatingFromUserId");
|
||||
const impersonatingStartedAt = session.get("impersonatingStartedAt");
|
||||
const impersonationValidation = validateImpersonationSessionState({ impersonatingFromUserId, impersonatingStartedAt, userId });
|
||||
const theme = session.get("theme");
|
||||
const passwordPolicyOutdated = session.get("passwordPolicyOutdated") === true;
|
||||
const sessionToken = session.get("sessionToken");
|
||||
const pendingMfaUserId = session.get(PENDING_MFA_USER_ID_KEY);
|
||||
const pendingMfaRedirectTo = session.get(PENDING_MFA_REDIRECT_KEY);
|
||||
return {
|
||||
userId,
|
||||
lightOrDarkMode,
|
||||
lng,
|
||||
csrf,
|
||||
metrics,
|
||||
cookies,
|
||||
impersonatingFromUserId: impersonationValidation.valid ? impersonatingFromUserId : undefined,
|
||||
impersonatingStartedAt: impersonationValidation.valid ? impersonatingStartedAt : undefined,
|
||||
theme,
|
||||
passwordPolicyOutdated: passwordPolicyOutdated || undefined,
|
||||
sessionToken: typeof sessionToken === "string" ? sessionToken : undefined,
|
||||
pendingMfaUserId: typeof pendingMfaUserId === "string" ? pendingMfaUserId : undefined,
|
||||
pendingMfaRedirectTo: typeof pendingMfaRedirectTo === "string" ? pendingMfaRedirectTo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D1 (2FA-Login-Gate) — schreibt den Pending-MFA-Zustand in das SIGNIERTE Session-Cookie und gibt
|
||||
* den Set-Cookie-String zurueck. Bewusst KEINE Session: `userId`/`sessionToken` werden entfernt,
|
||||
* gesetzt wird nur `pendingMfaUserId` (+ rohes `pendingMfaRedirectTo`). Das bestehende Cookie wird
|
||||
* weiterverwendet, damit der bereits ausgehandelte `csrf`-Token erhalten bleibt und die Verify-Route
|
||||
* die CSRF-Pruefung besteht.
|
||||
*/
|
||||
export async function commitPendingMfaLoginCookie(request: Request, { userId, redirectTo }: { userId: string; redirectTo?: string }) {
|
||||
const session = await getUserSession(request);
|
||||
session.unset("userId");
|
||||
session.unset("sessionToken");
|
||||
session.set(PENDING_MFA_USER_ID_KEY, userId);
|
||||
if (typeof redirectTo === "string" && redirectTo.length > 0) {
|
||||
session.set(PENDING_MFA_REDIRECT_KEY, redirectTo);
|
||||
} else {
|
||||
session.unset(PENDING_MFA_REDIRECT_KEY);
|
||||
}
|
||||
return storage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function requireUserId(request: Request, redirectTo: string = new URL(request.url).pathname) {
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (!userId || typeof userId !== "string") {
|
||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
await assertServerSessionNotRevoked(session, redirectTo);
|
||||
return userId;
|
||||
}
|
||||
|
||||
// R-P2a S4b — additive Revoke-Durchsetzung: traegt das Cookie einen serverseitigen Session-Token,
|
||||
// wird die UserSession-Zeile geprueft; ist sie revoked/abgelaufen, gilt der Nutzer als abgemeldet
|
||||
// (Cookie zerstoeren + zur Login-Seite). Fail-open: ein Store-Fehler meldet NICHT ab (ein DB-Blip
|
||||
// soll keinen Massen-Logout ausloesen). Alte Cookies ohne Token bleiben unberuehrt.
|
||||
async function assertServerSessionNotRevoked(session: Session, redirectTo: string) {
|
||||
const sessionToken = session.get("sessionToken");
|
||||
if (typeof sessionToken !== "string" || sessionToken.length === 0) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
try {
|
||||
active = (await getActiveSessionByToken(sessionToken)) !== null;
|
||||
} catch (error) {
|
||||
sessionLogger.warn(
|
||||
{ op: "session.revokeCheck.failed", err: { name: error instanceof Error ? error.name : "Error" } },
|
||||
"Server-session revoke check raised; failing open (treating session as valid)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`, { headers: { "Set-Cookie": await storage.destroySession(session) } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser({ request }: { request: Request }) {
|
||||
const userId = await requireUserId(request);
|
||||
const user = await getUser(userId);
|
||||
if (!user) {
|
||||
throw redirect("/login");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
// R-P2a S4b — die serverseitige Session-Zeile mit beenden (best-effort), damit ein abgemeldetes
|
||||
// Geraet auch in der Geraeteliste verschwindet und der Token nicht weiterlebt.
|
||||
const sessionToken = session.get("sessionToken");
|
||||
if (typeof sessionToken === "string" && sessionToken.length > 0) {
|
||||
await endServerSession(sessionToken).catch((error: unknown) => {
|
||||
sessionLogger.warn(
|
||||
{ op: "session.logout.revokeFailed", err: { name: error instanceof Error ? error.name : "Error" } },
|
||||
"Logout could not revoke the server session row; cookie is destroyed regardless."
|
||||
);
|
||||
});
|
||||
}
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.destroySession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserSession(userSession: UserSession, redirectTo: string = "") {
|
||||
const session = await storage.getSession();
|
||||
session.set("userId", userSession.userId);
|
||||
session.set("lightOrDarkMode", userSession.lightOrDarkMode);
|
||||
session.set("lng", userSession.lng);
|
||||
session.set("csrf", userSession.csrf);
|
||||
session.set("metrics", userSession.metrics);
|
||||
session.set("cookies", userSession.cookies);
|
||||
const impersonationValidation = validateImpersonationSessionState(userSession);
|
||||
session.set("impersonatingFromUserId", impersonationValidation.valid ? userSession.impersonatingFromUserId : undefined);
|
||||
session.set("impersonatingStartedAt", impersonationValidation.valid ? userSession.impersonatingStartedAt : undefined);
|
||||
session.set("theme", userSession.theme);
|
||||
session.set("passwordPolicyOutdated", userSession.passwordPolicyOutdated === true ? true : undefined);
|
||||
session.set("sessionToken", userSession.sessionToken);
|
||||
// D1 — ein vollwertiger Login loescht den Pending-MFA-Zustand. createUserSession baut zwar ein
|
||||
// frisches Cookie (ohne diese Keys), das explizite Loeschen dokumentiert die Invariante und
|
||||
// schuetzt, falls hier kuenftig eine bestehende Session weiterverwendet wuerde.
|
||||
session.unset(PENDING_MFA_USER_ID_KEY);
|
||||
session.unset(PENDING_MFA_REDIRECT_KEY);
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function commitSession(session: Session) {
|
||||
return await storage.commitSession(session);
|
||||
}
|
||||
|
||||
export function generateCSRFToken() {
|
||||
// 32 bytes = 256 bits of entropy (NIST-recommended for CSRF tokens). The
|
||||
// previous 100-byte payload was overkill and bloated every cookie/response.
|
||||
// Hex encoding keeps the token URL/header safe without padding chars.
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function setCSRFToken(session: Session, csrf: string = generateCSRFToken()) {
|
||||
session.set("csrf", csrf);
|
||||
return csrf;
|
||||
}
|
||||
|
||||
function getStringCSRFToken(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const token = value.trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
}
|
||||
|
||||
export type ValidateCSRFTokenOptions = {
|
||||
/**
|
||||
* Already-parsed form data from the caller. When provided, the helper will
|
||||
* NEVER touch the request body. This is the safe pattern for action handlers
|
||||
* that have already consumed `request.formData()` to read form fields —
|
||||
* cloning a consumed body raises `TypeError: unusable` at runtime (the
|
||||
* B-009 follow-up bug).
|
||||
*/
|
||||
formData?: FormData;
|
||||
/**
|
||||
* Skip the body fallback entirely. The header must carry a valid token.
|
||||
* Used by background or bot-driven contexts (e.g. lighthouse session
|
||||
* capture) where the body is reserved for a different purpose.
|
||||
*/
|
||||
headerOnly?: boolean;
|
||||
};
|
||||
|
||||
async function getCSRFTokenFromRequest(request: Request, options?: ValidateCSRFTokenOptions) {
|
||||
const headerToken = getStringCSRFToken(request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token"));
|
||||
if (headerToken) {
|
||||
return headerToken;
|
||||
}
|
||||
|
||||
if (options?.headerOnly) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (options?.formData) {
|
||||
// Caller already parsed the body — read the token from the injected
|
||||
// FormData and short-circuit. Never clone the request: its underlying
|
||||
// stream is already consumed.
|
||||
return getStringCSRFToken(options.formData.get("csrf"));
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) {
|
||||
try {
|
||||
const formData = await request.clone().formData();
|
||||
const token = getStringCSRFToken(formData.get("csrf"));
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to text parsing for malformed or non-standard requests.
|
||||
}
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await request.clone().text();
|
||||
} catch {
|
||||
// The request body was already consumed by the caller without supplying
|
||||
// `options.formData`. Surface a clear error instead of leaking the opaque
|
||||
// `TypeError: unusable` from undici.
|
||||
throw new Error("CSRF body already consumed; pass { formData } to validateCSRFToken.");
|
||||
}
|
||||
if (!rawBody) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawBody) as { csrf?: unknown };
|
||||
const token = getStringCSRFToken(parsed.csrf);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to URLSearchParams for clients that send form bodies with
|
||||
// incomplete content-type headers.
|
||||
}
|
||||
}
|
||||
|
||||
return getStringCSRFToken(new URLSearchParams(rawBody).get("csrf"));
|
||||
}
|
||||
|
||||
export async function validateCSRFToken(request: Request, options?: ValidateCSRFTokenOptions) {
|
||||
const session = await getUserSession(request);
|
||||
const sessionToken = session.get("csrf");
|
||||
const requestToken = await getCSRFTokenFromRequest(request, options);
|
||||
|
||||
if (!sessionToken) throw new Error("CSRF Session Token not included.");
|
||||
if (!requestToken) throw new Error("CSRF Request Token not included.");
|
||||
if (!timingSafeStringEqual(requestToken, sessionToken)) throw new Error("CSRF tokens do not match, try refreshing the page.");
|
||||
}
|
||||
|
||||
function timingSafeStringEqual(a: string, b: string) {
|
||||
const aBuffer = Buffer.from(a);
|
||||
const bBuffer = Buffer.from(b);
|
||||
if (aBuffer.byteLength !== bBuffer.byteLength) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(aBuffer, bBuffer);
|
||||
}
|
||||
Reference in new issue
Block a user