diff --git a/.github/workflows/add-PRs-to-project-reviewing-PRs.yml b/.github/workflows/add-PRs-to-project-reviewing-PRs.yml deleted file mode 100644 index 9a814a743a..0000000000 --- a/.github/workflows/add-PRs-to-project-reviewing-PRs.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Add PRs to project Reviewing PRs - -on: - pull_request: - types: - - opened - -jobs: - add-PR-to-project: - name: Add PRs to project Reviewing PRs - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v0.1.0 - with: - project-url: https://github.com/orgs/calcom/projects/11 - github-token: ${{ secrets.GH_ACCESS_TOKEN }} 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/dialog/EditLocationDialog.tsx b/apps/web/components/dialog/EditLocationDialog.tsx index b962d55971..c1be998af3 100644 --- a/apps/web/components/dialog/EditLocationDialog.tsx +++ b/apps/web/components/dialog/EditLocationDialog.tsx @@ -382,24 +382,22 @@ export const EditLocationDialog = (props: ISetLocationDialog) => { }} /> {selectedLocation && LocationOptions} - -
- + + - -
+
diff --git a/apps/web/components/dialog/RescheduleDialog.tsx b/apps/web/components/dialog/RescheduleDialog.tsx index 6109bb88be..973bf59cbf 100644 --- a/apps/web/components/dialog/RescheduleDialog.tsx +++ b/apps/web/components/dialog/RescheduleDialog.tsx @@ -41,7 +41,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => { return ( - +
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/components/setup/AdminUser.tsx b/apps/web/components/setup/AdminUser.tsx index 9d50b1f87a..4dce0f9934 100644 --- a/apps/web/components/setup/AdminUser.tsx +++ b/apps/web/components/setup/AdminUser.tsx @@ -57,12 +57,10 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on }), }); - const formMethods = useForm<{ - username: string; - email_address: string; - full_name: string; - password: string; - }>({ + type formSchemaType = z.infer; + + const formMethods = useForm({ + mode: "onChange", resolver: zodResolver(formSchema), }); @@ -70,7 +68,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on props.onError(); }; - const onSubmit = formMethods.handleSubmit(async (data: z.infer) => { + const onSubmit = formMethods.handleSubmit(async (data) => { props.onSubmit(); const response = await fetch("/api/auth/setup", { method: "POST", @@ -130,11 +128,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on className={classNames("my-0", longWebsiteUrl && "rounded-t-none")} onBlur={onBlur} name="username" - onChange={async (e) => { - onChange(e.target.value); - formMethods.setValue("username", e.target.value); - await formMethods.trigger("username"); - }} + onChange={(e) => onChange(e.target.value)} /> )} @@ -148,11 +142,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on { - onChange(e.target.value); - formMethods.setValue("full_name", e.target.value); - await formMethods.trigger("full_name"); - }} + onChange={(e) => onChange(e.target.value)} color={formMethods.formState.errors.full_name ? "warn" : ""} type="text" name="full_name" @@ -172,11 +162,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on { - onChange(e.target.value); - formMethods.setValue("email_address", e.target.value); - await formMethods.trigger("email_address"); - }} + onChange={(e) => onChange(e.target.value)} className="my-0" name="email_address" /> @@ -191,11 +177,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on { - onChange(e.target.value); - formMethods.setValue("password", e.target.value); - await formMethods.trigger("password"); - }} + onChange={(e) => onChange(e.target.value)} hintErrors={["caplow", "admin_min", "num"]} name="password" className="my-0" diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index f53421fb9c..f44c231fb3 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,5 +1,4 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername"; @@ -11,18 +10,9 @@ import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { validateUsernameInTeam, validateUsername } from "@calcom/lib/validateUsername"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; +import { signupSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; -const signupSchema = z.object({ - username: z.string().refine((value) => !value.includes("+"), { - message: "String should not contain a plus symbol (+).", - }), - email: z.string().email(), - password: z.string().min(7), - language: z.string().optional(), - token: z.string().optional(), -}); - export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); 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 && } + ), + isEnabled: true, + }, + { title: "Step 3", description: "Description 3", content:

Step 3

}, +]; + +const props = { + href: "/test/mock", + steps: steps, + nextLabel: "Next step", + prevLabel: "Previous step", + finishLabel: "Finish", +}; + +let currentStepNavigation: number; + +const renderComponent = (extraProps?: { disableNavigation: boolean }) => + render(); + +describe("Tests for WizardForm component", () => { + test("Should handle all the steps correctly", async () => { + currentStepNavigation = 1; + const { queryByTestId, queryByText, rerender } = renderComponent(); + const { prevLabel, nextLabel, finishLabel } = props; + const stepInfo = { + title: queryByTestId("step-title"), + description: queryByTestId("step-description"), + }; + + await waitFor(() => { + steps.forEach((step, index) => { + rerender(); + + const { title, description } = step; + const buttons = { + prev: queryByText(prevLabel), + next: queryByText(nextLabel), + finish: queryByText(finishLabel), + }; + + expect(stepInfo.title).toHaveTextContent(title); + expect(stepInfo.description).toHaveTextContent(description); + + if (index === 0) { + // case of first step + expect(buttons.prev && buttons.finish).not.toBeInTheDocument(); + expect(buttons.next).toBeInTheDocument(); + } else if (index === steps.length - 1) { + // case of last step + expect(buttons.prev && buttons.finish).toBeInTheDocument(); + expect(buttons.next).not.toBeInTheDocument(); + } else { + // case of in-between steps + expect(buttons.prev && buttons.next).toBeInTheDocument(); + expect(buttons.finish).not.toBeInTheDocument(); + } + + currentStepNavigation++; + }); + }); + }); + + describe("Should handle the visibility of the content", async () => { + test("Should render JSX content correctly", async () => { + currentStepNavigation = 1; + const { getByTestId, getByText } = renderComponent(); + const currentStep = steps[0]; + + expect(getByTestId("content-1")).toBeInTheDocument(); + expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument(); + }); + + test("Should render function content correctly", async () => { + currentStepNavigation = 2; + const { getByTestId, getByText } = renderComponent(); + const currentStep = steps[1]; + + expect(getByTestId("content-2")).toBeInTheDocument(); + expect(getByText(currentStep.title && currentStep.description)).toBeInTheDocument(); + }); + }); + + test("Should disable 'Next step' button if current step navigation is not enabled", async () => { + currentStepNavigation = 1; + const { nextLabel } = props; + const { getByText } = renderComponent(); + + expect(getByText(nextLabel)).toBeDisabled(); + }); + + test("Should handle when navigation is disabled", async () => { + const { queryByText, queryByTestId } = renderComponent({ disableNavigation: true }); + const { prevLabel, nextLabel, finishLabel } = props; + const stepComponent = queryByTestId("wizard-step-component"); + const stepInfo = { + title: queryByTestId("step-title"), + description: queryByTestId("step-description"), + }; + const buttons = { + prev: queryByText(prevLabel), + next: queryByText(nextLabel), + finish: queryByText(finishLabel), + }; + + expect(stepInfo.title && stepInfo.description).toBeInTheDocument(); + expect(stepComponent).not.toBeInTheDocument(); + expect(buttons.prev && buttons.next && buttons.finish).not.toBeInTheDocument(); + }); +});