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

View File

@@ -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], {

View File

@@ -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
};
}
});
};

View File

@@ -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) {

View File

@@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = {
ip: string;
userAgent: string;
orgId?: string;
isRecoveryCode?: boolean;
};
export type TOauthLoginDTO = {

View File

@@ -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) => {

View File

@@ -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<HTMLFormElement>) => {
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 (
<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 && (
<Link to="/">
<div className="mb-4 flex justify-center">
@@ -162,76 +171,106 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop
</>
)}
{method === MfaMethod.TOTP && (
<>
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
Authenticator MFA Required
<div className="mb-8 text-center">
<h2 className="mb-3 text-xl font-semibold text-bunker-100">Two-Factor Authentication</h2>
<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 className="text-l flex max-w-xs justify-center text-center text-bunker-300">
Open the authenticator app on your mobile device to get your verification code or enter
a recovery code.
</p>
</>
</div>
)}
<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 && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-6"
{...codeInputProps}
/>
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-6"
{...codeInputProps}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mb-4 mt-6">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
<div className="mb-6 mt-8 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery" : "totp"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputProps}
/>
</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 && (
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-2"
{...codeInputPropsPhone}
/>
<div className="flex justify-center">
<ReactCodeInput
name=""
inputMode="tel"
type="text"
fields={6}
onChange={setMfaCode}
className="mb-2 mt-2"
{...codeInputPropsPhone}
/>
</div>
)}
{method === MfaMethod.TOTP && (
<div className="mb-4 mt-2">
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
<div className="mb-6 mt-4 flex justify-center">
<ReactCodeInput
key={showRecoveryCodeInput ? "recovery-mobile" : "totp-mobile"}
name=""
inputMode="tel"
type="text"
fields={showRecoveryCodeInput ? 8 : 6}
onChange={setMfaCode}
className="mb-2"
{...codeInputPropsPhone}
/>
</div>
)}
</div>
{typeof triesLeft === "number" && (
<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="text-l w-full py-1 text-lg">
<Button
size="sm"
type="submit"
isFullWidth
className="h-14"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
</div>
<div className="mx-auto mt-6 flex w-full max-w-sm flex-col items-center justify-center text-center">
<Button
size="md"
type="submit"
isFullWidth
className="h-11 rounded-lg font-medium shadow-sm transition-all duration-200 hover:shadow-md"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
{String(t("mfa.verify"))}
</Button>
</div>
</form>
{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">
<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
</span>
</Link>

View File

@@ -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>
);
};

View File

@@ -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<void>;
@@ -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<string[]>([]);
const handleTotpVerify = async (event: React.FormEvent<HTMLFormElement>) => {
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 (
<div className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
code.
</div>
<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 className="flex max-w-lg flex-col text-bunker-200">
<div className="mb-8">
1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR
code.
</div>
</form>
</div>
<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>
</form>
</div>
<RecoveryCodesDownload
isOpen={showRecoveryModal}
onClose={() => setShowRecoveryModal(false)}
recoveryCodes={recoveryCodes}
onDownloadComplete={handleRecoveryDownloadComplete}
/>
</>
);
};

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) => {
const { data } = await apiRequest.post("/api/v1/invite-org/verify", details);
return data;

View File

@@ -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;
}
});
};