From d1278e89805c738c786c0e215643fa8fd08e35c0 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 15:36:00 +0000 Subject: [PATCH 01/17] feat: generate backup codes during totp setup --- apps/web/pages/api/auth/two-factor/totp/setup.ts | 7 ++++++- .../20230804153419_add_backup_codes/migration.sql | 2 ++ packages/prisma/schema.prisma | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql 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 de63fcada67882..a6fbed03912665 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/packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql b/packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql new file mode 100644 index 00000000000000..469f554e4962e7 --- /dev/null +++ b/packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "backupCodes" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5983f224f718ab..882130f1dadb80 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -200,6 +200,7 @@ model User { timeFormat Int? @default(12) twoFactorSecret String? twoFactorEnabled Boolean @default(false) + backupCodes String? identityProvider IdentityProvider @default(CAL) identityProviderId String? availability Availability[] From 1d62cae18c2ed196c6756a9843140870d56ffa7c Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 15:48:53 +0000 Subject: [PATCH 02/17] feat: add backup codes to login flow --- apps/web/components/auth/BackupCode.tsx | 27 +++++++++++ apps/web/pages/auth/login.tsx | 48 ++++++++++++++----- apps/web/public/static/locales/en/common.json | 5 ++ packages/features/auth/lib/ErrorCode.ts | 2 + .../features/auth/lib/next-auth-options.ts | 32 ++++++++++++- 5 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 apps/web/components/auth/BackupCode.tsx diff --git a/apps/web/components/auth/BackupCode.tsx b/apps/web/components/auth/BackupCode.tsx new file mode 100644 index 00000000000000..7a49da7396db26 --- /dev/null +++ b/apps/web/components/auth/BackupCode.tsx @@ -0,0 +1,27 @@ +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/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 4847ceb9fbf910..ca4e752e72d448 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 && } + {step !== SetupStep.DisplayBackupCodes ? ( + + ) : null} + + + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f1df7ee00aeda4..2857d95e166423 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2011,7 +2011,9 @@ "my_availability": "My Availability", "team_availability": "Team Availability", "backup_code": "Backup Code", + "backup_codes": "Backup Codes", "backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.", + "backup_codes_copied": "Backup codes copied!", "incorrect_backup_code": "Backup code is incorrect.", "lost_access": "Lost access", "missing_backup_codes": "No backup codes found. Please generate them in your settings.", From 9706b737391ac406255791dbc42308f06f60b130 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 16:09:23 +0000 Subject: [PATCH 04/17] fix: accept backup codes with or without dashes --- packages/features/auth/lib/next-auth-options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 01e82098974d79..7fd3ffbcafbdf1 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -141,7 +141,7 @@ const providers: Provider[] = [ ); // check if user-supplied code matches one - const index = backupCodes.indexOf(credentials.backupCode); + const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", "")); if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode); // delete verified backup code and re-encrypt remaining From 03dcdf849b358b5a9b30fe8037a4ec63822c7fb9 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 16:18:39 +0000 Subject: [PATCH 05/17] fix: backup code download content --- apps/web/components/settings/EnableTwoFactorModal.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index 539e8db841ff50..ccef08a50892a8 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -92,13 +92,11 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable setBackupCodes(body.backupCodes); // create backup codes download url - const textBlob = new Blob([backupCodes.map(formatBackupCode).join("\n")], { + const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], { type: "text/plain", }); - if (backupCodesUrl) { - window.URL.revokeObjectURL(backupCodesUrl); - } - setBackupCodesUrl(window.URL.createObjectURL(textBlob)); + if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl); + setBackupCodesUrl(URL.createObjectURL(textBlob)); setDataUri(body.dataUri); setSecret(body.secret); From d15cbecabc90baf02abf23f627653b7b5aba6cfe Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 16:20:33 +0000 Subject: [PATCH 06/17] fix: backup code button label --- apps/web/components/settings/EnableTwoFactorModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index ccef08a50892a8..bb3f4a3ba6bacd 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -283,7 +283,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable resetState(); onEnable(); }}> - {t("finish")} + {t("close")} From 67ae116f9bfcd58cb90d1013eedfe9d812d7a15e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 4 Aug 2023 16:27:39 +0000 Subject: [PATCH 07/17] fix: 2fa test --- apps/web/playwright/login.2fa.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/playwright/login.2fa.e2e.ts b/apps/web/playwright/login.2fa.e2e.ts index 5aff1299499ca9..37db0737191dda 100644 --- a/apps/web/playwright/login.2fa.e2e.ts +++ b/apps/web/playwright/login.2fa.e2e.ts @@ -103,6 +103,9 @@ test.describe("2FA Tests", async () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: secret! }); + // close backup code dialog + await page.click('button[type="submit"]'); + await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible(); return user; From ebd4d002151f75fc8efc38f733ef5e480401bba9 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 7 Aug 2023 21:21:59 +0000 Subject: [PATCH 08/17] fix: buildUser return type --- packages/lib/test/builder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 20962a69ffc15d..43c25e40776dae 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -189,6 +189,7 @@ export const buildUser = >(user?: T): UserPayload availability: [], avatar: "", away: false, + backupCodes: null, bio: null, brandColor: "#292929", bufferTime: 0, From f9f6c9817db88f8f74113585411dd657d30ef7fe Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 8 Aug 2023 08:35:42 +0000 Subject: [PATCH 09/17] fix: replace hardcoded strings --- apps/web/components/settings/EnableTwoFactorModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index bb3f4a3ba6bacd..4851b868d5d797 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -261,7 +261,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable <> - + - - + + + From ce2f0c59a536214f2526bff1a1e4000d0b1c3bce Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 8 Aug 2023 16:10:29 +0000 Subject: [PATCH 15/17] fix: allow using backup codes to disable 2fa --- .../settings/DisableTwoFactorModal.tsx | 42 +++++++++++++++---- .../components/settings/TwoFactorAuthAPI.ts | 4 +- .../pages/api/auth/two-factor/totp/disable.ts | 26 +++++++++++- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/apps/web/components/settings/DisableTwoFactorModal.tsx b/apps/web/components/settings/DisableTwoFactorModal.tsx index 385e775f320bf2..46d49ce62aa540 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/TwoFactorAuthAPI.ts b/apps/web/components/settings/TwoFactorAuthAPI.ts index 35ef6305759ec9..1ea7792e87524f 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 9f4db0f7017c73..fecb75d92f2d06 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); From 34615fe1e341aaa539ea261f3754d585c2d1fd5d Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 8 Aug 2023 17:12:51 +0000 Subject: [PATCH 16/17] fix: set backup code input min and max lengths --- apps/web/components/auth/BackupCode.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/components/auth/BackupCode.tsx b/apps/web/components/auth/BackupCode.tsx index 7a49da7396db26..a9121d815ebda5 100644 --- a/apps/web/components/auth/BackupCode.tsx +++ b/apps/web/components/auth/BackupCode.tsx @@ -19,6 +19,8 @@ export default function TwoFactor({ center = true }) { label="" defaultValue="" placeholder="XXXXX-XXXXX" + minLength={10} // without dash + maxLength={11} // with dash required {...methods.register("backupCode")} /> From 2a498d7f2ee013220b48271dfad7263c768f2fda Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 8 Aug 2023 18:54:18 +0000 Subject: [PATCH 17/17] test: fix 2fa e2e, check download and copy --- .../settings/EnableTwoFactorModal.tsx | 6 +++++- apps/web/playwright/login.2fa.e2e.ts | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/components/settings/EnableTwoFactorModal.tsx b/apps/web/components/settings/EnableTwoFactorModal.tsx index 8ee7bd5d9f5f14..0ed406787faa22 100644 --- a/apps/web/components/settings/EnableTwoFactorModal.tsx +++ b/apps/web/components/settings/EnableTwoFactorModal.tsx @@ -262,6 +262,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable <> - + diff --git a/apps/web/playwright/login.2fa.e2e.ts b/apps/web/playwright/login.2fa.e2e.ts index 37db0737191dda..6b1e588c4cce54 100644 --- a/apps/web/playwright/login.2fa.e2e.ts +++ b/apps/web/playwright/login.2fa.e2e.ts @@ -9,6 +9,8 @@ import { test } from "./lib/fixtures"; test.describe.configure({ mode: "parallel" }); +// TODO: add more backup code tests, e.g. login + disabling 2fa with backup + // a test to logout requires both a succesfull login as logout, to prevent // a doubling of tests failing on logout & logout, we can group them. test.describe("2FA Tests", async () => { @@ -45,6 +47,8 @@ test.describe("2FA Tests", async () => { secret: secret!, }); + // FIXME: this passes even when switch is not checked, compare to test + // below which checks for data-state="checked" and works as expected await page.waitForSelector(`[data-testid=two-factor-switch]`); await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy(); @@ -103,8 +107,22 @@ test.describe("2FA Tests", async () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await fillOtp({ page, secret: secret! }); + // backup codes are now showing, so run a few tests + + // click download button + const promise = page.waitForEvent("download"); + await page.getByTestId("backup-codes-download").click(); + const download = await promise; + expect(download.suggestedFilename()).toBe("cal-backup-codes.txt"); + // TODO: check file content + + // click copy button + await page.getByTestId("backup-codes-copy").click(); + await page.getByTestId("toast-success").waitFor(); + // TODO: check clipboard content + // close backup code dialog - await page.click('button[type="submit"]'); + await page.getByTestId("backup-codes-close").click(); await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();