From 082e11d603dc503015722b029199ad4b08f58649 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 19 Sep 2025 18:39:04 -0300 Subject: [PATCH 1/6] 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; } }); }; From e57cd54ead41d3d8e3b1b3a5187436a4250ac665 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sat, 20 Sep 2025 00:01:44 -0300 Subject: [PATCH 2/6] Address PR comments --- backend/src/server/routes/v2/mfa-router.ts | 112 ++++++++---------- .../src/services/auth/auth-login-service.ts | 2 +- .../src/components/mfa/TotpRegistration.tsx | 2 +- 3 files changed, 49 insertions(+), 67 deletions(-) diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 477e5e1be..e3e122a17 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -1,5 +1,7 @@ +import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -7,6 +9,49 @@ import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type"; +const handleMfaVerification = async ( + req: FastifyRequest & { mfa: { userId: string; orgId?: string; user: TUsers } }, + res: FastifyReply, + server: FastifyZodProvider, + mfaToken: string, + mfaMethod: MfaMethod, + isRecoveryCode?: boolean +) => { + 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, + mfaMethod, + isRecoveryCode + }); + + 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 + }; +}; + export const registerMfaRouter = async (server: FastifyZodProvider) => { const cfg = getConfig(); @@ -109,38 +154,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { } }, 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.mfaToken, - mfaMethod: req.body.mfaMethod - }); - - 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 - }; + return handleMfaVerification(req, res, server, req.body.mfaToken, req.body.mfaMethod); } }); @@ -169,39 +183,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { } }, 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 - }; + return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true); } }); }; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 16c1c60ea..d69c836e9 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -707,7 +707,7 @@ export const authLoginServiceFactory = ({ } else { if (mfaToken.length !== 6) { throw new BadRequestError({ - message: "Invalid TOTP code. Please use a valid recovery code." + message: "Please use a valid TOTP code." }); } await totpService.verifyUserTotp({ diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx index cd5189d25..0b79bfd98 100644 --- a/frontend/src/components/mfa/TotpRegistration.tsx +++ b/frontend/src/components/mfa/TotpRegistration.tsx @@ -35,7 +35,7 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { type: "success" }); - if (result.recoveryCodes) { + if (result.recoveryCodes && result.recoveryCodes.length > 0) { setRecoveryCodes(result.recoveryCodes); setShowRecoveryModal(true); } else if (onComplete) { From ba277a057e08f2021cf0d1618a8a6d2e8539a332 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 23 Sep 2025 09:27:14 -0300 Subject: [PATCH 3/6] Addressed PR comments --- backend/src/server/routes/v2/mfa-router.ts | 2 +- frontend/src/components/auth/Mfa.tsx | 37 ++++++++++++------- .../components/mfa/RecoveryCodesDownload.tsx | 15 ++------ 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index e3e122a17..e8c2cea69 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -56,7 +56,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { const cfg = getConfig(); server.decorateRequest("mfa", null); - server.addHook("preParsing", async (req, res) => { + server.addHook("preValidation", async (req, res) => { const authorizationHeader = req.headers.authorization; if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) { diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 6eab69098..d8ffc55d6 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -7,7 +7,7 @@ import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button } from "@app/components/v2"; -import { useSendMfaToken } from "@app/hooks/api"; +import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; @@ -66,6 +66,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop const [isLoadingResend, setIsLoadingResend] = useState(false); const [triesLeft, setTriesLeft] = useState(undefined); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); + const logout = useLogoutUser(true); const sendMfaToken = useSendMfaToken(); @@ -80,10 +81,18 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } }, []); + const getExpectedCodeLength = () => { + if (method === MfaMethod.EMAIL) return 6; + if (method === MfaMethod.TOTP) return showRecoveryCodeInput ? 8 : 6; + return 6; + }; + + const isCodeComplete = mfaCode.length === getExpectedCodeLength(); + const verifyMfa = async (event: React.FormEvent) => { event.preventDefault(); - if (!mfaCode.trim()) return; + if (!mfaCode.trim() || !isCodeComplete) return; setIsLoading(true); try { @@ -107,16 +116,18 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop closeMfa(); } } catch { - if (triesLeft) { - setTriesLeft((left) => { - if (triesLeft === 1) { - navigate({ to: "/" }); - - SecurityClient.setMfaToken(""); - SecurityClient.setToken(""); - } - return (left as number) - 1; - }); + if (typeof triesLeft === "number") { + const newTriesLeft = triesLeft - 1; + setTriesLeft(newTriesLeft); + if (newTriesLeft <= 0) { + setIsLoading(false); + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + SecurityClient.setSignupToken(""); + await logout.mutateAsync(); + navigate({ to: "/login" }); + return; + } } else { setTriesLeft(2); } @@ -251,6 +262,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop colorSchema="primary" variant="outline_bg" isLoading={isLoading} + isDisabled={!isCodeComplete} > {String(t("mfa.verify"))} @@ -263,7 +275,6 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop onClick={() => { setShowRecoveryCodeInput(!showRecoveryCodeInput); setMfaCode(""); - setTriesLeft(undefined); }} className="text-bunker-400 transition-colors duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4" > diff --git a/frontend/src/components/mfa/RecoveryCodesDownload.tsx b/frontend/src/components/mfa/RecoveryCodesDownload.tsx index 52739df97..50219f7dc 100644 --- a/frontend/src/components/mfa/RecoveryCodesDownload.tsx +++ b/frontend/src/components/mfa/RecoveryCodesDownload.tsx @@ -21,16 +21,7 @@ export const RecoveryCodesDownload = ({ 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 content = [...recoveryCodes].join("\n"); const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); @@ -75,8 +66,8 @@ export const RecoveryCodesDownload = ({
{recoveryCodes.map((code, index) => (
- {index + 1}. - {code} + {index + 1}. + {code}
))}
From d681594d25413bc526aab42da3c7d5348ba8da14 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 23 Sep 2025 10:01:43 -0300 Subject: [PATCH 4/6] Fix 'Lost your recovery codes?' flow with a better message to recover the account --- frontend/src/components/auth/Mfa.tsx | 38 +++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index d8ffc55d6..92e177ad2 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -6,7 +6,8 @@ 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 } from "@app/components/v2"; +import { Button, Tooltip } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; @@ -280,11 +281,36 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop > {showRecoveryCodeInput ? "Use authenticator code" : "Use a recovery code"} - - - Lost your recovery codes? Reset your account - - +
+ + {isInfisicalCloud() ? ( + <> +
Account Recovery Required
+
+ Contact support with valid proof of account ownership to initiate recovery +
+
support@infisical.com
+ + ) : ( + <> +
Account Recovery Required
+
+ Contact your instance administrator with valid proof of account ownership to + initiate recovery +
+ + )} +
+ } + > + + Lost your recovery codes? + + +
)} {method === MfaMethod.EMAIL && ( From a3391ac7f01c14d1fe848e9c29ed1a1326a3b591 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 23 Sep 2025 17:36:07 -0300 Subject: [PATCH 5/6] Fix redirect issue on locked users, improve lockout message --- frontend/src/components/auth/Mfa.tsx | 7 +++++- .../auth/SelectOrgPage/SelectOrgSection.tsx | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 92e177ad2..02ca0a5ad 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -5,6 +5,7 @@ import { t } from "i18next"; import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; +import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, Tooltip } from "@app/components/v2"; import { isInfisicalCloud } from "@app/helpers/platform"; @@ -121,6 +122,10 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop const newTriesLeft = triesLeft - 1; setTriesLeft(newTriesLeft); if (newTriesLeft <= 0) { + createNotification({ + text: "User is temporary locked due to multiple failed login attempts. Try again after 5 minutes. You can also reset your password now to proceed.", + type: "error" + }); setIsLoading(false); SecurityClient.setMfaToken(""); SecurityClient.setToken(""); @@ -263,7 +268,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop colorSchema="primary" variant="outline_bg" isLoading={isLoading} - isDisabled={!isCodeComplete} + isDisabled={!isCodeComplete || (typeof triesLeft === "number" && triesLeft <= 0)} > {String(t("mfa.verify"))} diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 4dd0bbe24..1fe0d881f 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -117,12 +117,28 @@ export const SelectOrganizationSection = () => { } } - const { token, isMfaEnabled, mfaMethod } = await selectOrg - .mutateAsync({ + let token; + let isMfaEnabled; + let mfaMethod; + + try { + const result = await selectOrg.mutateAsync({ organizationId: organization.id, userAgent: callbackPort ? UserAgentType.CLI : undefined - }) - .finally(() => setIsInitialOrgCheckLoading(false)); + }); + token = result.token; + isMfaEnabled = result.isMfaEnabled; + mfaMethod = result.mfaMethod; + } catch (error: any) { + setIsInitialOrgCheckLoading(false); + if (error?.response?.status === 403) { + await handleLogout(); + return; + } + throw error; + } finally { + setIsInitialOrgCheckLoading(false); + } await router.invalidate(); From 49d9bb99bcefe452cffb4dbcb25f1ed7e9630c1c Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 23 Sep 2025 17:44:23 -0300 Subject: [PATCH 6/6] Improve wording on toast message --- frontend/src/components/auth/Mfa.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 02ca0a5ad..efc33d52b 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -123,7 +123,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop setTriesLeft(newTriesLeft); if (newTriesLeft <= 0) { createNotification({ - text: "User is temporary locked due to multiple failed login attempts. Try again after 5 minutes. You can also reset your password now to proceed.", + text: "User is temporary locked due to multiple failed login attempts. Try again later. You can also reset your password now to proceed.", type: "error" }); setIsLoading(false);