diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 0c04044d913..374b2f31618 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -1,8 +1,13 @@ -import { ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid"; +import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { type User } from "@trigger.dev/database"; import { cn } from "~/utils/cn"; -import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder"; +import { + accountPath, + accountSecurityPath, + personalAccessTokensPath, + rootPath, +} from "~/utils/pathBuilder"; import { LinkButton } from "../primitives/Buttons"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; @@ -42,6 +47,13 @@ export function AccountSideMenu({ user }: { user: User }) { to={personalAccessTokensPath()} data-action="tokens" /> +
diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 1c1d611987b..2a93cb2c79f 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -27,6 +27,7 @@ type CopyButtonProps = { buttonClassName?: string; showTooltip?: boolean; buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal"; + children?: React.ReactNode; }; export function CopyButton({ @@ -37,6 +38,7 @@ export function CopyButton({ buttonClassName, showTooltip = true, buttonVariant = "tertiary", + children, }: CopyButtonProps) { const { copy, copied } = useCopy(value); @@ -66,22 +68,25 @@ export function CopyButton({ variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`} onClick={copy} className={cn("shrink-0", buttonClassName)} + LeadingIcon={ + copied ? ( + + ) : ( + + ) + } > - {copied ? ( - - ) : ( - - )} + {children} ); diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 5ac179646bf..7c28193717b 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -36,8 +36,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => (
{children} - - - - Close - + {showCloseButton && ( + + + + Close + + )}
)); diff --git a/apps/webapp/app/components/primitives/InputOTP.tsx b/apps/webapp/app/components/primitives/InputOTP.tsx new file mode 100644 index 00000000000..54ca070ac05 --- /dev/null +++ b/apps/webapp/app/components/primitives/InputOTP.tsx @@ -0,0 +1,110 @@ +"use client"; + +import * as React from "react"; +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; + +import { cn } from "~/utils/cn"; + +const variants = { + default: { + container: "flex items-center gap-2 has-disabled:opacity-50", + group: "flex items-center", + slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-sm outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]", + }, + large: { + container: "flex items-center gap-3 has-disabled:opacity-50", + group: "flex items-center gap-1", + slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-charcoal-750 border-charcoal-700 hover:border-charcoal-600 hover:bg-charcoal-650 relative flex h-12 w-12 items-center justify-center border text-base outline-none transition-all rounded-md data-[active=true]:z-10 data-[active=true]:ring-[3px] data-[active=true]:border-indigo-500", + }, + minimal: { + container: "flex items-center gap-2 has-disabled:opacity-50", + group: "flex items-center", + slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 border-transparent bg-transparent relative flex h-9 w-9 items-center justify-center border-b-2 border-b-charcoal-600 text-sm outline-none transition-all data-[active=true]:border-b-indigo-500 data-[active=true]:z-10", + }, +}; + +function InputOTP({ + className, + containerClassName, + variant = "default", + fullWidth = false, + ...props +}: React.ComponentProps & { + containerClassName?: string; + variant?: keyof typeof variants; + fullWidth?: boolean; +}) { + const variantStyles = variants[variant]; + + return ( + + ); +} + +function InputOTPGroup({ + className, + variant = "default", + fullWidth = false, + ...props +}: React.ComponentProps<"div"> & { + variant?: keyof typeof variants; + fullWidth?: boolean; +}) { + const variantStyles = variants[variant]; + + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + variant = "default", + fullWidth = false, + ...props +}: React.ComponentProps<"div"> & { + index: number; + variant?: keyof typeof variants; + fullWidth?: boolean; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + const variantStyles = variants[variant]; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 07bafd13b83..68c967b92d9 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -46,10 +46,11 @@ type SwitchProps = React.ComponentPropsWithoutRef label?: React.ReactNode; variant: keyof typeof variations; shortcut?: ShortcutDefinition; + labelPosition?: "left" | "right"; }; export const Switch = React.forwardRef, SwitchProps>( - ({ className, variant, label, ...props }, ref) => { + ({ className, variant, label, labelPosition = "left", ...props }, ref) => { const innerRef = React.useRef(null); React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement); @@ -67,35 +68,39 @@ export const Switch = React.forwardRef + {typeof label === "string" ? {label} : label} + + ) : null; + + const switchElement = ( +
+ +
+ ); + return ( - {label ? ( - - ) : null} -
- -
+ {labelPosition === "left" ? labelElement : null} + {switchElement} + {labelPosition === "right" ? labelElement : null}
); } diff --git a/apps/webapp/app/models/message.server.ts b/apps/webapp/app/models/message.server.ts index b488a43044d..cb6ca2963a9 100644 --- a/apps/webapp/app/models/message.server.ts +++ b/apps/webapp/app/models/message.server.ts @@ -1,6 +1,6 @@ import { json, Session } from "@remix-run/node"; import { createCookieSessionStorage } from "@remix-run/node"; -import { redirect } from "remix-typedjson"; +import { redirect, typedjson } from "remix-typedjson"; import { env } from "~/env.server"; export type ToastMessage = { @@ -121,6 +121,44 @@ export async function jsonWithErrorMessage( }); } +export async function typedJsonWithSuccessMessage( + data: T, + request: Request, + message: string, + options?: ToastMessageOptions +) { + const session = await getSession(request.headers.get("cookie")); + + setSuccessMessage(session, message, options); + + return typedjson(data, { + headers: { + "Set-Cookie": await commitSession(session, { + expires: new Date(Date.now() + ONE_YEAR), + }), + }, + }); +} + +export async function typedJsonWithErrorMessage( + data: T, + request: Request, + message: string, + options?: ToastMessageOptions +) { + const session = await getSession(request.headers.get("cookie")); + + setErrorMessage(session, message, options); + + return typedjson(data, { + headers: { + "Set-Cookie": await commitSession(session, { + expires: new Date(Date.now() + ONE_YEAR), + }), + }, + }); +} + export async function redirectWithSuccessMessage( path: string, request: Request, diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx new file mode 100644 index 00000000000..18cf8b54ab7 --- /dev/null +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -0,0 +1,49 @@ +import { type MetaFunction } from "@remix-run/react"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { MfaSetup } from "../resources.account.mfa.setup/route"; +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { requireUser } from "~/services/session.server"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Security | Trigger.dev`, + }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); + + return typedjson({ + user, + }); +} + +export default function Page() { + const { user } = useTypedLoaderData(); + + return ( + + + + + + + +
+ Security +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 15068414cfe..4c1c452d44b 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -1,25 +1,53 @@ import type { LoaderFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; import { redirectCookie } from "./auth.github"; +import { getUserSession, commitSession } from "~/services/sessionStorage.server"; import { logger } from "~/services/logger.server"; +import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server"; export let loader: LoaderFunction = async ({ request }) => { - const cookie = request.headers.get("Cookie"); - const redirectValue = await redirectCookie.parse(cookie); - const redirectTo = redirectValue ?? "/"; + try { + const cookie = request.headers.get("Cookie"); + const redirectValue = await redirectCookie.parse(cookie); + const redirectTo = redirectValue ?? "/"; - logger.debug("auth.github.callback loader", { - redirectTo, - }); + logger.debug("auth.github.callback loader", { + redirectTo, + }); - const authuser = await authenticator.authenticate("github", request, { - successRedirect: redirectTo, - failureRedirect: "/login", - }); + const authuser = await authenticator.authenticate("github", request, { + successRedirect: undefined, // Don't auto-redirect, we'll handle it + failureRedirect: undefined, // Don't auto-redirect on failure either + }); - logger.debug("auth.github.callback authuser", { - authuser, - }); + logger.debug("auth.github.callback authuser", { + authuser, + }); - return authuser; + // If we get here, user doesn't have MFA - complete login normally + return redirect(redirectTo); + } catch (error) { + // Check if this is an MFA_REQUIRED error + if (error instanceof MfaRequiredError) { + // User has MFA enabled - store pending user ID and redirect to MFA page + const session = await getUserSession(request); + session.set("pending-mfa-user-id", error.userId); + + const cookie = request.headers.get("Cookie"); + const redirectValue = await redirectCookie.parse(cookie); + const redirectTo = redirectValue ?? "/"; + session.set("pending-mfa-redirect-to", redirectTo); + + return redirect("/login/mfa", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + // Regular authentication failure, redirect to login page + logger.debug("auth.github.callback error", { error }); + return redirect("/login"); + } }; diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 69b6d84c5c8..04085ebd612 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -113,7 +113,7 @@ export default function LoginMagicLinkPage() { We've sent you a magic link!
- + We sent you an email which contains a magic link that will log you in to your account. diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx new file mode 100644 index 00000000000..0d1a347d4b5 --- /dev/null +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -0,0 +1,320 @@ +import type { + ActionFunctionArgs, + LoaderFunctionArgs, + MetaFunction, + Session, +} from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { Form, useNavigation } from "@remix-run/react"; +import React, { useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { LoginPageLayout } from "~/components/LoginPageLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +import { Header1 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { authenticator } from "~/services/auth.server"; +import { commitSession, getUserSession, sessionStorage } from "~/services/sessionStorage.server"; +import { getSession as getMessageSession } from "~/models/message.server"; +import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; +import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server"; + +export const meta: MetaFunction = ({ matches }) => { + const parentMeta = matches + .flatMap((match) => match.meta ?? []) + .filter((meta) => { + if ("title" in meta) return false; + if ("name" in meta && meta.name === "viewport") return false; + return true; + }); + + return [ + ...parentMeta, + { title: `Multi-factor authentication` }, + { + name: "viewport", + content: "width=device-width,initial-scale=1", + }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // Check if user is already fully authenticated + await authenticator.isAuthenticated(request, { + successRedirect: "/", + }); + + const session = await getUserSession(request); + + // Check if there's a pending MFA user ID + const pendingUserId = session.get("pending-mfa-user-id"); + if (!pendingUserId) { + // No pending MFA, redirect to login + return redirect("/login"); + } + + // Get flash message for MFA errors + const messageSession = await getMessageSession(request.headers.get("cookie")); + const toastMessage = messageSession.get("toastMessage"); + + let mfaError: string | undefined; + if (toastMessage?.type === "error") { + mfaError = toastMessage.message; + } + + return typedjson( + { + mfaError, + }, + { + headers: { "Set-Cookie": await commitSession(session) }, + } + ); +} + +export async function action({ request }: ActionFunctionArgs) { + try { + const session = await getUserSession(request); + const pendingUserId = session.get("pending-mfa-user-id"); + + if (!pendingUserId) { + return redirect("/login"); + } + + const payload = Object.fromEntries(await request.formData()); + + const { action } = z + .object({ + action: z.enum(["verify-recovery", "verify-mfa"]), + }) + .parse(payload); + + const mfaService = new MultiFactorAuthenticationService(); + + if (action === "verify-recovery") { + const recoveryCode = payload.recoveryCode as string; + + if (!recoveryCode) { + return redirectBackWithErrorMessage(request, "Recovery code is required"); + } + + // Rate limit MFA verification attempts + await checkMfaRateLimit(pendingUserId); + + const result = await mfaService.verifyRecoveryCodeForLogin(pendingUserId, recoveryCode); + + if (!result.success) { + return redirectBackWithErrorMessage(request, result.error || "Invalid authentication code"); + } + // Recovery code verified - complete the login + return await completeLogin(request, session, pendingUserId); + } else if (action === "verify-mfa") { + const mfaCode = payload.mfaCode as string; + + if (!mfaCode || mfaCode.length !== 6) { + return redirectBackWithErrorMessage(request, "Valid 6-digit code is required"); + } + + // Rate limit MFA verification attempts + await checkMfaRateLimit(pendingUserId); + + const result = await mfaService.verifyTotpForLogin(pendingUserId, mfaCode); + + if (!result.success) { + return redirectBackWithErrorMessage(request, result.error || "Invalid authentication code"); + } + + // TOTP code verified - complete the login + return await completeLogin(request, session, pendingUserId); + } + + return redirect("/login"); + } catch (error) { + if (error instanceof ServiceValidationError) { + return redirectWithErrorMessage("/login", request, error.message); + } + + if (error instanceof MfaRateLimitError) { + return redirectBackWithErrorMessage(request, error.message); + } + + throw error; + } +} + +async function completeLogin(request: Request, session: Session, userId: string) { + // Create a new authenticated session + const authSession = await sessionStorage.getSession(request.headers.get("Cookie")); + authSession.set(authenticator.sessionKey, { userId }); + + // Get the redirect URL and clean up pending MFA data + const redirectTo = session.get("pending-mfa-redirect-to") ?? "/"; + session.unset("pending-mfa-user-id"); + session.unset("pending-mfa-redirect-to"); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(authSession), + }, + }); +} + +export default function LoginMfaPage() { + const data = useTypedLoaderData(); + const rawMfaError = "mfaError" in data ? data.mfaError : undefined; + const navigate = useNavigation(); + const [showRecoveryCode, setShowRecoveryCode] = useState(false); + const [mfaCode, setMfaCode] = useState(""); + const [hideError, setHideError] = useState(false); + + // Clear the MFA code when form submission completes (success or failure) + const prevNavigationState = React.useRef(navigate.state); + React.useEffect(() => { + if (prevNavigationState.current === "submitting" && navigate.state === "idle") { + setMfaCode(""); + } + prevNavigationState.current = navigate.state; + }, [navigate.state]); + + // Reset hideError when a new error appears + React.useEffect(() => { + if (rawMfaError) { + setHideError(false); + } + }, [rawMfaError]); + + // Clear error and MFA code when switching between modes + const handleShowRecoveryCode = (show: boolean) => { + setShowRecoveryCode(show); + setHideError(true); + if (!show) { + setMfaCode(""); + } + }; + + // Only show error if not explicitly hidden and we have an error + const mfaError = hideError ? undefined : rawMfaError; + + const isLoading = + (navigate.state === "loading" || navigate.state === "submitting") && + navigate.formAction !== undefined; + + return ( + +
+
+ + Multi-factor authentication + + {showRecoveryCode ? ( + <> + + Enter one of your recovery codes to log in. + +
+ + + + + + {typeof mfaError === "string" && {mfaError}} +
+ + + ) : ( + <> + + Open your authenticator app to get your code. Then enter it below. + +
+ setMfaCode(value)} + variant="large" + fullWidth + > + + + + + + + + + + + + + {typeof mfaError === "string" && {mfaError}} +
+ + + )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index 725640c6f4b..05d3ac4f1be 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -1,12 +1,39 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; import { authenticator } from "~/services/auth.server"; +import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server"; import { getRedirectTo } from "~/services/redirectTo.server"; +import { getUserSession, commitSession } from "~/services/sessionStorage.server"; export async function loader({ request }: LoaderFunctionArgs) { - const redirectTo = await getRedirectTo(request); + try { + // Attempt to authenticate the user with email-link + const authUser = await authenticator.authenticate("email-link", request, { + successRedirect: undefined, // Don't auto-redirect, we'll handle it + failureRedirect: undefined, // Don't auto-redirect on failure either + }); - await authenticator.authenticate("email-link", request, { - successRedirect: redirectTo ?? "/", - failureRedirect: "/login/magic", - }); + // If we get here, user doesn't have MFA - complete login normally + const redirectTo = await getRedirectTo(request); + return redirect(redirectTo ?? "/"); + } catch (error) { + // Check if this is an MFA_REQUIRED error + if (error instanceof MfaRequiredError) { + // User has MFA enabled - store pending user ID and redirect to MFA page + const session = await getUserSession(request); + session.set("pending-mfa-user-id", error.userId); + + const redirectTo = await getRedirectTo(request); + session.set("pending-mfa-redirect-to", redirectTo ?? "/"); + + return redirect("/login/mfa", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } + + // Regular authentication failure, redirect to magic link page + return redirect("/login/magic"); + } } diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx new file mode 100644 index 00000000000..641a7edad21 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx @@ -0,0 +1,152 @@ +import { Form } from "@remix-run/react"; +import { useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; + +interface MfaDisableDialogProps { + isOpen: boolean; + isSubmitting: boolean; + error?: string; + onDisable: (totpCode?: string, recoveryCode?: string) => void; + onCancel: () => void; +} + +export function MfaDisableDialog({ + isOpen, + isSubmitting, + error, + onDisable, + onCancel, +}: MfaDisableDialogProps) { + const [totpCode, setTotpCode] = useState(""); + const [recoveryCode, setRecoveryCode] = useState(""); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onDisable(useRecoveryCode ? undefined : totpCode, useRecoveryCode ? recoveryCode : undefined); + }; + + const handleCancel = () => { + setTotpCode(""); + setRecoveryCode(""); + setUseRecoveryCode(false); + onCancel(); + }; + + const handleSwitchToRecoveryCode = () => { + setUseRecoveryCode(true); + setTotpCode(""); + }; + + const handleSwitchToTotpCode = () => { + setUseRecoveryCode(false); + setRecoveryCode(""); + }; + + return ( + + + + Disable multi-factor authentication + +
+ {useRecoveryCode ? ( +
+ + Enter one of your recovery codes to disable MFA. + +
+ + setRecoveryCode(e.target.value)} + /> + +
+
+ +
+
+ ) : ( +
+ + Enter the code from your authenticator app to disable MFA. + +
+ setTotpCode(value)} + variant="large" + > + + + + + + + + + +
+
+ +
+
+ )} + + {error && {error}} + + + + + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx new file mode 100644 index 00000000000..3f5bb9ff367 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx @@ -0,0 +1,204 @@ +import { Form } from "@remix-run/react"; +import { DownloadIcon } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import { useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { CopyButton } from "~/components/primitives/CopyButton"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; +import { FormError } from "~/components/primitives/FormError"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP"; +import { Paragraph } from "~/components/primitives/Paragraph"; + +interface MfaSetupDialogProps { + isOpen: boolean; + setupData?: { + secret: string; + otpAuthUrl: string; + }; + recoveryCodes?: string[]; + error?: string; + isSubmitting: boolean; + onValidate: (code: string) => void; + onCancel: () => void; + onSaveRecoveryCodes: () => void; +} + +export function MfaSetupDialog({ + isOpen, + setupData, + recoveryCodes, + error, + isSubmitting, + onValidate, + onCancel, + onSaveRecoveryCodes, +}: MfaSetupDialogProps) { + const [totpCode, setTotpCode] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onValidate(totpCode); + setTotpCode(""); + }; + + const handleCancel = () => { + setTotpCode(""); + onCancel(); + }; + + const handleRecoverySubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSaveRecoveryCodes(); + }; + + const downloadRecoveryCodes = () => { + if (!recoveryCodes) return; + + const content = recoveryCodes.join("\n"); + const blob = new Blob([content], { 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); + }; + + // Show recovery codes if they exist + if (recoveryCodes && recoveryCodes.length > 0) { + return ( + + + + Recovery codes + +
+
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( + + {code} + + ))} +
+
+ + + Copy + +
+
+
+ + + + +
+
+
+ ); + } + + // Show QR setup if no recovery codes yet + if (!setupData) return null; + + return ( + + + + Enable authenticator app + +
+
+ + Scan the QR code below with your preferred authenticator app then enter the 6 digit + code that the app generates. Alternatively, you can copy the secret below and paste it + into your app. + + +
+
+ +
+ +
+ +
+ setTotpCode(value)} + variant="large" + name="totpCode" + onKeyDown={(e) => { + if (e.key === "Enter" && totpCode.length === 6) { + handleSubmit(e); + } + }} + > + + + + + + + + + +
+
+ +
{error && {error}}
+ + + + + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx new file mode 100644 index 00000000000..8fecc0a6493 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx @@ -0,0 +1,35 @@ +import { Form } from "@remix-run/react"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Switch } from "~/components/primitives/Switch"; + +interface MfaToggleProps { + isEnabled: boolean; + onToggle: (enabled: boolean) => void; +} + +export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) { + return ( +
+ + + + Enable an extra layer of security by requiring a one-time code from your authenticator + app (TOTP) each time you log in. + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx new file mode 100644 index 00000000000..33800cb842f --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -0,0 +1,179 @@ +import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { redirectWithSuccessMessage, redirectWithErrorMessage, typedJsonWithSuccessMessage } from "~/models/message.server"; +import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; +import { requireUserId } from "~/services/session.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { useMfaSetup } from "./useMfaSetup"; +import { MfaToggle } from "./MfaToggle"; +import { MfaSetupDialog } from "./MfaSetupDialog"; +import { MfaDisableDialog } from "./MfaDisableDialog"; + +const formSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("enable-mfa"), + }), + z.object({ + action: z.literal("disable-mfa"), + totpCode: z.string().optional(), + recoveryCode: z.string().optional(), + }), + z.object({ + action: z.literal("saved-recovery-codes"), + }), + z.object({ + action: z.literal("cancel-totp"), + }), + z.object({ + action: z.literal("validate-totp"), + totpCode: z.string().length(6, "TOTP code must be 6 digits"), + }), +]); + +function validateForm(formData: FormData) { + const formEntries = Object.fromEntries(formData.entries()); + + const result = formSchema.safeParse(formEntries); + + if (!result.success) { + return { + valid: false as const, + errors: result.error.flatten().fieldErrors, + }; + } + + return { + valid: true as const, + data: result.data, + }; +} + +export async function action({ request }: ActionFunctionArgs) { + try { + const userId = await requireUserId(request); + + const formData = await request.formData(); + + const submission = validateForm(formData); + + if (!submission.valid) { + return typedjson({ + action: "invalid-form" as const, + errors: submission.errors, + }); + } + + const mfaSetupService = new MultiFactorAuthenticationService(); + + switch (submission.data.action) { + case "enable-mfa": { + const result = await mfaSetupService.enableTotp(userId); + + return typedjson({ + action: "enable-mfa" as const, + secret: result.secret, + otpAuthUrl: result.otpAuthUrl, + }); + } + case "disable-mfa": { + const result = await mfaSetupService.disableTotp(userId, { + totpCode: submission.data.totpCode, + recoveryCode: submission.data.recoveryCode, + }); + + if (result.success) { + return typedJsonWithSuccessMessage( + { + action: "disable-mfa" as const, + success: true as const, + }, + request, + "Successfully disabled MFA" + ); + } else { + return typedjson({ + action: "disable-mfa" as const, + success: false as const, + error: "Invalid code provided. Please try again.", + }); + } + } + case "validate-totp": { + const result = await mfaSetupService.validateTotpSetup(userId, submission.data.totpCode); + + if (result.success) { + return typedjson({ + action: "validate-totp" as const, + success: true as const, + recoveryCodes: result.recoveryCodes, + }); + } else { + return typedjson({ + action: "validate-totp" as const, + success: false as const, + error: "Invalid code provided. Please try again.", + otpAuthUrl: result.otpAuthUrl, + secret: result.secret, + }); + } + } + case "cancel-totp": { + return typedjson({ + action: "cancel-totp" as const, + success: true as const, + }); + } + case "saved-recovery-codes": { + return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA"); + } + } + } catch (error) { + if (error instanceof ServiceValidationError) { + return redirectWithErrorMessage("/account/security", request, error.message); + } + + // Re-throw unexpected errors + throw error; + } +} + +export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { + const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = useMfaSetup(isEnabled); + + const handleToggle = (enabled: boolean) => { + if (enabled && !state.isEnabled) { + actions.enableMfa(); + } else if (!enabled && state.isEnabled) { + actions.openDisableDialog(); + } + }; + + return ( + <> + + + + + + + ); +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts new file mode 100644 index 00000000000..0a69d44617f --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts @@ -0,0 +1,302 @@ +import { useReducer, useEffect } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { action } from "./route"; + +export type MfaPhase = 'idle' | 'enabling' | 'validating' | 'showing-recovery' | 'disabling'; + +export interface MfaState { + phase: MfaPhase; + isEnabled: boolean; + setupData?: { + secret: string; + otpAuthUrl: string; + }; + recoveryCodes?: string[]; + error?: string; + isSubmitting: boolean; + disableMethod: 'totp' | 'recovery'; +} + +export type MfaAction = + | { type: 'ENABLE_MFA' } + | { type: 'SETUP_DATA_RECEIVED'; setupData: { secret: string; otpAuthUrl: string } } + | { type: 'CANCEL_SETUP' } + | { type: 'VALIDATE_TOTP'; code: string } + | { type: 'VALIDATION_SUCCESS'; recoveryCodes: string[] } + | { type: 'VALIDATION_FAILED'; error: string; setupData: { secret: string; otpAuthUrl: string } } + | { type: 'RECOVERY_CODES_SAVED' } + | { type: 'OPEN_DISABLE_DIALOG' } + | { type: 'DISABLE_MFA' } + | { type: 'DISABLE_SUCCESS' } + | { type: 'DISABLE_FAILED'; error: string } + | { type: 'CANCEL_DISABLE' } + | { type: 'SET_DISABLE_METHOD'; method: 'totp' | 'recovery' } + | { type: 'SET_ERROR'; error: string } + | { type: 'CLEAR_ERROR' } + | { type: 'SET_SUBMITTING'; isSubmitting: boolean }; + +function mfaReducer(state: MfaState, action: MfaAction): MfaState { + switch (action.type) { + case 'ENABLE_MFA': + return { + ...state, + phase: 'enabling', + isSubmitting: true, + error: undefined, + }; + + case 'SETUP_DATA_RECEIVED': + return { + ...state, + phase: 'enabling', + setupData: action.setupData, + error: undefined, + isSubmitting: false, + }; + + case 'CANCEL_SETUP': + return { + ...state, + phase: 'idle', + setupData: undefined, + error: undefined, + isSubmitting: false, + }; + + case 'VALIDATE_TOTP': + return { + ...state, + phase: 'validating', + isSubmitting: true, + error: undefined, + }; + + case 'VALIDATION_SUCCESS': + return { + ...state, + phase: 'showing-recovery', + recoveryCodes: action.recoveryCodes, + isSubmitting: false, + isEnabled: true, + error: undefined, + }; + + case 'VALIDATION_FAILED': + return { + ...state, + phase: 'enabling', + setupData: action.setupData, + error: action.error, + isSubmitting: false, + }; + + case 'RECOVERY_CODES_SAVED': + return { + ...state, + phase: 'idle', + setupData: undefined, + recoveryCodes: undefined, + isSubmitting: false, + }; + + case 'OPEN_DISABLE_DIALOG': + return { + ...state, + phase: 'disabling', + error: undefined, + isSubmitting: false, + }; + + case 'DISABLE_MFA': + return { + ...state, + isSubmitting: true, + error: undefined, + }; + + case 'DISABLE_SUCCESS': + return { + ...state, + phase: 'idle', + isEnabled: false, + error: undefined, + isSubmitting: false, + }; + + case 'DISABLE_FAILED': + return { + ...state, + error: action.error, + isSubmitting: false, + }; + + case 'CANCEL_DISABLE': + return { + ...state, + phase: 'idle', + error: undefined, + isSubmitting: false, + }; + + case 'SET_DISABLE_METHOD': + return { + ...state, + disableMethod: action.method, + error: undefined, + }; + + case 'SET_ERROR': + return { + ...state, + error: action.error, + }; + + case 'CLEAR_ERROR': + return { + ...state, + error: undefined, + }; + + case 'SET_SUBMITTING': + return { + ...state, + isSubmitting: action.isSubmitting, + }; + + default: + return state; + } +} + +export function useMfaSetup(initialIsEnabled: boolean) { + const fetcher = useTypedFetcher(); + + const [state, dispatch] = useReducer(mfaReducer, { + phase: 'idle', + isEnabled: initialIsEnabled, + isSubmitting: false, + disableMethod: 'totp', + }); + + // Handle fetcher responses + useEffect(() => { + if (fetcher.data) { + const { data } = fetcher; + + switch (data.action) { + case 'enable-mfa': + dispatch({ + type: 'SETUP_DATA_RECEIVED', + setupData: { secret: data.secret, otpAuthUrl: data.otpAuthUrl } + }); + break; + + case 'validate-totp': + if (data.success) { + dispatch({ + type: 'VALIDATION_SUCCESS', + recoveryCodes: data.recoveryCodes || [] + }); + } else { + dispatch({ + type: 'VALIDATION_FAILED', + error: data.error || 'Invalid code', + setupData: { secret: data.secret!, otpAuthUrl: data.otpAuthUrl! } + }); + } + break; + + case 'disable-mfa': + if (data.success) { + dispatch({ type: 'DISABLE_SUCCESS' }); + } else { + dispatch({ + type: 'DISABLE_FAILED', + error: data.error || 'Failed to disable MFA' + }); + } + break; + + case 'cancel-totp': + dispatch({ type: 'CANCEL_SETUP' }); + break; + } + } + }, [fetcher.data]); + + // Handle submitting state + useEffect(() => { + dispatch({ type: 'SET_SUBMITTING', isSubmitting: fetcher.state === 'submitting' }); + }, [fetcher.state]); + + const actions = { + enableMfa: () => { + dispatch({ type: 'ENABLE_MFA' }); + fetcher.submit( + { action: 'enable-mfa' }, + { method: 'POST', action: '/resources/account/mfa/setup' } + ); + }, + + cancelSetup: () => { + dispatch({ type: 'CANCEL_SETUP' }); + fetcher.submit( + { action: 'cancel-totp' }, + { method: 'POST', action: '/resources/account/mfa/setup' } + ); + }, + + validateTotp: (code: string) => { + dispatch({ type: 'VALIDATE_TOTP', code }); + fetcher.submit( + { action: 'validate-totp', totpCode: code }, + { method: 'POST', action: '/resources/account/mfa/setup' } + ); + }, + + saveRecoveryCodes: () => { + dispatch({ type: 'RECOVERY_CODES_SAVED' }); + fetcher.submit( + { action: 'saved-recovery-codes' }, + { method: 'POST', action: '/resources/account/mfa/setup' } + ); + }, + + openDisableDialog: () => { + dispatch({ type: 'OPEN_DISABLE_DIALOG' }); + }, + + disableMfa: (totpCode?: string, recoveryCode?: string) => { + dispatch({ type: 'DISABLE_MFA' }); + const formData: Record = { action: 'disable-mfa' }; + if (totpCode) formData.totpCode = totpCode; + if (recoveryCode) formData.recoveryCode = recoveryCode; + + fetcher.submit( + formData, + { method: 'POST', action: '/resources/account/mfa/setup' } + ); + }, + + cancelDisable: () => { + dispatch({ type: 'CANCEL_DISABLE' }); + }, + + setDisableMethod: (method: 'totp' | 'recovery') => { + dispatch({ type: 'SET_DISABLE_METHOD', method }); + }, + + clearError: () => { + dispatch({ type: 'CLEAR_ERROR' }); + }, + }; + + return { + state, + actions, + // Computed properties for easier access + isQrDialogOpen: (state.phase === 'enabling' && !!state.setupData) || (state.phase === 'showing-recovery' && !!state.recoveryCodes), + isRecoveryDialogOpen: false, // Recovery is now handled within the setup dialog + isDisableDialogOpen: state.phase === 'disabling', + }; +} \ No newline at end of file diff --git a/apps/webapp/app/routes/storybook.switch/route.tsx b/apps/webapp/app/routes/storybook.switch/route.tsx index d4db97f6f3a..63b49922c8a 100644 --- a/apps/webapp/app/routes/storybook.switch/route.tsx +++ b/apps/webapp/app/routes/storybook.switch/route.tsx @@ -6,6 +6,7 @@ export default function Story() { + diff --git a/apps/webapp/app/services/emailAuth.server.tsx b/apps/webapp/app/services/emailAuth.server.tsx index e48d405dd28..ffbebe22241 100644 --- a/apps/webapp/app/services/emailAuth.server.tsx +++ b/apps/webapp/app/services/emailAuth.server.tsx @@ -6,6 +6,7 @@ import { env } from "~/env.server"; import { sendMagicLinkEmail } from "~/services/email.server"; import { postAuthentication } from "./postAuth.server"; import { logger } from "./logger.server"; +import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server"; let secret = env.MAGIC_LINK_SECRET; if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable."); @@ -36,8 +37,19 @@ const emailStrategy = new EmailLinkStrategy( await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" }); + // Check if user has MFA enabled + if (user.mfaEnabledAt) { + // Throw a special error that will be caught by the magic route + throw new MfaRequiredError(user.id); + } + return { userId: user.id }; } catch (error) { + // Skip logging the error if it's a MfaRequiredError + if (error instanceof MfaRequiredError) { + throw error; + } + logger.debug("Magic link user failed to authenticate", { error: JSON.stringify(error) }); throw error; } diff --git a/apps/webapp/app/services/gitHubAuth.server.ts b/apps/webapp/app/services/gitHubAuth.server.ts index ff9e736d64b..8c2dbef460a 100644 --- a/apps/webapp/app/services/gitHubAuth.server.ts +++ b/apps/webapp/app/services/gitHubAuth.server.ts @@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; import { postAuthentication } from "./postAuth.server"; import { logger } from "./logger.server"; +import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server"; export function addGitHubStrategy( authenticator: Authenticator, @@ -40,10 +41,21 @@ export function addGitHubStrategy( await postAuthentication({ user, isNewUser, loginMethod: "GITHUB" }); + // Check if user has MFA enabled + if (user.mfaEnabledAt) { + // Throw a special error that will be caught by the callback route + throw new MfaRequiredError(user.id); + } + return { userId: user.id, }; } catch (error) { + // Skip logging the error if it's a MfaRequiredError + if (error instanceof MfaRequiredError) { + throw error; + } + console.error(error); throw error; } diff --git a/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts b/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts new file mode 100644 index 00000000000..cbf15c157e9 --- /dev/null +++ b/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts @@ -0,0 +1,48 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "~/env.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +export const mfaRateLimiter = singleton("mfaRateLimiter", initializeMfaRateLimiter); + +function initializeMfaRateLimiter() { + const redisClient = createRedisRateLimitClient({ + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }); + + return new RateLimiter({ + redisClient, + keyPrefix: "mfa:validation", + limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 attempts per minute + logSuccess: false, // Don't log successful attempts for privacy + logFailure: true, // Log rate limit violations for security monitoring + }); +} + +export class MfaRateLimitError extends Error { + public readonly retryAfter: number; + + constructor(retryAfter: number) { + super(`MFA validation rate limit exceeded.`); + this.retryAfter = retryAfter; + } +} + +/** + * Check if the user can attempt MFA validation + * @param userId - The user ID to rate limit + * @throws {MfaRateLimitError} If rate limit is exceeded + */ +export async function checkMfaRateLimit(userId: string): Promise { + const result = await mfaRateLimiter.limit(userId); + + if (!result.success) { + const retryAfter = new Date(result.reset).getTime() - Date.now(); + throw new MfaRateLimitError(retryAfter); + } +} diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts new file mode 100644 index 00000000000..3105e45340f --- /dev/null +++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts @@ -0,0 +1,387 @@ +import { SecretReference, User, type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { createRandomStringGenerator } from "@better-auth/utils/random"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { createHash } from "@better-auth/utils/hash"; +import { createOTP } from "@better-auth/utils/otp"; +import { base32 } from "@better-auth/utils/base32"; +import { z } from "zod"; +import { scheduleEmail } from "../email.server"; + +const generateRandomString = createRandomStringGenerator("A-Z", "0-9"); + +const SecretSchema = z.object({ + secret: z.string(), +}); + +export class MfaRequiredError extends Error { + public readonly userId: string; + + constructor(userId: string) { + super(`MFA is required for user ${userId}`); + this.userId = userId; + } +} + +export class MultiFactorAuthenticationService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) { + const user = await this.#prismaClient.user.findFirst({ + where: { id: userId }, + include: { + mfaSecretReference: true, + }, + }); + + if (!user) { + return { + success: false, + }; + } + + if (!user.mfaEnabledAt) { + return { + success: false, + }; + } + + if (!user.mfaSecretReference) { + return { + success: false, + }; + } + + // validate the TOTP code + const secretStore = getSecretStore(user.mfaSecretReference.provider); + const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key); + + if (!secretResult) { + return { + success: false, + }; + } + + const isValid = await this.#verifyTotpCodeOrRecoveryCode( + user, + user.mfaSecretReference, + params.totpCode, + params.recoveryCode + ); + + if (!isValid) { + return { + success: false, + }; + } + + // Delete the MFA secret + await secretStore.deleteSecret(user.mfaSecretReference.key); + + // Delete the MFA backup codes + await this.#prismaClient.mfaBackupCode.deleteMany({ + where: { + userId, + }, + }); + + await this.#prismaClient.user.update({ + where: { id: userId }, + data: { + mfaEnabledAt: null, + mfaSecretReference: { + delete: true, + }, + }, + }); + + await scheduleEmail({ + email: "mfa-disabled", + to: user.email, + userEmail: user.email, + }); + + return { + success: true, + }; + } + + public async enableTotp(userId: string) { + const user = await this.#prismaClient.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + throw new ServiceValidationError("User not found"); + } + + const secretStore = getSecretStore("DATABASE"); + + // Generate a new secret + const secret = generateRandomString(24); + const secretKey = `mfa:${userId}:${generateRandomString(8)}`; + + // Store the secret in the SecretStore + await secretStore.setSecret(secretKey, { + secret, + }); + + // Update the user's secret reference to the secret store + await this.#prismaClient.user.update({ + where: { id: userId }, + data: { + mfaSecretReference: { + create: { + provider: "DATABASE", + key: secretKey, + }, + }, + }, + }); + + // Return the secret and the recovery codes + const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email); + + const displaySecret = base32.encode(secret, { + padding: false, + }); + + return { + secret: displaySecret, + otpAuthUrl, + }; + } + + public async validateTotpSetup(userId: string, totpCode: string) { + const user = await this.#prismaClient.user.findFirst({ + where: { id: userId }, + include: { + mfaSecretReference: true, + }, + }); + + if (!user) { + throw new ServiceValidationError("User not found"); + } + + if (!user.mfaSecretReference) { + throw new ServiceValidationError("User has not enabled MFA"); + } + + const secretStore = getSecretStore(user.mfaSecretReference.provider); + const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key); + + if (!secretResult) { + throw new ServiceValidationError("User has not enabled MFA"); + } + + const secret = secretResult.secret; + + const otp = createOTP(secret, { + digits: 6, + period: 30, + }); + + const isValid = await otp.verify(totpCode); + + if (!isValid) { + // Return the secret and the recovery codes + const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email); + + const displaySecret = base32.encode(secret, { + padding: false, + }); + + return { + success: false, + otpAuthUrl, + secret: displaySecret, + }; + } + + // Now that we've validated the TOTP code, we can enable MFA for the user + await this.#prismaClient.user.update({ + where: { id: userId }, + data: { + mfaEnabledAt: new Date(), + }, + }); + + // Generate a new set of recovery codes + const recoveryCodes = Array.from({ length: 9 }, () => generateRandomString(16, "a-z", "0-9")); + + // Delete any existing recovery codes + await this.#prismaClient.mfaBackupCode.deleteMany({ + where: { + userId, + }, + }); + + // Hash and store the recovery codes + for (const code of recoveryCodes) { + const hashedCode = await createHash("SHA-512", "hex").digest(code); + await this.#prismaClient.mfaBackupCode.create({ + data: { + userId, + code: hashedCode, + }, + }); + } + + await scheduleEmail({ + email: "mfa-enabled", + to: user.email, + userEmail: user.email, + }); + + return { + success: true, + recoveryCodes, + }; + } + + async #verifyTotpCodeOrRecoveryCode( + user: User, + secretReference: SecretReference, + totpCode?: string, + recoveryCode?: string + ) { + if (!totpCode && !recoveryCode) { + return false; + } + + if (typeof totpCode === "string" && totpCode.length === 6) { + return this.#verifyTotpCode(user, secretReference, totpCode); + } + + if (typeof recoveryCode === "string") { + return this.#verifyRecoveryCode(user, recoveryCode); + } + + return false; + } + + async #verifyTotpCode(user: User, secretReference: SecretReference, totpCode: string) { + const secretStore = getSecretStore(secretReference.provider); + const secretResult = await secretStore.getSecret(SecretSchema, secretReference.key); + + if (!secretResult) { + return false; + } + + const secret = secretResult.secret; + + const isValid = await createOTP(secret, { + digits: 6, + period: 30, + }).verify(totpCode); + + return isValid; + } + + async #verifyRecoveryCode(user: User, recoveryCode: string) { + const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode); + + const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({ + where: { userId: user.id, code: hashedCode, usedAt: null }, + }); + + return !!backupCode; + } + + // Public methods for login flow with security measures + public async verifyTotpForLogin(userId: string, totpCode: string) { + const user = await this.#prismaClient.user.findFirst({ + where: { id: userId }, + include: { + mfaSecretReference: true, + }, + }); + + if (!user || !user.mfaEnabledAt || !user.mfaSecretReference) { + return { + success: false, + error: "Invalid authentication code", + }; + } + + // Check for replay attack - if this code was already used + const hashedCode = await createHash("SHA-512", "hex").digest(totpCode); + if (user.mfaLastUsedCode === hashedCode) { + return { + success: false, + error: "Invalid authentication code", + }; + } + + // Verify the TOTP code + const isValid = await this.#verifyTotpCode(user, user.mfaSecretReference, totpCode); + + if (!isValid) { + return { + success: false, + error: "Invalid authentication code", + }; + } + + // Mark this code as used to prevent replay + await this.#prismaClient.user.update({ + where: { id: userId }, + data: { + mfaLastUsedCode: hashedCode, + }, + }); + + return { + success: true, + }; + } + + public async verifyRecoveryCodeForLogin(userId: string, recoveryCode: string) { + const user = await this.#prismaClient.user.findFirst({ + where: { id: userId }, + }); + + if (!user || !user.mfaEnabledAt) { + return { + success: false, + error: "Invalid authentication code", + }; + } + + const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode); + + // Find an unused recovery code + const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({ + where: { + userId: user.id, + code: hashedCode, + usedAt: null, + }, + }); + + if (!backupCode) { + return { + success: false, + error: "Invalid authentication code", + }; + } + + // Mark this recovery code as used + await this.#prismaClient.mfaBackupCode.update({ + where: { id: backupCode.id }, + data: { + usedAt: new Date(), + }, + }); + + return { + success: true, + }; + } +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 55cbd06b5a3..70450afb694 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -53,6 +53,7 @@ export async function requireUser(request: Request) { updatedAt: user.updatedAt, dashboardPreferences: user.dashboardPreferences, confirmedBasicDetails: user.confirmedBasicDetails, + mfaEnabledAt: user.mfaEnabledAt, isImpersonating: !!impersonationId, }; } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4a48b3a4b82..c4a4247ac1d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -60,6 +60,10 @@ export function personalAccessTokensPath() { return `/account/tokens`; } +export function accountSecurityPath() { + return `/account/security`; +} + export function invitesPath() { return `/invites`; } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 6d0e2b24075..371ad770cc9 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -36,6 +36,7 @@ "@aws-sdk/client-ecr": "^3.839.0", "@aws-sdk/client-sqs": "^3.445.0", "@aws-sdk/client-sts": "^3.840.0", + "@better-auth/utils": "^0.2.6", "@codemirror/autocomplete": "^6.3.1", "@codemirror/commands": "^6.1.2", "@codemirror/lang-javascript": "^6.1.1", @@ -139,6 +140,7 @@ "graphile-worker": "0.16.6", "highlight.run": "^7.3.4", "humanize-duration": "^3.27.3", + "input-otp": "^1.4.2", "intl-parse-accept-language": "^1.0.0", "ioredis": "^5.3.2", "isbot": "^3.6.5", @@ -160,6 +162,7 @@ "prism-react-renderer": "^2.3.1", "prismjs": "^1.30.0", "prom-client": "^15.1.0", + "qrcode.react": "^4.2.0", "random-words": "^2.0.0", "react": "^18.2.0", "react-aria": "^3.31.1", diff --git a/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql b/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql new file mode 100644 index 00000000000..7aef24a4cba --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql @@ -0,0 +1,26 @@ + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mfaEnabledAt" TIMESTAMP(3), +ADD COLUMN "mfaLastUsedCode" TEXT, +ADD COLUMN "mfaSecretReferenceId" TEXT; + +-- CreateTable +CREATE TABLE "MfaBackupCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MfaBackupCode_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MfaBackupCode_userId_code_key" ON "MfaBackupCode"("userId", "code"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_mfaSecretReferenceId_fkey" FOREIGN KEY ("mfaSecretReferenceId") REFERENCES "SecretReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MfaBackupCode" ADD CONSTRAINT "MfaBackupCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3143b089f22..86bcf07f480 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -46,10 +46,33 @@ model User { orgMemberships OrgMember[] sentInvites OrgMemberInvite[] + mfaEnabledAt DateTime? + mfaSecretReference SecretReference? @relation(fields: [mfaSecretReferenceId], references: [id]) + mfaSecretReferenceId String? + /// Hash of the last used code to prevent replay attacks + mfaLastUsedCode String? + invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? personalAccessTokens PersonalAccessToken[] deployments WorkerDeployment[] + backupCodes MfaBackupCode[] +} + +model MfaBackupCode { + id String @id @default(cuid()) + /// Hash of the actual code + code String + + user User @relation(fields: [userId], references: [id]) + userId String + + usedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, code]) } // @deprecated This model is no longer used as the Cloud is out of private beta @@ -346,6 +369,7 @@ model SecretReference { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt OrganizationIntegration OrganizationIntegration[] + User User[] } enum SecretStoreProvider { diff --git a/internal-packages/emails/emails/mfa-disabled.tsx b/internal-packages/emails/emails/mfa-disabled.tsx new file mode 100644 index 00000000000..a9d4d03fa19 --- /dev/null +++ b/internal-packages/emails/emails/mfa-disabled.tsx @@ -0,0 +1,48 @@ +import { Body, Container, Head, Html, Preview, Text } from "@react-email/components"; +import { Footer } from "./components/Footer"; +import { Image } from "./components/Image"; +import { container, h1, main, paragraphLight } from "./components/styles"; +import { z } from "zod"; + +export const MfaDisabledEmailSchema = z.object({ + email: z.literal("mfa-disabled"), + userEmail: z.string(), +}); + +type MfaDisabledEmailProps = z.infer; + +const previewDefaults: MfaDisabledEmailProps = { + email: "mfa-disabled", + userEmail: "user@example.com", +}; + +export default function Email(props: MfaDisabledEmailProps) { + const { userEmail } = { + ...previewDefaults, + ...props, + }; + + return ( + + + Multi-factor authentication disabled + + + Multi-factor authentication disabled + Hi there, + + You have successfully disabled multi-factor authentication (MFA) for your Trigger.dev + account ({userEmail}). Your account no longer has the additional security layer provided + by MFA. + + + You can re-enable MFA at any time from your account security page. If you didn't disable + MFA, please contact our support team immediately. + + Trigger.dev +