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.

🛠️ Refactor suggestion

Improve component naming and type safety.

The component is named TwoFactor but handles backup codes specifically. The center prop lacks type definition.

-export default function TwoFactor({ center = true }) {
+interface BackupCodeProps {
+  center?: boolean;
+}
+
+export default function BackupCode({ center = true }: BackupCodeProps) {

Also update the filename to match: BackupCode.tsx → component name should be BackupCode.

📝 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 }) {
interface BackupCodeProps {
center?: boolean;
}
export default function BackupCode({ center = true }: BackupCodeProps) {
🤖 Prompt for AI Agents
In apps/web/components/auth/BackupCode.tsx at line 7, rename the component from
TwoFactor to BackupCode to match the filename and its specific functionality.
Add a proper TypeScript type definition for the center prop, such as defining an
interface or type for the props with center as a boolean. This improves clarity
and type safety.

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>

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.

🛠️ Refactor suggestion

Add proper accessibility connection.

The instructions should be properly connected to the input field for screen readers.

-      <p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
+      <p id="backup-code-instructions" className="text-subtle mb-4 text-sm">
+        {t("backup_code_instructions")}
+      </p>
📝 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
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
<p id="backup-code-instructions" className="text-subtle mb-4 text-sm">
{t("backup_code_instructions")}
</p>
🤖 Prompt for AI Agents
In apps/web/components/auth/BackupCode.tsx at line 15, the paragraph with backup
code instructions is not properly connected to the input field for
accessibility. Add an aria-describedby attribute to the input element
referencing the id of this paragraph to ensure screen readers associate the
instructions with the input field.


<TextField
id="backup-code"
label=""
defaultValue=""
placeholder="XXXXX-XXXXX"
minLength={10} // without dash
maxLength={11} // with dash
required
{...methods.register("backupCode")}
/>
Comment on lines +17 to +26

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.

🛠️ Refactor suggestion

Enhance input validation and accessibility.

The current validation only checks length but doesn't validate the backup code format. Also missing accessibility attributes.

      <TextField
        id="backup-code"
        label=""
+        aria-label={t("backup_code")}
+        aria-describedby="backup-code-instructions"
        defaultValue=""
        placeholder="XXXXX-XXXXX"
        minLength={10} // without dash
        maxLength={11} // with dash
        required
+        pattern="[a-fA-F0-9]{5}-?[a-fA-F0-9]{5}"
+        title={t("backup_code_format_hint")}
        {...methods.register("backupCode")}
      />

Add corresponding localization key for format hint.

📝 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
<TextField
id="backup-code"
label=""
defaultValue=""
placeholder="XXXXX-XXXXX"
minLength={10} // without dash
maxLength={11} // with dash
required
{...methods.register("backupCode")}
/>
<TextField
id="backup-code"
label=""
aria-label={t("backup_code")}
aria-describedby="backup-code-instructions"
defaultValue=""
placeholder="XXXXX-XXXXX"
minLength={10} // without dash
maxLength={11} // with dash
required
pattern="[a-fA-F0-9]{5}-?[a-fA-F0-9]{5}"
title={t("backup_code_format_hint")}
{...methods.register("backupCode")}
/>
🤖 Prompt for AI Agents
In apps/web/components/auth/BackupCode.tsx around lines 17 to 26, the TextField
input only validates length but lacks format validation and accessibility
attributes. Enhance validation by adding a pattern attribute or custom
validation to enforce the backup code format (e.g., five characters, a dash,
then five characters). Add an aria-describedby attribute linking to a localized
format hint message for accessibility. Also, create and use a localization key
for the format hint text to support internationalization.

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

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.

🛠️ Refactor suggestion

Consider parameter validation and mutual exclusivity.

The method signature accepts both code and backupCode parameters, but typically only one should be provided. Consider validating that exactly one authentication method is provided.

-async disable(password: string, code: string, backupCode: string) {
+async disable(password: string, code?: string, backupCode?: string) {

And add validation to ensure exactly one authentication method is provided:

if ((!code && !backupCode) || (code && backupCode)) {
  throw new Error("Provide either TOTP code or backup code, not both");
}
🤖 Prompt for AI Agents
In apps/web/components/settings/TwoFactorAuthAPI.ts at line 22, the disable
method accepts both code and backupCode parameters but should enforce that
exactly one is provided. Add validation at the start of the method to check if
either code or backupCode is provided exclusively, and throw an error if neither
or both are given, using a condition like: if ((!code && !backupCode) || (code
&& backupCode)) { throw new Error("Provide either TOTP code or backup code, not
both"); }.

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

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

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.

🛠️ Refactor suggestion

Security concern: Backup codes returned in plaintext.

While necessary for user display, returning backup codes in the API response creates a brief window where they exist in plaintext. Ensure the frontend handles these securely and doesn't log them.

Consider adding a security comment to remind developers:

-  return res.json({ secret, keyUri, dataUri, backupCodes });
+  // SECURITY: Backup codes are returned in plaintext for user display
+  // Frontend must handle securely and avoid logging
+  return res.json({ secret, keyUri, dataUri, backupCodes });
📝 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
return res.json({ secret, keyUri, dataUri, backupCodes });
// SECURITY: Backup codes are returned in plaintext for user display
// Frontend must handle securely and avoid logging
return res.json({ secret, keyUri, dataUri, backupCodes });
🤖 Prompt for AI Agents
In apps/web/pages/api/auth/two-factor/totp/setup.ts at line 78, the backup codes
are returned in plaintext in the API response, which poses a security risk if
mishandled. Add a clear security comment above this return statement to remind
developers that backup codes must be handled securely on the frontend, avoiding
logging or exposing them unnecessarily.

}
Loading
Loading