Skip to content

Commit 6b0c6c1

Browse files
MauryaQbitOpenCode
andcommitted
feat(webapp): add MFA_REQUIRED env flag to enforce MFA for all dashboard users
Self-hosted instances under compliance programs (FedRAMP, SOC 2, HIPAA) could not require a second factor — TOTP was opt-in per user and users could turn it off again. Adds an opt-in `MFA_REQUIRED` webapp env var (off by default). When set: - A signed-in user without MFA is redirected to a new /mfa/setup page and every other dashboard request is refused until they enroll. - MFA can no longer be disabled, enforced in the service, the resource route action and the UI. The gate lives in `requireUser` (covers /account/*, /invites, /admin/* and anything else calling it) and in the root loader (covers routes authenticating with `requireUserId` only). The auth flow, /logout, /magic and the enrollment endpoint are exempt so the flow cannot deadlock. Closes #5025 Co-Authored-By: OpenCode <noreply@opencode.ai>
1 parent 3f4ac50 commit 6b0c6c1

14 files changed

Lines changed: 646 additions & 15 deletions

File tree

‎.server-changes/mfa-required.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

‎apps/webapp/app/env.server.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,11 @@ const EnvironmentSchema = z
400400
WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(),
401401
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
402402
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
403+
// Opt-in for self-hosted instances under a compliance program (FedRAMP,
404+
// SOC 2, HIPAA): require every dashboard user to enroll a second factor
405+
// (TOTP) before they can use the dashboard, and prevent them from
406+
// disabling it afterwards. Off by default.
407+
MFA_REQUIRED: BoolEnv.default(false),
403408
APP_ORIGIN: z.string().default("http://localhost:3030"),
404409
// Extra exact origins (comma separated) added to the document `img-src` CSP,
405410
// e.g. an SSO host serving profile images. Wildcards are refused.

‎apps/webapp/app/root.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { usePostHog } from "./hooks/usePostHog";
2323
import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
2424
import { clearImpersonation } from "./models/admin.server";
2525
import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server";
26+
import { throwIfMfaRequired } from "./services/mfa/mfaRequirement.server";
2627
import { getUser } from "./services/session.server";
2728
import {
2829
normalizeIconContrast,
@@ -81,6 +82,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
8182
const timezone = await getTimezonePreference(request);
8283

8384
const user = await getUser(request);
85+
// `MFA_REQUIRED`: refuse every non-exempt request from a signed-in user who
86+
// has not enrolled a second factor, sending them to the setup page instead.
87+
//
88+
// `requireUser` applies the same check, but it only guards the routes that
89+
// call it — this covers routes that authenticate with `requireUserId`
90+
// (which stays cookie-only, with no user row to inspect). Action-only
91+
// resource routes skip the root loader entirely, so they are gated only
92+
// where they call `requireUser`; the MFA enrollment endpoint is exempt by
93+
// design, since it is the only way to enroll.
94+
//
95+
// Logged-out requests are untouched — they take the normal login flow.
96+
throwIfMfaRequired(request, user);
8497
// Feature-flagged; while off everyone stays on Dark at contrast 0. Admins
8598
// always get it. Cached: this loader runs on every request and navigation.
8699
const showThemeSwitcher = user

‎apps/webapp/app/routes/_app/route.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { tenantContext } from "~/services/tenantContext.server";
99
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
1010

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

‎apps/webapp/app/routes/account.security/route.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Header2 } from "~/components/primitives/Headers";
99
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
1010
import { $replica } from "~/db.server";
11+
import { env } from "~/env.server";
1112
import { requireUser } from "~/services/session.server";
1213
import {
1314
getAllowedSessionOptions,
@@ -49,7 +50,12 @@ export default function Page() {
4950
<Header2>Security</Header2>
5051
</div>
5152
<div className="w-full border-b border-grid-dimmed py-4">
52-
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
53+
{/*
54+
Under MFA_REQUIRED every signed-in user is gated to the setup
55+
page until they enroll, so this page is only reachable with MFA
56+
already on — the toggle is hidden and there is nothing to disable.
57+
*/}
58+
<MfaSetup isEnabled={!!user.mfaEnabledAt} isRequired={env.MFA_REQUIRED} />
5359
</div>
5460
<div className="w-full border-b border-grid-dimmed py-4">
5561
<SessionDurationSetting
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { LockClosedIcon } from "@heroicons/react/20/solid";
2+
import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
3+
import { Form } from "@remix-run/react";
4+
import { DownloadIcon } from "lucide-react";
5+
import { QRCodeSVG } from "qrcode.react";
6+
import { useState } from "react";
7+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
8+
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
9+
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
10+
import { Button } from "~/components/primitives/Buttons";
11+
import { CopyableText } from "~/components/primitives/CopyableText";
12+
import { CopyButton } from "~/components/primitives/CopyButton";
13+
import { FormError } from "~/components/primitives/FormError";
14+
import { FormTitle } from "~/components/primitives/FormTitle";
15+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
16+
import { Paragraph } from "~/components/primitives/Paragraph";
17+
import { requireUser } from "~/services/session.server";
18+
import { sanitizeRedirectPath } from "~/utils";
19+
import { rootPath } from "~/utils/pathBuilder";
20+
import { pageMeta } from "~/utils/pageTitle";
21+
import { useMfaSetup } from "../resources.account.mfa.setup/useMfaSetup";
22+
23+
export const meta = pageMeta("Set up multi-factor authentication");
24+
25+
/**
26+
* Forced MFA enrollment.
27+
*
28+
* Reached when the instance sets `MFA_REQUIRED` and a signed-in user has not
29+
* enrolled a second factor: `requireUser`, the root loader and the `_app`
30+
* layout all redirect here for every other dashboard request, so this is the
31+
* one place a MFA-less session can land.
32+
*
33+
* Unlike `/account/security` (where enrollment is one option among several),
34+
* the flow here is inline and has no way out — the authenticator QR, the code
35+
* entry and the recovery codes are the whole page.
36+
*/
37+
export async function loader({ request }: LoaderFunctionArgs) {
38+
// Not gated: `/mfa/setup` is on the MFA exemption list, so this cannot loop.
39+
const user = await requireUser(request);
40+
const url = new URL(request.url);
41+
42+
// `redirectTo` is user-influenced (it round-trips through the query string),
43+
// so it goes through the same sanitizer the login flow uses.
44+
const redirectTo = sanitizeRedirectPath(url.searchParams.get("redirectTo"), rootPath());
45+
46+
// Already enrolled — they set it up in another tab, or the flag was enabled
47+
// after they'd finished. Send them on to where they were going.
48+
if (user.mfaEnabledAt) {
49+
throw redirect(redirectTo);
50+
}
51+
52+
return typedjson({ redirectTo });
53+
}
54+
55+
export default function Page() {
56+
const { redirectTo } = useTypedLoaderData<typeof loader>();
57+
58+
return <ForcedMfaEnrollment redirectTo={redirectTo} />;
59+
}
60+
61+
function ForcedMfaEnrollment({ redirectTo }: { redirectTo: string }) {
62+
const { state, actions } = useMfaSetup(false, redirectTo);
63+
const [totpCode, setTotpCode] = useState("");
64+
65+
const isEnrolled = state.phase === "showing-recovery" && !!state.recoveryCodes;
66+
67+
const handleSubmit = (e: React.FormEvent) => {
68+
e.preventDefault();
69+
if (totpCode.length !== 6) return;
70+
actions.validateTotp(totpCode);
71+
setTotpCode("");
72+
};
73+
74+
const downloadRecoveryCodes = () => {
75+
if (!state.recoveryCodes) return;
76+
77+
const blob = new Blob([state.recoveryCodes.join("\n")], { type: "text/plain" });
78+
const url = URL.createObjectURL(blob);
79+
const a = document.createElement("a");
80+
a.href = url;
81+
a.download = "trigger-dev-recovery-codes.txt";
82+
document.body.appendChild(a);
83+
a.click();
84+
document.body.removeChild(a);
85+
URL.revokeObjectURL(url);
86+
};
87+
88+
return (
89+
<AppContainer className="bg-background-deep">
90+
<BackgroundWrapper>
91+
<MainCenteredContainer
92+
variant="onboarding"
93+
className="max-w-130 rounded-lg border border-grid-bright bg-background-dimmed p-6 shadow-lg"
94+
>
95+
<FormTitle
96+
title="Multi-factor authentication is required"
97+
LeadingIcon={<LockClosedIcon className="size-7 text-amber-400" />}
98+
description="Your organization requires a second factor before you can use the dashboard. Set one up now with your authenticator app."
99+
/>
100+
101+
{isEnrolled ? (
102+
<RecoveryCodes
103+
codes={state.recoveryCodes ?? []}
104+
onDownload={downloadRecoveryCodes}
105+
onContinue={actions.saveRecoveryCodes}
106+
isSubmitting={state.isSubmitting}
107+
/>
108+
) : state.setupData ? (
109+
<AuthenticatorSetup
110+
otpAuthUrl={state.setupData.otpAuthUrl}
111+
secret={state.setupData.secret}
112+
totpCode={totpCode}
113+
onTotpCodeChange={setTotpCode}
114+
onSubmit={handleSubmit}
115+
error={state.error}
116+
isSubmitting={state.isSubmitting}
117+
/>
118+
) : (
119+
<StartEnrollment
120+
onStart={actions.enableMfa}
121+
isSubmitting={state.isSubmitting}
122+
error={state.error}
123+
/>
124+
)}
125+
</MainCenteredContainer>
126+
</BackgroundWrapper>
127+
</AppContainer>
128+
);
129+
}
130+
131+
function StartEnrollment({
132+
onStart,
133+
isSubmitting,
134+
error,
135+
}: {
136+
onStart: () => void;
137+
isSubmitting: boolean;
138+
error?: string;
139+
}) {
140+
return (
141+
<div className="flex flex-col gap-4 pt-2">
142+
<Paragraph spacing>
143+
You'll scan a QR code with an app like Google Authenticator, 1Password or Authy, then enter
144+
the 6-digit code it generates to prove it's set up correctly.
145+
</Paragraph>
146+
<div className="flex flex-col gap-2">
147+
<Button type="button" variant="primary/medium" onClick={onStart} isLoading={isSubmitting}>
148+
Set up authenticator app
149+
</Button>
150+
{error && <FormError>{error}</FormError>}
151+
</div>
152+
</div>
153+
);
154+
}
155+
156+
function AuthenticatorSetup({
157+
otpAuthUrl,
158+
secret,
159+
totpCode,
160+
onTotpCodeChange,
161+
onSubmit,
162+
error,
163+
isSubmitting,
164+
}: {
165+
otpAuthUrl: string;
166+
secret: string;
167+
totpCode: string;
168+
onTotpCodeChange: (code: string) => void;
169+
onSubmit: (e: React.FormEvent) => void;
170+
error?: string;
171+
isSubmitting: boolean;
172+
}) {
173+
return (
174+
<Form method="post" onSubmit={onSubmit}>
175+
<div className="flex flex-col gap-4 pt-3">
176+
<Paragraph>
177+
Scan the QR code below with your authenticator app, then enter the 6-digit code it
178+
generates. You can also copy the secret into your app manually.
179+
</Paragraph>
180+
181+
<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
182+
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
183+
<QRCodeSVG value={otpAuthUrl} size={260} marginSize={3} />
184+
</div>
185+
<CopyableText value={secret} className="font-mono text-sm tracking-wide" />
186+
</div>
187+
188+
<div className="flex items-center justify-center">
189+
<InputOTP
190+
maxLength={6}
191+
value={totpCode}
192+
onChange={onTotpCodeChange}
193+
variant="large"
194+
name="totpCode"
195+
>
196+
<InputOTPGroup variant="large">
197+
<InputOTPSlot index={0} variant="large" autoFocus />
198+
<InputOTPSlot index={1} variant="large" />
199+
<InputOTPSlot index={2} variant="large" />
200+
<InputOTPSlot index={3} variant="large" />
201+
<InputOTPSlot index={4} variant="large" />
202+
<InputOTPSlot index={5} variant="large" />
203+
</InputOTPGroup>
204+
</InputOTP>
205+
</div>
206+
207+
{error && <FormError>{error}</FormError>}
208+
209+
<Button
210+
type="submit"
211+
variant="primary/medium"
212+
disabled={totpCode.length !== 6 || isSubmitting}
213+
isLoading={isSubmitting}
214+
shortcut={{ key: "Enter" }}
215+
hideShortcutKey
216+
>
217+
Confirm
218+
</Button>
219+
</div>
220+
</Form>
221+
);
222+
}
223+
224+
function RecoveryCodes({
225+
codes,
226+
onDownload,
227+
onContinue,
228+
isSubmitting,
229+
}: {
230+
codes: string[];
231+
onDownload: () => void;
232+
onContinue: () => void;
233+
isSubmitting: boolean;
234+
}) {
235+
return (
236+
<div className="flex flex-col gap-3 pt-3">
237+
<Paragraph spacing>
238+
Multi-factor authentication is on. Copy and store these recovery codes somewhere safe —
239+
they're the only way back in if you lose your device. Each code works once.
240+
</Paragraph>
241+
242+
<div className="flex flex-col rounded border border-grid-dimmed bg-background-bright">
243+
<div className="grid grid-cols-3 gap-x-2 gap-y-4 px-3 py-6">
244+
{codes.map((code, index) => (
245+
<span key={index} className="text-center font-mono text-xs text-text-bright">
246+
{code}
247+
</span>
248+
))}
249+
</div>
250+
<div className="flex items-center justify-end border-t border-grid-bright px-1.5 py-1.5">
251+
<Button
252+
type="button"
253+
variant="minimal/medium"
254+
onClick={onDownload}
255+
LeadingIcon={DownloadIcon}
256+
>
257+
Download
258+
</Button>
259+
<CopyButton value={codes.join("\n")} buttonVariant="minimal" showTooltip={false}>
260+
Copy
261+
</CopyButton>
262+
</div>
263+
</div>
264+
265+
<Button
266+
type="button"
267+
variant="primary/medium"
268+
onClick={onContinue}
269+
isLoading={isSubmitting}
270+
shortcut={{ key: "Enter" }}
271+
hideShortcutKey
272+
autoFocus
273+
>
274+
Continue
275+
</Button>
276+
</div>
277+
);
278+
}

0 commit comments

Comments
 (0)