-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 2fa backup codes #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: enhance-two-factor-security-foundation
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }) { | ||
| 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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"; | ||||||||||||||||||
|
|
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps { | |||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| interface DisableTwoFactorValues { | ||||||||||||||||||
| backupCode: string; | ||||||||||||||||||
| totpCode: string; | ||||||||||||||||||
| password: string; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -33,33 +35,45 @@ const DisableTwoFactorAuthModal = ({ | |||||||||||||||||
| }: DisableTwoFactorAuthModalProps) => { | ||||||||||||||||||
| const [isDisabling, setIsDisabling] = useState(false); | ||||||||||||||||||
| const [errorMessage, setErrorMessage] = useState<string | null>(null); | ||||||||||||||||||
| const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — State persistence across modal opens (confidence: 93%) The twoFactorLostAccess state is reset on successful disable (line 63), but if the user closes the modal via the cancel button without success, the state is not reset. Next time the modal opens, it may show the backup code input instead of the TOTP input. The resetForm function exists but is not called from onCancel. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 93% |
||||||||||||||||||
| 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")); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -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> | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| enum SetupStep { | ||||||||||||||||||||||
| ConfirmPassword, | ||||||||||||||||||||||
| DisplayBackupCodes, | ||||||||||||||||||||||
| DisplayQrCode, | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Business Logic Correctness (confidence: 99%) The Evidence:
Agent: logic |
||||||||||||||||||||||
| EnterTotpCode, | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Intent alignment - Step ordering (confidence: 100%) The SetupStep enum defines DisplayBackupCodes (value 1) before DisplayQrCode (value 2), but the intent specifies the flow order should be ConfirmPassword → DisplayQrCode → EnterTotpCode → DisplayBackupCodes. While the actual step transitions are controlled explicitly via setStep() calls and the enum numeric values don't drive the flow, this ordering is misleading and fragile. More importantly, the enum assigns DisplayBackupCodes=1 and DisplayQrCode=2, EnterTotpCode=3. The actual flow correctly goes ConfirmPassword(0) → DisplayQrCode(2) → EnterTotpCode(3) → DisplayBackupCodes(1) via explicit setStep calls, so the runtime behavior is correct. However, this is a maintenance hazard. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • critical • confidence: 100% |
||||||||||||||||||||||
|
|
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable | |||||||||||||||||||||
|
|
||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Cross-module impact / State management (confidence: 93%) The Evidence:
Agent: architecture |
||||||||||||||||||||||
| 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(""); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — Type Safety (confidence: 93%) The Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — TypeScript type safety - backupCodes state (confidence: 85%) The backupCodes state is initialized as an empty array without a type annotation: useState([]). This infers as never[], which means TypeScript won't catch type errors when setting string values. It should be typed as useState([]). Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 85% |
||||||||||||||||||||||
| 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 = () => { | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — resetState incomplete - missing backupCodes and backupCodesUrl cleanup (confidence: 85%) The resetState function resets password, errorMessage, and step, but does not reset backupCodes, backupCodesUrl, dataUri, or secret. When the modal is cancelled and reopened, stale state from a previous setup attempt could persist. While the states would be overwritten on a new successful setup, if the component instance is reused (which it likely is since open/close is controlled by a prop), this could cause the previous backup codes to briefly flash on screen. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • minor • confidence: 85% |
||||||||||||||||||||||
| setPassword(""); | ||||||||||||||||||||||
| setErrorMessage(null); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MINOR — State Management (confidence: 93%) The Evidence:
Agent: logic |
||||||||||||||||||||||
| setStep(SetupStep.ConfirmPassword); | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async function handleSetup(e: React.FormEvent) { | ||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -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", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — Memory leak - Blob URL not revoked on unmount (confidence: 91%) The Blob URL created via URL.createObjectURL is only revoked if handleSetup is called again (when backupCodesUrl is truthy). If the modal unmounts without the user re-running setup, the Blob URL is never revoked, causing a memory leak. There is no useEffect cleanup that revokes the URL on unmount. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ✅
Suggested change
🤖 Grapple PR auto-fix • major • confidence: 91% |
||||||||||||||||||||||
| if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Resource Leak / State Management (confidence: 73%) The Evidence:
Agent: logic |
||||||||||||||||||||||
| setBackupCodesUrl(URL.createObjectURL(textBlob)); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setDataUri(body.dataUri); | ||||||||||||||||||||||
| setSecret(body.secret); | ||||||||||||||||||||||
| setStep(SetupStep.DisplayQrCode); | ||||||||||||||||||||||
|
|
@@ -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; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -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")} | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — State Management (confidence: 100%) When the modal's Evidence:
Agent: logic |
||||||||||||||||||||||
| type="password" | ||||||||||||||||||||||
| name="password" | ||||||||||||||||||||||
|
|
@@ -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"> | ||||||||||||||||||||||
|
|
@@ -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" | ||||||||||||||||||||||
|
|
@@ -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> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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."); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Error Handling (confidence: 100%) The encryption key guard throws a raw Error instead of returning a structured JSON error response. This will result in an unhandled 500 error with potentially leaked internal details, unlike every other error path in this handler which returns Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — API contract violation (confidence: 100%) The disable endpoint throws a raw Evidence:
Agent: architecture |
||
| 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); | ||
|
|
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | |
| id: session.user.id, | ||
| }, | ||
| data: { | ||
| backupCodes: null, | ||
| twoFactorEnabled: false, | ||
| twoFactorSecret: null, | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import crypto from "crypto"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Linked Issue Mismatch (confidence: 81%) The linked issue (#3) describes 'SMS workflow reminder retry count tracking' which is completely unrelated to the 2FA backup codes feature implemented in this PR. The PR cannot be considered as addressing the linked issue. Evidence:
Agent: logic |
||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
| import { authenticator } from "otplib"; | ||
| import qrcode from "qrcode"; | ||
|
|
@@ -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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 CRITICAL — Missing encryption key guard (confidence: 100%) setup.ts calls symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY) without first checking if CALENDSO_ENCRYPTION_KEY is defined. If the env var is missing, this will pass undefined to symmetricEncrypt, which will either throw a cryptic error or produce corrupted data. Both disable.ts and next-auth-options.ts explicitly check for this key before proceeding, but setup.ts does not. Evidence:
Agent: logic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡
--- a/apps/web/pages/api/auth/two-factor/totp/setup.ts
+++ b/apps/web/pages/api/auth/two-factor/totp/setup.ts
@@ -57,6 +57,10 @@ 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);
+ if (!process.env.CALENDSO_ENCRYPTION_KEY) {
+ return res.status(500).json({ error: ErrorCode.InternalServerError });
+ }
+
// generate backup codes with 10 character length
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
@@ -66,8 +70,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
data: {
- backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
+ backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY as string),
twoFactorEnabled: false,
- twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
+ twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY as string),
},
});🤖 Grapple PR auto-fix • critical • Review this diff before applying |
||
| twoFactorEnabled: false, | ||
| twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY), | ||
| }, | ||
|
|
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) | |
| const keyUri = authenticator.keyuri(name, "Cal", secret); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 MAJOR — API contract violation (confidence: 100%) The setup endpoint returns plaintext backup codes in the JSON response ( Evidence:
Agent: architecture There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 INFO — Security (confidence: 87%) Backup codes are returned in plaintext in the API response. This is necessary for the initial display but means the codes travel over the wire. The intent spec notes this relies entirely on HTTPS for protection. Consider adding a comment documenting this security decision. Evidence:
Agent: logic |
||
| const dataUri = await qrcode.toDataURL(keyUri); | ||
|
|
||
| return res.json({ secret, keyUri, dataUri }); | ||
| return res.json({ secret, keyUri, dataUri, backupCodes }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 MAJOR — Module boundaries / Component naming (confidence: 90%)
The BackupCode component's default export is named
TwoFactor, which is identical to the default export name fromTwoFactor.tsxin the same directory. This creates confusion for developers, makes stack traces ambiguous, and could lead to import errors if both are used in the same file.Evidence:
export default function TwoFactor({ center = true })— same function name as in TwoFactor.tsx line 8Agent: architecture