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..e8c2cea69 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,11 +9,54 @@ 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(); 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 ")) { @@ -109,38 +154,36 @@ 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(); + return handleMfaVerification(req, res, server, req.body.mfaToken, req.body.mfaMethod); + } + }); - 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 - }; + 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) => { + 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 2a680b9b8..d69c836e9 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: "Please use a valid TOTP 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..efc33d52b 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -5,10 +5,12 @@ 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, Input } from "@app/components/v2"; -import { useSendMfaToken } from "@app/hooks/api"; -import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries"; +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"; // The style for the verification code input @@ -17,10 +19,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,11 +62,13 @@ 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); const [triesLeft, setTriesLeft] = useState(undefined); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); + const logout = useLogoutUser(true); const sendMfaToken = useSendMfaToken(); @@ -79,35 +83,57 @@ 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() || !isCodeComplete) 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) { 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) { + createNotification({ + 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); + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + SecurityClient.setSignupToken(""); + await logout.mutateAsync(); + navigate({ to: "/login" }); + return; + } } else { setTriesLeft(2); } @@ -147,7 +173,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } return ( -
+
{!hideLogo && (
@@ -162,79 +188,134 @@ 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 - - +
+ +
+ + {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 && ( diff --git a/frontend/src/components/mfa/RecoveryCodesDownload.tsx b/frontend/src/components/mfa/RecoveryCodesDownload.tsx new file mode 100644 index 000000000..50219f7dc --- /dev/null +++ b/frontend/src/components/mfa/RecoveryCodesDownload.tsx @@ -0,0 +1,111 @@ +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 = [...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 = `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..0b79bfd98 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 && result.recoveryCodes.length > 0) { + 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; } }); }; 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();