Improve 2FA flow

This commit is contained in:
Carlos Monastyrski
2025-09-19 18:39:04 -03:00
parent 3c9ad328a9
commit 082e11d603
10 changed files with 379 additions and 106 deletions
+3 -1
View File
@@ -255,7 +255,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
totp: z.string() totp: z.string()
}), }),
response: { response: {
200: z.object({}) 200: z.object({
recoveryCodes: z.string().array()
})
} }
}, },
onRequest: verifyAuth([AuthMode.JWT], { onRequest: verifyAuth([AuthMode.JWT], {
@@ -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
};
}
});
}; };
@@ -684,7 +684,8 @@ export const authLoginServiceFactory = ({
mfaJwtToken, mfaJwtToken,
ip, ip,
userAgent, userAgent,
orgId orgId,
isRecoveryCode = false
}: TVerifyMfaTokenDTO) => { }: TVerifyMfaTokenDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
const user = await userDAL.findById(userId); const user = await userDAL.findById(userId);
@@ -698,16 +699,21 @@ export const authLoginServiceFactory = ({
code: mfaToken code: mfaToken
}); });
} else if (mfaMethod === MfaMethod.TOTP) { } else if (mfaMethod === MfaMethod.TOTP) {
if (mfaToken.length === 6) { if (isRecoveryCode) {
await totpService.verifyUserTotp({
userId,
totp: mfaToken
});
} else {
await totpService.verifyWithUserRecoveryCode({ await totpService.verifyWithUserRecoveryCode({
userId, userId,
recoveryCode: mfaToken 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) { } catch (err) {
@@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = {
ip: string; ip: string;
userAgent: string; userAgent: string;
orgId?: string; orgId?: string;
isRecoveryCode?: boolean;
}; };
export type TOauthLoginDTO = { export type TOauthLoginDTO = {
+10 -5
View File
@@ -131,15 +131,20 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp
secret secret
}); });
if (isValid) { if (!isValid) {
await totpConfigDAL.updateById(totpConfig.id, {
isVerified: true
});
} else {
throw new BadRequestError({ throw new BadRequestError({
message: "Invalid TOTP token" 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) => { const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => {
+98 -59
View File
@@ -6,9 +6,9 @@ import { t } from "i18next";
import Error from "@app/components/basic/Error"; import Error from "@app/components/basic/Error";
import TotpRegistration from "@app/components/mfa/TotpRegistration"; import TotpRegistration from "@app/components/mfa/TotpRegistration";
import SecurityClient from "@app/components/utilities/SecurityClient"; 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 { 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"; import { MfaMethod } from "@app/hooks/api/auth/types";
// The style for the verification code input // The style for the verification code input
@@ -17,10 +17,10 @@ const codeInputProps = {
fontFamily: "monospace", fontFamily: "monospace",
margin: "4px", margin: "4px",
MozAppearance: "textfield", MozAppearance: "textfield",
width: "48px", width: "55px",
borderRadius: "5px", borderRadius: "5px",
fontSize: "24px", fontSize: "24px",
height: "48px", height: "55px",
paddingLeft: "7", paddingLeft: "7",
backgroundColor: "#0d1117", backgroundColor: "#0d1117",
color: "white", color: "white",
@@ -60,6 +60,7 @@ type Props = {
export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
const [mfaCode, setMfaCode] = useState(""); const [mfaCode, setMfaCode] = useState("");
const [showRecoveryCodeInput, setShowRecoveryCodeInput] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingResend, setIsLoadingResend] = 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<HTMLFormElement>) => { const verifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!mfaCode.trim()) return;
setIsLoading(true); setIsLoading(true);
try { try {
const { token } = await verifyMfaToken({ let result;
email,
mfaCode, if (method === MfaMethod.TOTP && showRecoveryCodeInput) {
mfaMethod: method result = await verifyRecoveryCode(mfaCode.trim());
}); } else {
result = await verifyMfaToken({
email,
mfaCode: mfaCode.trim(),
mfaMethod: method
});
}
SecurityClient.setMfaToken(""); SecurityClient.setMfaToken("");
SecurityClient.setToken(token); SecurityClient.setToken(result.token);
await successCallback(); await successCallback();
if (closeMfa) { if (closeMfa) {
@@ -147,7 +156,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
} }
return ( return (
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8"> <div className="mx-auto w-max pb-6 pt-6 md:mb-16 md:px-8">
{!hideLogo && ( {!hideLogo && (
<Link to="/"> <Link to="/">
<div className="mb-4 flex justify-center"> <div className="mb-4 flex justify-center">
@@ -162,76 +171,106 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
</> </>
)} )}
{method === MfaMethod.TOTP && ( {method === MfaMethod.TOTP && (
<> <div className="mb-8 text-center">
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100"> <h2 className="mb-3 text-xl font-semibold text-bunker-100">Two-Factor Authentication</h2>
Authenticator MFA Required <p className="mx-auto max-w-md text-sm leading-relaxed text-bunker-300">
{showRecoveryCodeInput
? "Enter one of your backup recovery codes"
: "Enter the verification code from your authenticator app"}
</p> </p>
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300"> </div>
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
)} )}
<form onSubmit={verifyMfa}> <form onSubmit={verifyMfa}>
<div className="mx-auto hidden w-max min-w-[20rem] md:block"> <div className="mx-auto hidden md:block" style={{ minWidth: "600px" }}>
{method === MfaMethod.EMAIL && ( {method === MfaMethod.EMAIL && (
<ReactCodeInput <div className="flex justify-center">
name="" <ReactCodeInput
inputMode="tel" name=""
type="text" inputMode="tel"
fields={6} type="text"
onChange={setMfaCode} fields={6}
className="mb-2 mt-6" onChange={setMfaCode}
{...codeInputProps} className="mb-2 mt-6"
/> {...codeInputProps}
/>
</div>
)} )}
{method === MfaMethod.TOTP && ( {method === MfaMethod.TOTP && (
<div className="mb-4 mt-6"> <div className="mb-6 mt-8 flex justify-center">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} /> <ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</div> </div>
)} )}
</div> </div>
<div className="mx-auto mt-4 block w-max min-w-[18rem] md:hidden"> <div className="mx-auto mt-4 block md:hidden" style={{ minWidth: "400px" }}>
{method === MfaMethod.EMAIL && ( {method === MfaMethod.EMAIL && (
<ReactCodeInput <div className="flex justify-center">
name="" <ReactCodeInput
inputMode="tel" name=""
type="text" inputMode="tel"
fields={6} type="text"
onChange={setMfaCode} fields={6}
className="mb-2 mt-2" onChange={setMfaCode}
{...codeInputPropsPhone} className="mb-2 mt-2"
/> {...codeInputPropsPhone}
/>
</div>
)} )}
{method === MfaMethod.TOTP && ( {method === MfaMethod.TOTP && (
<div className="mb-4 mt-2"> <div className="mb-6 mt-4 flex justify-center">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} /> <ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div> </div>
)} )}
</div> </div>
{typeof triesLeft === "number" && ( {typeof triesLeft === "number" && (
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} /> <Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
)} )}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]"> <div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<div className="text-l w-full py-1 text-lg"> <Button
<Button size="md"
size="sm" type="submit"
type="submit" isFullWidth
isFullWidth className="h-11 rounded-lg font-medium shadow-sm transition-all duration-200 hover:shadow-md"
className="h-14" colorSchema="primary"
colorSchema="primary" variant="outline_bg"
variant="outline_bg" isLoading={isLoading}
isLoading={isLoading} >
> {String(t("mfa.verify"))}
{String(t("mfa.verify"))} </Button>
</Button>
</div>
</div> </div>
</form> </form>
{method === MfaMethod.TOTP && ( {method === MfaMethod.TOTP && (
<div className="mt-2 flex flex-row justify-center text-sm text-bunker-400"> <div className="mt-6 flex flex-col items-center gap-4 text-sm">
<button
type="button"
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"
>
{showRecoveryCodeInput ? "Use authenticator code" : "Use a recovery code"}
</button>
<Link to="/verify-email"> <Link to="/verify-email">
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"> <span className="text-sm text-bunker-400 transition-colors duration-200 hover:text-bunker-200">
Lost your recovery codes? Reset your account Lost your recovery codes? Reset your account
</span> </span>
</Link> </Link>
@@ -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 (
<Modal isOpen={isOpen} onOpenChange={hasDownloaded ? handleClose : () => {}}>
<ModalContent title="Recovery Codes" className="max-w-md">
<div className="space-y-4">
<div className="rounded border border-yellow bg-yellow/10 p-2 px-3 text-xs text-yellow">
Save these codes securely. Each can only be used once.
</div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="grid grid-cols-2 gap-x-6 gap-y-2 font-mono text-sm">
{recoveryCodes.map((code, index) => (
<div key={code} className="flex items-center text-mineshaft-200">
<span className="w-6 text-mineshaft-400">{index + 1}.</span>
<span>{code}</span>
</div>
))}
</div>
</div>
<div className="flex gap-3">
<Button
onClick={downloadRecoveryCodes}
className="flex flex-1 items-center justify-center gap-2"
colorSchema="primary"
variant="solid"
>
<FontAwesomeIcon icon={faDownload} className="mr-2 h-4 w-4" />
Download
</Button>
<Button
onClick={copyToClipboard}
className="flex flex-1 items-center justify-center gap-2"
colorSchema="secondary"
variant="outline"
>
<FontAwesomeIcon icon={faCopy} className="mr-2 h-4 w-4" />
{copied ? "Copied!" : "Copy"}
</Button>
</div>
{hasDownloaded ? (
<p className="text-center text-xs text-mineshaft-400">
Recovery codes downloaded. You can now close this modal.
</p>
) : (
<p className="text-center text-xs text-mineshaft-400">
Download the recovery codes to continue.
</p>
)}
</div>
</ModalContent>
</Modal>
);
};
@@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation";
import { createNotification } from "../notifications"; import { createNotification } from "../notifications";
import { Button, ContentLoader, Input } from "../v2"; import { Button, ContentLoader, Input } from "../v2";
import { RecoveryCodesDownload } from "./RecoveryCodesDownload";
type Props = { type Props = {
onComplete?: () => Promise<void>; onComplete?: () => Promise<void>;
@@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
useVerifyUserTotpRegistration(); useVerifyUserTotpRegistration();
const [qrCodeUrl, setQrCodeUrl] = useState(""); const [qrCodeUrl, setQrCodeUrl] = useState("");
const [totp, setTotp] = useState(""); const [totp, setTotp] = useState("");
const [showRecoveryModal, setShowRecoveryModal] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => { const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
await verifyUserTotp({ try {
totp const result = await verifyUserTotp({
}); totp
});
createNotification({ createNotification({
text: "Successfully configured mobile authenticator", text: "Successfully configured mobile authenticator",
type: "success" 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) { if (onComplete) {
onComplete(); await onComplete();
} }
}; };
@@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => {
} }
return ( return (
<div className="flex max-w-lg flex-col text-bunker-200"> <>
<div className="mb-8"> <div className="flex max-w-lg flex-col text-bunker-200">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR <div className="mb-8">
code. 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
</div> code.
<div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
<img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4">2. Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
</div> </div>
</form> <div className={twMerge("mb-8 flex items-center", shouldCenterQr && "justify-center")}>
</div> <img src={qrCodeUrl} alt="registration-qr" />
</div>
<form onSubmit={handleTotpVerify}>
<div className="mb-4">2. Enter the resulting verification code</div>
<div className="mb-4 flex flex-row gap-2">
<Input
onChange={(e) => setTotp(e.target.value)}
value={totp}
placeholder="Verification code"
/>
<Button isLoading={isVerifyLoading} type="submit">
Enable MFA
</Button>
</div>
</form>
</div>
<RecoveryCodesDownload
isOpen={showRecoveryModal}
onClose={() => setShowRecoveryModal(false)}
recoveryCodes={recoveryCodes}
onDownloadComplete={handleRecoveryDownloadComplete}
/>
</>
); );
}; };
+7
View File
@@ -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) => { export const verifySignupInvite = async (details: VerifySignupInviteDTO) => {
const { data } = await apiRequest.post("/api/v1/invite-org/verify", details); const { data } = await apiRequest.post("/api/v1/invite-org/verify", details);
return data; return data;
+8 -5
View File
@@ -77,13 +77,16 @@ export const useUpdateUserProjectFavorites = () => {
}; };
export const useVerifyUserTotpRegistration = () => { export const useVerifyUserTotpRegistration = () => {
return useMutation({ return useMutation<{ recoveryCodes: string[] }, unknown, { totp: string }>({
mutationFn: async ({ totp }: { totp: string }) => { mutationFn: async ({ totp }: { totp: string }) => {
await apiRequest.post("/api/v1/user/me/totp/verify", { const { data } = await apiRequest.post<{ recoveryCodes: string[] }>(
totp "/api/v1/user/me/totp/verify",
}); {
totp
}
);
return {}; return data;
} }
}); });
}; };