From a308075bc39b77ed7059b0cae9d443d669a7bf98 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 30 Aug 2023 08:33:48 +0100 Subject: [PATCH] feat: 2fa backup codes (#10600) Co-authored-by: Peer Richelsen --- apps/web/components/auth/BackupCode.tsx | 29 +++++++ apps/web/components/auth/TwoFactor.tsx | 4 +- .../settings/DisableTwoFactorModal.tsx | 42 ++++++++-- .../settings/EnableTwoFactorModal.tsx | 83 +++++++++++++++++-- .../components/settings/TwoFactorAuthAPI.ts | 4 +- .../pages/api/auth/two-factor/totp/disable.ts | 27 +++++- .../pages/api/auth/two-factor/totp/setup.ts | 7 +- apps/web/pages/auth/login.tsx | 48 ++++++++--- apps/web/playwright/login.2fa.e2e.ts | 21 +++++ apps/web/public/static/locales/en/common.json | 7 ++ packages/features/auth/lib/ErrorCode.ts | 2 + .../features/auth/lib/next-auth-options.ts | 32 ++++++- packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/ui/components/form/inputs/Input.tsx | 6 +- 16 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 apps/web/components/auth/BackupCode.tsx create mode 100644 packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql diff --git a/apps/web/components/auth/BackupCode.tsx b/apps/web/components/auth/BackupCode.tsx new file mode 100644 index 0000000000..a9121d815e --- /dev/null +++ b/apps/web/components/auth/BackupCode.tsx @@ -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 ( +
+ + +

{t("backup_code_instructions")}

+ + +
+ ); +} diff --git a/apps/web/components/auth/TwoFactor.tsx b/apps/web/components/auth/TwoFactor.tsx index e074639fe2..f46aa3b7e3 100644 --- a/apps/web/components/auth/TwoFactor.tsx +++ b/apps/web/components/auth/TwoFactor.tsx @@ -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(); @@ -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" /> ))} diff --git a/apps/web/components/settings/DisableTwoFactorModal.tsx b/apps/web/components/settings/DisableTwoFactorModal.tsx index 385e775f32..46d49ce62a 100644 --- a/apps/web/components/settings/DisableTwoFactorModal.tsx +++ b/apps/web/components/settings/DisableTwoFactorModal.tsx @@ -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,11 +35,19 @@ const DisableTwoFactorAuthModal = ({ }: DisableTwoFactorAuthModalProps) => { const [isDisabling, setIsDisabling] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); const { t } = useLocale(); const form = useForm(); - 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; } @@ -45,8 +55,10 @@ const DisableTwoFactorAuthModal = ({ 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; } @@ -54,12 +66,14 @@ const DisableTwoFactorAuthModal = ({ 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,6 +92,7 @@ const DisableTwoFactorAuthModal = ({
{!disablePassword && ( )} - + {twoFactorLostAccess ? ( + + ) : ( + + )} {errorMessage &&

{errorMessage}

}
+ diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index 099558a8af..0ed406787f 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -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, EnterTotpCode, } @@ -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(null); + const resetState = () => { + setPassword(""); + setErrorMessage(null); + 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", + }); + if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl); + 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 ( - +
- + + <> +
+ {backupCodes.map((code) => ( +
{formatBackupCode(code)}
+ ))} +
+ +
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
- + {step !== SetupStep.DisplayBackupCodes ? ( + + ) : null} + + + + + + diff --git a/apps/web/components/settings/TwoFactorAuthAPI.ts b/apps/web/components/settings/TwoFactorAuthAPI.ts index 35ef630575..1ea7792e87 100644 --- a/apps/web/components/settings/TwoFactorAuthAPI.ts +++ b/apps/web/components/settings/TwoFactorAuthAPI.ts @@ -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", }, diff --git a/apps/web/pages/api/auth/two-factor/totp/disable.ts b/apps/web/pages/api/auth/two-factor/totp/disable.ts index abc0835c4a..fecb75d92f 100644 --- a/apps/web/pages/api/auth/two-factor/totp/disable.ts +++ b/apps/web/pages/api/auth/two-factor/totp/disable.ts @@ -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); @@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: session.user.id, }, data: { + backupCodes: null, twoFactorEnabled: false, twoFactorSecret: null, }, diff --git a/apps/web/pages/api/auth/two-factor/totp/setup.ts b/apps/web/pages/api/auth/two-factor/totp/setup.ts index de63fcada6..a6fbed0391 100644 --- a/apps/web/pages/api/auth/two-factor/totp/setup.ts +++ b/apps/web/pages/api/auth/two-factor/totp/setup.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; 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), 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); const dataUri = await qrcode.toDataURL(keyUri); - return res.json({ secret, keyUri, dataUri }); + return res.json({ secret, keyUri, dataUri, backupCodes }); } diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 4847ceb9fb..ca4e752e72 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -21,7 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import prisma from "@calcom/prisma"; import { Alert, Button, EmailField, PasswordField } from "@calcom/ui"; -import { ArrowLeft } from "@calcom/ui/components/icon"; +import { ArrowLeft, Lock } from "@calcom/ui/components/icon"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; import type { WithNonceProps } from "@lib/withNonce"; @@ -29,6 +29,7 @@ import withNonce from "@lib/withNonce"; import AddToHomescreen from "@components/AddToHomescreen"; import PageWrapper from "@components/PageWrapper"; +import BackupCode from "@components/auth/BackupCode"; import TwoFactor from "@components/auth/TwoFactor"; import AuthContainer from "@components/ui/AuthContainer"; @@ -39,6 +40,7 @@ interface LoginValues { email: string; password: string; totpCode: string; + backupCode: string; csrfToken: string; } export default function Login({ @@ -65,6 +67,7 @@ export default function Login({ const methods = useForm({ resolver: zodResolver(formSchema) }); const { register, formState } = methods; const [twoFactorRequired, setTwoFactorRequired] = useState(!!totpEmail || false); + const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const errorMessages: { [key: string]: string } = { @@ -98,15 +101,35 @@ export default function Login({ ); const TwoFactorFooter = ( - + <> + + {!twoFactorLostAccess ? ( + + ) : null} + ); const ExternalTotpFooter = ( @@ -130,8 +153,9 @@ export default function Login({ if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]); // we're logged in! let's do a hard refresh to the desired url else if (!res.error) router.push(callbackUrl); - // reveal two factor input if required else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true); + else if (res.error === ErrorCode.IncorrectBackupCode) setErrorMessage(t("incorrect_backup_code")); + else if (res.error === ErrorCode.MissingBackupCodes) setErrorMessage(t("missing_backup_codes")); // fallback if error not found else setErrorMessage(errorMessages[res.error] || t("something_went_wrong")); }; @@ -194,7 +218,7 @@ export default function Login({
- {twoFactorRequired && } + {twoFactorRequired ? !twoFactorLostAccess ? : : null} {errorMessage && }