167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router";
|
|
import { serverOnly$ } from "vite-env-only/macros";
|
|
import { loadRequiredWebConfig } from "@toolctrl/config";
|
|
import { getTranslations } from "~/locale/i18next.server";
|
|
import { ADMIN_MFA_DEFAULT_REDIRECT_PATH, ADMIN_MFA_ENROLLMENT_PATH } from "~/utils/auth/mfa/paths";
|
|
import { setMfaStepUpState, type MfaStepUpMethod } from "~/utils/auth/mfa/step-up.server";
|
|
import { verifyTotpCode } from "~/services/auth/totp.server";
|
|
import { checkMfaChallengeRateLimit, mfaChallengeRateLimitKey, recordFailedMfaChallengeAttempt, resetMfaChallengeRateLimit } from "~/utils/auth/mfa/challenge-rate-limit.server";
|
|
import { absoluteAdminPath } from "~/utils/app/CrossHostUrls";
|
|
import { getHostRoutingContextFromRequest } from "~/utils/app/HostRouting.server";
|
|
import { safeRedirectPath } from "~/utils/app/RedirectSafety.server";
|
|
import { consumeUserMfaRecoveryCode, getUser, getUserTotpStatus } from "~/utils/db/users.db.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { commitSession, getUserSession, validateCSRFToken } from "~/utils/session.server";
|
|
|
|
export type LoaderData = {
|
|
redirectTo: string;
|
|
totpEnabled: boolean;
|
|
enrollmentPath: string;
|
|
};
|
|
|
|
export type ActionData = {
|
|
error?: string;
|
|
redirectTo: string;
|
|
};
|
|
|
|
function isAdminHostRequest(request: Request) {
|
|
return getHostRoutingContextFromRequest(request) === "admin";
|
|
}
|
|
|
|
function getCanonicalAdminChallengeUrl(request: Request) {
|
|
const url = new URL(request.url);
|
|
return absoluteAdminPath(loadRequiredWebConfig().routing, `mfa-challenge${url.search}`);
|
|
}
|
|
|
|
function safeRedirectTo(value: FormDataEntryValue | string | null | undefined) {
|
|
return safeRedirectPath(typeof value === "string" ? value : null, { fallbackPath: ADMIN_MFA_DEFAULT_REDIRECT_PATH });
|
|
}
|
|
|
|
function isSixDigitTotpCode(code: FormDataEntryValue | null): code is string {
|
|
return typeof code === "string" && /^\d{6}$/.test(code);
|
|
}
|
|
|
|
function normalizeRecoveryCode(code: FormDataEntryValue | null) {
|
|
if (typeof code !== "string") {
|
|
return "";
|
|
}
|
|
return code.trim().toUpperCase();
|
|
}
|
|
|
|
async function getActiveTotpSecret(userId: string) {
|
|
const user = await db.user.findUnique({
|
|
where: { id: userId },
|
|
select: { totpSecretEncrypted: true, totpEnabledAt: true },
|
|
});
|
|
|
|
if (!user?.totpSecretEncrypted || !user.totpEnabledAt) {
|
|
return null;
|
|
}
|
|
|
|
return user.totpSecretEncrypted;
|
|
}
|
|
|
|
export const loader = serverOnly$(async ({ request }: LoaderFunctionArgs) => {
|
|
if (!isAdminHostRequest(request)) {
|
|
throw redirect(getCanonicalAdminChallengeUrl(request));
|
|
}
|
|
|
|
const session = await getUserSession(request);
|
|
const userId = session.get("userId");
|
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
throw redirect("/login");
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const { t } = await getTranslations(request);
|
|
const redirectTo = safeRedirectTo(url.searchParams.get("redirectTo"));
|
|
const user = await getUser(userId);
|
|
if (!user?.admin) {
|
|
throw Response.json({ error: t("admin.mfaChallenge.errors.adminAccessRequired"), redirectTo } satisfies ActionData, { status: 403 });
|
|
}
|
|
|
|
const totpStatus = await getUserTotpStatus(userId);
|
|
if (!totpStatus?.enabled) {
|
|
throw redirect(ADMIN_MFA_ENROLLMENT_PATH);
|
|
}
|
|
|
|
return Response.json({ redirectTo, totpEnabled: true, enrollmentPath: ADMIN_MFA_ENROLLMENT_PATH } satisfies LoaderData);
|
|
})!;
|
|
|
|
export const action = serverOnly$(async ({ request }: ActionFunctionArgs) => {
|
|
const { t } = await getTranslations(request);
|
|
if (!isAdminHostRequest(request)) {
|
|
return Response.json(
|
|
{ error: t("admin.mfaChallenge.errors.adminHostRequired"), redirectTo: getCanonicalAdminChallengeUrl(request) } satisfies ActionData,
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const session = await getUserSession(request);
|
|
const userId = session.get("userId");
|
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
throw redirect("/login");
|
|
}
|
|
|
|
await validateCSRFToken(request);
|
|
const form = await request.formData();
|
|
const redirectTo = safeRedirectTo(form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo"));
|
|
const user = await getUser(userId);
|
|
if (!user?.admin) {
|
|
return Response.json({ error: t("admin.mfaChallenge.errors.adminAccessRequired"), redirectTo } satisfies ActionData, { status: 403 });
|
|
}
|
|
|
|
const totpStatus = await getUserTotpStatus(user.id);
|
|
if (!totpStatus?.enabled) {
|
|
throw redirect(ADMIN_MFA_ENROLLMENT_PATH);
|
|
}
|
|
|
|
const rateLimitKey = mfaChallengeRateLimitKey(request, user.id);
|
|
const rateLimit = await checkMfaChallengeRateLimit(rateLimitKey);
|
|
if (!rateLimit.allowed) {
|
|
return Response.json({ error: t("admin.mfaChallenge.errors.tooManyAttempts"), redirectTo } satisfies ActionData, {
|
|
status: 429,
|
|
headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
|
|
});
|
|
}
|
|
|
|
const code = form.get("code");
|
|
let method: MfaStepUpMethod | null = null;
|
|
|
|
if (isSixDigitTotpCode(code)) {
|
|
const encryptedSecret = await getActiveTotpSecret(user.id);
|
|
let totpMatches = false;
|
|
try {
|
|
totpMatches = Boolean(encryptedSecret && verifyTotpCode(encryptedSecret, code));
|
|
} catch {
|
|
totpMatches = false;
|
|
}
|
|
if (totpMatches) {
|
|
method = "totp";
|
|
}
|
|
}
|
|
|
|
if (!method) {
|
|
const recoveryCode = normalizeRecoveryCode(form.get("recoveryCode"));
|
|
if (recoveryCode.length > 0) {
|
|
const result = await consumeUserMfaRecoveryCode(user.id, recoveryCode);
|
|
if (result?.consumed) {
|
|
method = "recovery-code";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!method) {
|
|
await recordFailedMfaChallengeAttempt(rateLimitKey);
|
|
return Response.json({ error: t("admin.mfaChallenge.errors.invalidCode"), redirectTo } satisfies ActionData, { status: 400 });
|
|
}
|
|
|
|
await resetMfaChallengeRateLimit(rateLimitKey);
|
|
setMfaStepUpState(session, { userId: user.id, method, redirectTo });
|
|
throw redirect(redirectTo, {
|
|
headers: {
|
|
"Set-Cookie": await commitSession(session),
|
|
},
|
|
});
|
|
})!;
|