Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/web/components/auth/BackupCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { useFormContext } from "react-hook-form";

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, TextField } from "@calcom/ui";

export default function TwoFactor({ center = true }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Function name should be BackupCode, not TwoFactor.

The exported function is named TwoFactor but the file is BackupCode.tsx and it renders backup code input, not TOTP input. This creates confusion since there's already a TwoFactor component in TwoFactor.tsx. The naming mismatch could cause maintainability issues and confusion when reading the code.

🔧 Proposed fix
-export default function TwoFactor({ center = true }) {
+export default function BackupCode({ center = true }: { center?: boolean }) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function TwoFactor({ center = true }) {
export default function BackupCode({ center = true }: { center?: boolean }) {
🤖 Prompt for AI Agents
In @apps/web/components/auth/BackupCode.tsx at line 7, The exported component is
misnamed: change the function declaration currently named TwoFactor to
BackupCode so the default export and component name match the file and purpose;
update the function signature (TwoFactor -> BackupCode) and any internal
references or prop usages inside this component (e.g., its props and JSX) to use
BackupCode to avoid conflicts with the existing TwoFactor component.

const { t } = useLocale();
const methods = useFormContext();

return (
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<Label className="mt-4">{t("backup_code")}</Label>

<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>

<TextField
id="backup-code"
label=""
defaultValue=""
placeholder="XXXXX-XXXXX"
minLength={10} // without dash
maxLength={11} // with dash
required
{...methods.register("backupCode")}
/>
</div>
);
}
4 changes: 2 additions & 2 deletions apps/web/components/auth/TwoFactor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Input } from "@calcom/ui";

export default function TwoFactor({ center = true }) {
export default function TwoFactor({ center = true, autoFocus = true }) {
const [value, onChange] = useState("");
const { t } = useLocale();
const methods = useFormContext();
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
name={`2fa${index + 1}`}
inputMode="decimal"
{...digit}
autoFocus={index === 0}
autoFocus={autoFocus && index === 0}
autoComplete="one-time-code"
/>
))}
Expand Down
42 changes: 35 additions & 7 deletions apps/web/components/settings/DisableTwoFactorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";

import BackupCode from "@components/auth/BackupCode";
import TwoFactor from "@components/auth/TwoFactor";

import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
Expand All @@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
}

interface DisableTwoFactorValues {
backupCode: string;
totpCode: string;
password: string;
}
Expand All @@ -33,33 +35,45 @@ const DisableTwoFactorAuthModal = ({
}: DisableTwoFactorAuthModalProps) => {
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
const { t } = useLocale();

const form = useForm<DisableTwoFactorValues>();

async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
const resetForm = (clearPassword = true) => {
if (clearPassword) form.setValue("password", "");
form.setValue("backupCode", "");
form.setValue("totpCode", "");
setErrorMessage(null);
};

async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
if (isDisabling) {
return;
}
setIsDisabling(true);
setErrorMessage(null);

try {
const response = await TwoFactorAuthAPI.disable(password, totpCode);
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
if (response.status === 200) {
setTwoFactorLostAccess(false);
resetForm();
onDisable();
return;
}

const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage(t("incorrect_password"));
}
if (body.error === ErrorCode.SecondFactorRequired) {
} else if (body.error === ErrorCode.SecondFactorRequired) {
setErrorMessage(t("2fa_required"));
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage(t("incorrect_2fa"));
} else if (body.error === ErrorCode.IncorrectBackupCode) {
setErrorMessage(t("incorrect_backup_code"));
} else if (body.error === ErrorCode.MissingBackupCodes) {
setErrorMessage(t("missing_backup_codes"));
} else {
setErrorMessage(t("something_went_wrong"));
}
Expand All @@ -78,19 +92,33 @@ const DisableTwoFactorAuthModal = ({
<div className="mb-8">
{!disablePassword && (
<PasswordField
required
labelProps={{
className: "block text-sm font-medium text-default",
}}
{...form.register("password")}
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
)}
<TwoFactor center={false} />
{twoFactorLostAccess ? (
<BackupCode center={false} />
) : (
<TwoFactor center={false} autoFocus={false} />
)}

{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>

<DialogFooter showDivider className="relative mt-5">
<Button
color="minimal"
className="mr-auto"
onClick={() => {
setTwoFactorLostAccess(!twoFactorLostAccess);
resetForm(false);
}}>
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
</Button>
Comment on lines +113 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add type="button" to prevent accidental form submission.

The toggle button is inside a <Form> element. Without explicit type="button", clicking it could trigger form submission in some browsers since the default button type is "submit".

🔧 Proposed fix
             <Button
+              type="button"
               color="minimal"
               className="mr-auto"
               onClick={() => {
                 setTwoFactorLostAccess(!twoFactorLostAccess);
                 resetForm(false);
               }}>
               {twoFactorLostAccess ? t("go_back") : t("lost_access")}
             </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
color="minimal"
className="mr-auto"
onClick={() => {
setTwoFactorLostAccess(!twoFactorLostAccess);
resetForm(false);
}}>
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
</Button>
<Button
type="button"
color="minimal"
className="mr-auto"
onClick={() => {
setTwoFactorLostAccess(!twoFactorLostAccess);
resetForm(false);
}}>
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
</Button>
🤖 Prompt for AI Agents
In @apps/web/components/settings/DisableTwoFactorModal.tsx around lines 113 -
121, The toggle Button inside the Form (the Button rendering the
{twoFactorLostAccess ? t("go_back") : t("lost_access")} label and calling
setTwoFactorLostAccess and resetForm onClick) lacks an explicit type, so
clicking it may submit the form; update that Button to include type="button" to
ensure it does not trigger form submission when clicked.

<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
Expand Down
83 changes: 76 additions & 7 deletions apps/web/components/settings/EnableTwoFactorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";

import TwoFactor from "@components/auth/TwoFactor";

Expand All @@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {

enum SetupStep {
ConfirmPassword,
DisplayBackupCodes,
DisplayQrCode,
EnterTotpCode,
}
Expand All @@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable

const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
};
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState([]);
const [backupCodesUrl, setBackupCodesUrl] = useState("");
const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const resetState = () => {
setPassword("");
setErrorMessage(null);
setStep(SetupStep.ConfirmPassword);
};

async function handleSetup(e: React.FormEvent) {
e.preventDefault();

Expand All @@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();

if (response.status === 200) {
setBackupCodes(body.backupCodes);

// create backup codes download url
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
type: "text/plain",
});
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
setBackupCodesUrl(URL.createObjectURL(textBlob));

setDataUri(body.dataUri);
setSecret(body.secret);
setStep(SetupStep.DisplayQrCode);
Expand Down Expand Up @@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
const body = await response.json();

if (response.status === 200) {
onEnable();
setStep(SetupStep.DisplayBackupCodes);
return;
}

Expand Down Expand Up @@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
}
}, [form, handleEnableRef, totpCode]);

const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
<DialogContent
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
description={setupDescriptions[step]}
type="creation">
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<form onSubmit={handleSetup}>
<div className="mb-4">
<TextField
<PasswordField
label={t("password")}
type="password"
name="password"
Expand All @@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</p>
</>
</WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
{backupCodes.map((code) => (
<div key={code}>{formatBackupCode(code)}</div>
))}
</div>
</>
</WithStep>
<Form handleSubmit={handleEnable} form={form}>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<div className="-mt-4 pb-2">
Expand All @@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
</div>
</WithStep>
<DialogFooter className="mt-8" showDivider>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
{step !== SetupStep.DisplayBackupCodes ? (
<Button
color="secondary"
onClick={() => {
onCancel();
resetState();
}}>
{t("cancel")}
</Button>
) : null}
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
Expand Down Expand Up @@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
{t("enable")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
<>
<Button
color="secondary"
data-testid="backup-codes-close"
onClick={(e) => {
e.preventDefault();
resetState();
onEnable();
}}>
{t("close")}
</Button>
<Button
color="secondary"
data-testid="backup-codes-copy"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
showToast(t("backup_codes_copied"), "success");
}}>
{t("copy")}
</Button>
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
<Button color="primary" data-testid="backup-codes-download">
{t("download")}
</Button>
</a>
</>
</WithStep>
</DialogFooter>
</Form>
</DialogContent>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/components/settings/TwoFactorAuthAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
});
},

async disable(password: string, code: string) {
async disable(password: string, code: string, backupCode: string) {
return fetch("/api/auth/two-factor/totp/disable", {
method: "POST",
body: JSON.stringify({ password, code }),
body: JSON.stringify({ password, code, backupCode }),
headers: {
"Content-Type": "application/json",
},
Expand Down
27 changes: 25 additions & 2 deletions apps/web/pages/api/auth/two-factor/totp/disable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
}
// if user has 2fa
if (user.twoFactorEnabled) {

// if user has 2fa and using backup code
if (user.twoFactorEnabled && req.body.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
}

if (!user.backupCodes) {
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
}

const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));

// check if user-supplied code matches one
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
if (index === -1) {
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
}

// we delete all stored backup codes at the end, no need to do this here

Comment on lines +48 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Multiple security and reliability issues in backup code validation.

Three critical issues must be addressed:

  1. Type validation missing (Line 61): req.body.backupCode.replaceAll("-", "") is called without checking if backupCode is a string. If it's undefined, null, or another type, this will cause a runtime error.

  2. Missing error handling (Line 58): JSON.parse(symmetricDecrypt(...)) lacks try-catch. If the encrypted data is corrupted or decryption fails, the endpoint will crash instead of returning a proper error response.

  3. Security vulnerability - backup code reuse (Lines 61-66): Once a backup code is validated, it's not removed from the array or marked as used. This allows the same backup code to be reused unlimited times until 2FA is fully disabled. Standard security practice requires single-use backup codes.

🔒 Proposed fixes
  // if user has 2fa and using backup code
  if (user.twoFactorEnabled && req.body.backupCode) {
+   // Validate backupCode is a string
+   if (typeof req.body.backupCode !== 'string') {
+     return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
+   }
+
    if (!process.env.CALENDSO_ENCRYPTION_KEY) {
      console.error("Missing encryption key; cannot proceed with backup code login.");
      throw new Error(ErrorCode.InternalServerError);
    }

    if (!user.backupCodes) {
      return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
    }

-   const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
+   let backupCodes: string[];
+   try {
+     backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
+   } catch (error) {
+     console.error("Failed to decrypt backup codes:", error);
+     return res.status(500).json({ error: ErrorCode.InternalServerError });
+   }

    // check if user-supplied code matches one
    const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
    if (index === -1) {
      return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
    }

-   // we delete all stored backup codes at the end, no need to do this here
+   // Remove the used backup code to prevent reuse
+   backupCodes.splice(index, 1);
+   
+   // Update user with remaining backup codes
+   await prisma.user.update({
+     where: { id: session.user.id },
+     data: {
+       backupCodes: backupCodes.length > 0 
+         ? symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY)
+         : null,
+     },
+   });

    // if user has 2fa and NOT using backup code, try totp
  } else if (user.twoFactorEnabled) {

Note: You'll need to import symmetricEncrypt from @calcom/lib/crypto.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @apps/web/pages/api/auth/two-factor/totp/disable.ts around lines 48 - 67, The
backup code validation block has three issues: add a defensive type check for
req.body.backupCode (ensure it's a string before calling replaceAll, normalize
by removing dashes), wrap the symmetricDecrypt/JSON.parse call in try-catch and
return a suitable error response on decryption/parsing failure, and enforce
single-use backup codes by removing the matched code from the backupCodes array,
re-encrypting it with symmetricEncrypt and storing it back to user.backupCodes
(and persisting the user record) after successful validation; reference the
symbols req.body.backupCode, symmetricDecrypt, JSON.parse, backupCodes,
symmetricEncrypt, and user.backupCodes in your changes and return appropriate
ErrorCode responses for each failure path.

// if user has 2fa and NOT using backup code, try totp
} else if (user.twoFactorEnabled) {
if (!req.body.code) {
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
// throw new Error(ErrorCode.SecondFactorRequired);
Expand Down Expand Up @@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: session.user.id,
},
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},
Expand Down
7 changes: 6 additions & 1 deletion apps/web/pages/api/auth/two-factor/totp/setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
import qrcode from "qrcode";
Expand Down Expand Up @@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// bytes without updating the sanity checks in the enable and login endpoints.
const secret = authenticator.generateSecret(20);

// generate backup codes with 10 character length
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));

await prisma.user.update({
where: {
id: session.user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
twoFactorEnabled: false,
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
},
Expand All @@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const keyUri = authenticator.keyuri(name, "Cal", secret);
const dataUri = await qrcode.toDataURL(keyUri);

return res.json({ secret, keyUri, dataUri });
return res.json({ secret, keyUri, dataUri, backupCodes });
}
Loading