From 082e11d603dc503015722b029199ad4b08f58649 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 19 Sep 2025 18:39:04 -0300 Subject: [PATCH] Improve 2FA flow --- backend/src/server/routes/v1/user-router.ts | 4 +- backend/src/server/routes/v2/mfa-router.ts | 61 +++++++ .../src/services/auth/auth-login-service.ts | 20 ++- backend/src/services/auth/auth-login-type.ts | 1 + backend/src/services/totp/totp-service.ts | 15 +- frontend/src/components/auth/Mfa.tsx | 157 +++++++++++------- .../components/mfa/RecoveryCodesDownload.tsx | 120 +++++++++++++ .../src/components/mfa/TotpRegistration.tsx | 87 ++++++---- frontend/src/hooks/api/auth/queries.tsx | 7 + frontend/src/hooks/api/users/mutation.tsx | 13 +- 10 files changed, 379 insertions(+), 106 deletions(-) create mode 100644 frontend/src/components/mfa/RecoveryCodesDownload.tsx diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 7ef2e0d33..40fbcf5a2 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -255,7 +255,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { totp: z.string() }), response: { - 200: z.object({}) + 200: z.object({ + recoveryCodes: z.string().array() + }) } }, onRequest: verifyAuth([AuthMode.JWT], { diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index d8a57d29a..477e5e1be 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -143,4 +143,65 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + url: "/mfa/verify/recovery-code", + method: "POST", + config: { + rateLimit: mfaRateLimit + }, + schema: { + body: z.object({ + recoveryCode: z.string().trim().length(8, "Recovery code must be 8 characters") + }), + response: { + 200: z.object({ + encryptionVersion: z.number().default(1).nullable().optional(), + protectedKey: z.string().nullish(), + protectedKeyIV: z.string().nullish(), + protectedKeyTag: z.string().nullish(), + publicKey: z.string().nullish(), + encryptedPrivateKey: z.string().nullish(), + iv: z.string().nullish(), + tag: z.string().nullish(), + token: z.string() + }) + } + }, + handler: async (req, res) => { + const userAgent = req.headers["user-agent"]; + const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); + if (!userAgent) throw new Error("user agent header is required"); + if (!mfaJwtToken) throw new Error("authorization header is required"); + const appCfg = getConfig(); + + const { user, token } = await server.services.login.verifyMfaToken({ + userAgent, + mfaJwtToken, + ip: req.realIp, + userId: req.mfa.userId, + orgId: req.mfa.orgId, + mfaToken: req.body.recoveryCode, + mfaMethod: MfaMethod.TOTP, + isRecoveryCode: true + }); + + void res.setCookie("jid", token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + addAuthOriginDomainCookie(res); + + return { + ...user, + token: token.access, + protectedKey: user.protectedKey || null, + protectedKeyIV: user.protectedKeyIV || null, + protectedKeyTag: user.protectedKeyTag || null + }; + } + }); }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 2a680b9b8..16c1c60ea 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -684,7 +684,8 @@ export const authLoginServiceFactory = ({ mfaJwtToken, ip, userAgent, - orgId + orgId, + isRecoveryCode = false }: TVerifyMfaTokenDTO) => { const appCfg = getConfig(); const user = await userDAL.findById(userId); @@ -698,16 +699,21 @@ export const authLoginServiceFactory = ({ code: mfaToken }); } else if (mfaMethod === MfaMethod.TOTP) { - if (mfaToken.length === 6) { - await totpService.verifyUserTotp({ - userId, - totp: mfaToken - }); - } else { + if (isRecoveryCode) { await totpService.verifyWithUserRecoveryCode({ userId, recoveryCode: mfaToken }); + } else { + if (mfaToken.length !== 6) { + throw new BadRequestError({ + message: "Invalid TOTP code. Please use a valid recovery code." + }); + } + await totpService.verifyUserTotp({ + userId, + totp: mfaToken + }); } } } catch (err) { diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 09d81033f..9b010a860 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = { ip: string; userAgent: string; orgId?: string; + isRecoveryCode?: boolean; }; export type TOauthLoginDTO = { diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts index 591a66ed6..193a27d90 100644 --- a/backend/src/services/totp/totp-service.ts +++ b/backend/src/services/totp/totp-service.ts @@ -131,15 +131,20 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp secret }); - if (isValid) { - await totpConfigDAL.updateById(totpConfig.id, { - isVerified: true - }); - } else { + if (!isValid) { throw new BadRequestError({ message: "Invalid TOTP token" }); } + + await totpConfigDAL.updateById(totpConfig.id, { + isVerified: true + }); + + const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(","); + return { + recoveryCodes + }; }; const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => { diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 64c8a5ee6..6eab69098 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -6,9 +6,9 @@ import { t } from "i18next"; import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Input } from "@app/components/v2"; +import { Button } from "@app/components/v2"; import { useSendMfaToken } from "@app/hooks/api"; -import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries"; +import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; // The style for the verification code input @@ -17,10 +17,10 @@ const codeInputProps = { fontFamily: "monospace", margin: "4px", MozAppearance: "textfield", - width: "48px", + width: "55px", borderRadius: "5px", fontSize: "24px", - height: "48px", + height: "55px", paddingLeft: "7", backgroundColor: "#0d1117", color: "white", @@ -60,6 +60,7 @@ type Props = { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => { const [mfaCode, setMfaCode] = useState(""); + const [showRecoveryCodeInput, setShowRecoveryCodeInput] = useState(false); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); @@ -82,16 +83,24 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop const verifyMfa = async (event: React.FormEvent) => { event.preventDefault(); + if (!mfaCode.trim()) return; + setIsLoading(true); try { - const { token } = await verifyMfaToken({ - email, - mfaCode, - mfaMethod: method - }); + let result; + + if (method === MfaMethod.TOTP && showRecoveryCodeInput) { + result = await verifyRecoveryCode(mfaCode.trim()); + } else { + result = await verifyMfaToken({ + email, + mfaCode: mfaCode.trim(), + mfaMethod: method + }); + } SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); + SecurityClient.setToken(result.token); await successCallback(); if (closeMfa) { @@ -147,7 +156,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } return ( -
+
{!hideLogo && (
@@ -162,76 +171,106 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop )} {method === MfaMethod.TOTP && ( - <> -

- Authenticator MFA Required +

+

Two-Factor Authentication

+

+ {showRecoveryCodeInput + ? "Enter one of your backup recovery codes" + : "Enter the verification code from your authenticator app"}

-

- Open the authenticator app on your mobile device to get your verification code or enter - a recovery code. -

- +
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
{typeof triesLeft === "number" && ( )} -
-
- -
+
+
{method === MfaMethod.TOTP && ( -
+
+ - + Lost your recovery codes? Reset your account diff --git a/frontend/src/components/mfa/RecoveryCodesDownload.tsx b/frontend/src/components/mfa/RecoveryCodesDownload.tsx new file mode 100644 index 000000000..52739df97 --- /dev/null +++ b/frontend/src/components/mfa/RecoveryCodesDownload.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Modal, ModalContent } from "../v2"; + +type Props = { + isOpen: boolean; + onClose: () => void; + recoveryCodes: string[]; + onDownloadComplete: () => void; +}; + +export const RecoveryCodesDownload = ({ + isOpen, + onClose, + recoveryCodes, + onDownloadComplete +}: Props) => { + const [hasDownloaded, setHasDownloaded] = useState(false); + const [copied, setCopied] = useState(false); + + const downloadRecoveryCodes = () => { + const content = [ + "Infisical Two-Factor Authentication Recovery Codes", + `Generated on: ${new Date().toLocaleString()}`, + "", + "Important: Store these codes in a safe place. Each code can only be used once.", + "If you lose access to your mobile authenticator, you can use these codes to regain access to your account.", + "", + "Recovery Codes:", + ...recoveryCodes.map((code, index) => `${index + 1}. ${code}`) + ].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 = `infisical-recovery-codes-${new Date().toISOString().split("T")[0]}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setHasDownloaded(true); + }; + + const copyToClipboard = async () => { + const text = recoveryCodes.join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy recovery codes:", err); + } + }; + + const handleClose = () => { + if (hasDownloaded) { + onDownloadComplete(); + onClose(); + } + }; + + return ( + {}}> + +
+
+ Save these codes securely. Each can only be used once. +
+ +
+
+ {recoveryCodes.map((code, index) => ( +
+ {index + 1}. + {code} +
+ ))} +
+
+ +
+ + + +
+ + {hasDownloaded ? ( +

+ Recovery codes downloaded. You can now close this modal. +

+ ) : ( +

+ Download the recovery codes to continue. +

+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx index b6e2ebe2a..cd5189d25 100644 --- a/frontend/src/components/mfa/TotpRegistration.tsx +++ b/frontend/src/components/mfa/TotpRegistration.tsx @@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation"; import { createNotification } from "../notifications"; import { Button, ContentLoader, Input } from "../v2"; +import { RecoveryCodesDownload } from "./RecoveryCodesDownload"; type Props = { onComplete?: () => Promise; @@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { useVerifyUserTotpRegistration(); const [qrCodeUrl, setQrCodeUrl] = useState(""); const [totp, setTotp] = useState(""); + const [showRecoveryModal, setShowRecoveryModal] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState([]); const handleTotpVerify = async (event: React.FormEvent) => { event.preventDefault(); - await verifyUserTotp({ - totp - }); + try { + const result = await verifyUserTotp({ + totp + }); - createNotification({ - text: "Successfully configured mobile authenticator", - type: "success" - }); + createNotification({ + text: "Successfully configured mobile authenticator", + type: "success" + }); + if (result.recoveryCodes) { + setRecoveryCodes(result.recoveryCodes); + setShowRecoveryModal(true); + } else if (onComplete) { + onComplete(); + } + } catch { + createNotification({ + text: "Failed to verify TOTP code", + type: "error" + }); + } + }; + + const handleRecoveryDownloadComplete = async () => { + setShowRecoveryModal(false); if (onComplete) { - onComplete(); + await onComplete(); } }; @@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { } return ( -
-
- 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR - code. -
-
- registration-qr -
-
-
2. Enter the resulting verification code
-
- setTotp(e.target.value)} - value={totp} - placeholder="Verification code" - /> - + <> +
+
+ 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR + code.
- -
+
+ registration-qr +
+
+
2. Enter the resulting verification code
+
+ setTotp(e.target.value)} + value={totp} + placeholder="Verification code" + /> + +
+
+
+ + setShowRecoveryModal(false)} + recoveryCodes={recoveryCodes} + onDownloadComplete={handleRecoveryDownloadComplete} + /> + ); }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index d41a4d115..207980017 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -183,6 +183,13 @@ export const useVerifyMfaToken = () => { }); }; +export const verifyRecoveryCode = async (recoveryCode: string) => { + const { data } = await apiRequest.post("/api/v2/auth/mfa/verify/recovery-code", { + recoveryCode + }); + return data; +}; + export const verifySignupInvite = async (details: VerifySignupInviteDTO) => { const { data } = await apiRequest.post("/api/v1/invite-org/verify", details); return data; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 7acfb8fe0..10df1b8a9 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -77,13 +77,16 @@ export const useUpdateUserProjectFavorites = () => { }; export const useVerifyUserTotpRegistration = () => { - return useMutation({ + return useMutation<{ recoveryCodes: string[] }, unknown, { totp: string }>({ mutationFn: async ({ totp }: { totp: string }) => { - await apiRequest.post("/api/v1/user/me/totp/verify", { - totp - }); + const { data } = await apiRequest.post<{ recoveryCodes: string[] }>( + "/api/v1/user/me/totp/verify", + { + totp + } + ); - return {}; + return data; } }); };