-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 2fa backup codes #10
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); | ||||||||||||||||||||||||||||||||||||||||
| 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> | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+113
to
+121
Contributor
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. Add The toggle button is inside a 🔧 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| <Button color="secondary" onClick={onCancel}> | ||||||||||||||||||||||||||||||||||||||||
| {t("cancel")} | ||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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."); | ||
| 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
Contributor
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: Multiple security and reliability issues in backup code validation. Three critical issues must be addressed:
🔒 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
🤖 Prompt for AI Agents |
||
| // 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, | ||
| }, | ||
|
|
||
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.
Function name should be
BackupCode, notTwoFactor.The exported function is named
TwoFactorbut the file isBackupCode.tsxand it renders backup code input, not TOTP input. This creates confusion since there's already aTwoFactorcomponent inTwoFactor.tsx. The naming mismatch could cause maintainability issues and confusion when reading the code.🔧 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents