Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/mfa-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Self-hosted instances can set `MFA_REQUIRED` to make multi-factor authentication mandatory for every dashboard user. Until a user has enrolled a second factor they're taken to a setup page instead of the dashboard, and once enrolled they can no longer turn MFA off. Off by default.
5 changes: 5 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@ const EnvironmentSchema = z
WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
// Opt-in for self-hosted instances under a compliance program (FedRAMP,
// SOC 2, HIPAA): require every dashboard user to enroll a second factor
// (TOTP) before they can use the dashboard, and prevent them from
// disabling it afterwards. Off by default.
MFA_REQUIRED: BoolEnv.default(false),
APP_ORIGIN: z.string().default("http://localhost:3030"),
// Extra exact origins (comma separated) added to the document `img-src` CSP,
// e.g. an SSO host serving profile images. Wildcards are refused.
Expand Down
13 changes: 13 additions & 0 deletions apps/webapp/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { usePostHog } from "./hooks/usePostHog";
import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
import { clearImpersonation } from "./models/admin.server";
import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server";
import { throwIfMfaRequired } from "./services/mfa/mfaRequirement.server";
import { getUser } from "./services/session.server";
import {
normalizeIconContrast,
Expand Down Expand Up @@ -81,6 +82,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const timezone = await getTimezonePreference(request);

const user = await getUser(request);
// `MFA_REQUIRED`: refuse every non-exempt request from a signed-in user who
// has not enrolled a second factor, sending them to the setup page instead.
//
// `requireUser` applies the same check, but it only guards the routes that
// call it — this covers routes that authenticate with `requireUserId`
// (which stays cookie-only, with no user row to inspect). Action-only
// resource routes skip the root loader entirely, so they are gated only
// where they call `requireUser`; the MFA enrollment endpoint is exempt by
// design, since it is the only way to enroll.
//
// Logged-out requests are untouched — they take the normal login flow.
throwIfMfaRequired(request, user);
// Feature-flagged; while off everyone stays on Dark at contrast 0. Admins
// always get it. Cached: this loader runs on every request and navigation.
const showThemeSwitcher = user
Expand Down
5 changes: 5 additions & 0 deletions apps/webapp/app/routes/_app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { tenantContext } from "~/services/tenantContext.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";

export const loader = async ({ request }: LoaderFunctionArgs) => {
// `requireUser` also enforces `MFA_REQUIRED`: a signed-in user without a
// second factor is redirected to /mfa/setup before this loader gets any
// further. That check lives there rather than here so it also covers the
// routes not nested under this layout (/account/*, /invites, /admin/*) —
// see services/mfa/mfaRequirement.server.ts.
const user = await requireUser(request);
tenantContext.enrich({ userId: user.id });

Expand Down
8 changes: 7 additions & 1 deletion apps/webapp/app/routes/account.security/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { requireUser } from "~/services/session.server";
import {
getAllowedSessionOptions,
Expand Down Expand Up @@ -49,7 +50,12 @@ export default function Page() {
<Header2>Security</Header2>
</div>
<div className="w-full border-b border-grid-dimmed py-4">
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
{/*
Under MFA_REQUIRED every signed-in user is gated to the setup
page until they enroll, so this page is only reachable with MFA
already on — the toggle is hidden and there is nothing to disable.
*/}
<MfaSetup isEnabled={!!user.mfaEnabledAt} isRequired={env.MFA_REQUIRED} />
</div>
<div className="w-full border-b border-grid-dimmed py-4">
<SessionDurationSetting
Expand Down
278 changes: 278 additions & 0 deletions apps/webapp/app/routes/mfa.setup/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { DownloadIcon } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { CopyButton } from "~/components/primitives/CopyButton";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
import { Paragraph } from "~/components/primitives/Paragraph";
import { requireUser } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";
import { rootPath } from "~/utils/pathBuilder";
import { pageMeta } from "~/utils/pageTitle";
import { useMfaSetup } from "../resources.account.mfa.setup/useMfaSetup";

export const meta = pageMeta("Set up multi-factor authentication");

/**
* Forced MFA enrollment.
*
* Reached when the instance sets `MFA_REQUIRED` and a signed-in user has not
* enrolled a second factor: `requireUser`, the root loader and the `_app`
* layout all redirect here for every other dashboard request, so this is the
* one place a MFA-less session can land.
*
* Unlike `/account/security` (where enrollment is one option among several),
* the flow here is inline and has no way out — the authenticator QR, the code
* entry and the recovery codes are the whole page.
*/
export async function loader({ request }: LoaderFunctionArgs) {
// Not gated: `/mfa/setup` is on the MFA exemption list, so this cannot loop.
const user = await requireUser(request);
const url = new URL(request.url);

// `redirectTo` is user-influenced (it round-trips through the query string),
// so it goes through the same sanitizer the login flow uses.
const redirectTo = sanitizeRedirectPath(url.searchParams.get("redirectTo"), rootPath());

// Already enrolled — they set it up in another tab, or the flag was enabled
// after they'd finished. Send them on to where they were going.
if (user.mfaEnabledAt) {
throw redirect(redirectTo);
}

return typedjson({ redirectTo });
}

export default function Page() {
const { redirectTo } = useTypedLoaderData<typeof loader>();

return <ForcedMfaEnrollment redirectTo={redirectTo} />;
}

function ForcedMfaEnrollment({ redirectTo }: { redirectTo: string }) {
const { state, actions } = useMfaSetup(false, redirectTo);
const [totpCode, setTotpCode] = useState("");

const isEnrolled = state.phase === "showing-recovery" && !!state.recoveryCodes;

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (totpCode.length !== 6) return;
actions.validateTotp(totpCode);
setTotpCode("");
};

const downloadRecoveryCodes = () => {
if (!state.recoveryCodes) return;

const blob = new Blob([state.recoveryCodes.join("\n")], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "trigger-dev-recovery-codes.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

return (
<AppContainer className="bg-background-deep">
<BackgroundWrapper>
<MainCenteredContainer
variant="onboarding"
className="max-w-130 rounded-lg border border-grid-bright bg-background-dimmed p-6 shadow-lg"
>
<FormTitle
title="Multi-factor authentication is required"
LeadingIcon={<LockClosedIcon className="size-7 text-amber-400" />}
description="Your organization requires a second factor before you can use the dashboard. Set one up now with your authenticator app."
/>

{isEnrolled ? (
<RecoveryCodes
codes={state.recoveryCodes ?? []}
onDownload={downloadRecoveryCodes}
onContinue={actions.saveRecoveryCodes}
isSubmitting={state.isSubmitting}
/>
) : state.setupData ? (
<AuthenticatorSetup
otpAuthUrl={state.setupData.otpAuthUrl}
secret={state.setupData.secret}
totpCode={totpCode}
onTotpCodeChange={setTotpCode}
onSubmit={handleSubmit}
error={state.error}
isSubmitting={state.isSubmitting}
/>
) : (
<StartEnrollment
onStart={actions.enableMfa}
isSubmitting={state.isSubmitting}
error={state.error}
/>
)}
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}

function StartEnrollment({
onStart,
isSubmitting,
error,
}: {
onStart: () => void;
isSubmitting: boolean;
error?: string;
}) {
return (
<div className="flex flex-col gap-4 pt-2">
<Paragraph spacing>
You'll scan a QR code with an app like Google Authenticator, 1Password or Authy, then enter
the 6-digit code it generates to prove it's set up correctly.
</Paragraph>
<div className="flex flex-col gap-2">
<Button type="button" variant="primary/medium" onClick={onStart} isLoading={isSubmitting}>
Set up authenticator app
</Button>
{error && <FormError>{error}</FormError>}
</div>
</div>
);
}

function AuthenticatorSetup({
otpAuthUrl,
secret,
totpCode,
onTotpCodeChange,
onSubmit,
error,
isSubmitting,
}: {
otpAuthUrl: string;
secret: string;
totpCode: string;
onTotpCodeChange: (code: string) => void;
onSubmit: (e: React.FormEvent) => void;
error?: string;
isSubmitting: boolean;
}) {
return (
<Form method="post" onSubmit={onSubmit}>
<div className="flex flex-col gap-4 pt-3">
<Paragraph>
Scan the QR code below with your authenticator app, then enter the 6-digit code it
generates. You can also copy the secret into your app manually.
</Paragraph>

<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<QRCodeSVG value={otpAuthUrl} size={260} marginSize={3} />
</div>
<CopyableText value={secret} className="font-mono text-sm tracking-wide" />
</div>

<div className="flex items-center justify-center">
<InputOTP
maxLength={6}
value={totpCode}
onChange={onTotpCodeChange}
variant="large"
name="totpCode"
>
<InputOTPGroup variant="large">
<InputOTPSlot index={0} variant="large" autoFocus />
<InputOTPSlot index={1} variant="large" />
<InputOTPSlot index={2} variant="large" />
<InputOTPSlot index={3} variant="large" />
<InputOTPSlot index={4} variant="large" />
<InputOTPSlot index={5} variant="large" />
</InputOTPGroup>
</InputOTP>
</div>

{error && <FormError>{error}</FormError>}

<Button
type="submit"
variant="primary/medium"
disabled={totpCode.length !== 6 || isSubmitting}
isLoading={isSubmitting}
shortcut={{ key: "Enter" }}
hideShortcutKey
>
Confirm
</Button>
</div>
</Form>
);
}

function RecoveryCodes({
codes,
onDownload,
onContinue,
isSubmitting,
}: {
codes: string[];
onDownload: () => void;
onContinue: () => void;
isSubmitting: boolean;
}) {
return (
<div className="flex flex-col gap-3 pt-3">
<Paragraph spacing>
Multi-factor authentication is on. Copy and store these recovery codes somewhere safe —
they're the only way back in if you lose your device. Each code works once.
</Paragraph>

<div className="flex flex-col rounded border border-grid-dimmed bg-background-bright">
<div className="grid grid-cols-3 gap-x-2 gap-y-4 px-3 py-6">
{codes.map((code, index) => (
<span key={index} className="text-center font-mono text-xs text-text-bright">
{code}
</span>
))}
</div>
<div className="flex items-center justify-end border-t border-grid-bright px-1.5 py-1.5">
<Button
type="button"
variant="minimal/medium"
onClick={onDownload}
LeadingIcon={DownloadIcon}
>
Download
</Button>
<CopyButton value={codes.join("\n")} buttonVariant="minimal" showTooltip={false}>
Copy
</CopyButton>
</div>
</div>

<Button
type="button"
variant="primary/medium"
onClick={onContinue}
isLoading={isSubmitting}
shortcut={{ key: "Enter" }}
hideShortcutKey
autoFocus
>
Continue
</Button>
</div>
);
}
Loading