diff --git a/apps/web/components/auth/TwoFactor.tsx b/apps/web/components/auth/TwoFactor.tsx index 28a5627ff3..6e06e23b6b 100644 --- a/apps/web/components/auth/TwoFactor.tsx +++ b/apps/web/components/auth/TwoFactor.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useState } from "react"; import useDigitInput from "react-digit-input"; import { useFormContext } from "react-hook-form"; -import { Input } from "@calcom/ui/form/fields"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Label, Input } from "@calcom/ui/form/fields"; -import { useLocale } from "@lib/hooks/useLocale"; - -export default function TwoFactor() { +export default function TwoFactor({ center = true }) { const [value, onChange] = useState(""); const { t } = useLocale(); const methods = useFormContext(); @@ -26,7 +25,9 @@ export default function TwoFactor() { const className = "h-12 w-12 !text-xl text-center"; return ( -
+
+ +

{t("2fa_enabled_instructions")}

diff --git a/apps/web/components/security/DisableTwoFactorModal.tsx b/apps/web/components/security/DisableTwoFactorModal.tsx index 2ed6305e9a..79739eb5c5 100644 --- a/apps/web/components/security/DisableTwoFactorModal.tsx +++ b/apps/web/components/security/DisableTwoFactorModal.tsx @@ -1,10 +1,14 @@ -import { SyntheticEvent, useState } from "react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { ErrorCode } from "@calcom/lib/auth"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { Dialog, DialogContent } from "@calcom/ui/Dialog"; +import { Form, Label } from "@calcom/ui/form/fields"; +import { PasswordField } from "@calcom/ui/v2/core/form/fields"; -import { ErrorCode } from "@lib/auth"; -import { useLocale } from "@lib/hooks/useLocale"; +import TwoFactor from "@components/auth/TwoFactor"; import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; import TwoFactorModalHeader from "./TwoFactorModalHeader"; @@ -16,15 +20,17 @@ interface DisableTwoFactorAuthModalProps { onDisable: () => void; } +interface DisableTwoFactorValues { + totpCode: string; + password: string; +} + const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => { - const [password, setPassword] = useState(""); const [isDisabling, setIsDisabling] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const { t } = useLocale(); - - async function handleDisable(e: SyntheticEvent) { - e.preventDefault(); - + const form = useForm(); + async function handleDisable({ totpCode, password }: DisableTwoFactorValues) { if (isDisabling) { return; } @@ -32,7 +38,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth setErrorMessage(null); try { - const response = await TwoFactorAuthAPI.disable(password); + const response = await TwoFactorAuthAPI.disable(password, totpCode); if (response.status === 200) { onDisable(); return; @@ -41,6 +47,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth const body = await response.json(); if (body.error === ErrorCode.IncorrectPassword) { setErrorMessage(t("incorrect_password")); + } + if (body.error === ErrorCode.SecondFactorRequired) { + setErrorMessage(t("2fa_required")); + } + if (body.error === ErrorCode.IncorrectTwoFactorCode) { + setErrorMessage(t("incorrect_2fa")); } else { setErrorMessage(t("something_went_wrong")); } @@ -55,41 +67,32 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth return ( - +
+ -
- -
- setPassword(e.currentTarget.value)} - className="block w-full rounded-sm border-gray-300 text-sm" - /> -
+ + + {errorMessage &&

{errorMessage}

}
- -
- - -
+
+ + +
+
); diff --git a/apps/web/components/security/EnableTwoFactorModal.tsx b/apps/web/components/security/EnableTwoFactorModal.tsx index e10a408129..8ebc95cac7 100644 --- a/apps/web/components/security/EnableTwoFactorModal.tsx +++ b/apps/web/components/security/EnableTwoFactorModal.tsx @@ -1,10 +1,13 @@ -import React, { SyntheticEvent, useState } from "react"; +import React, { BaseSyntheticEvent, useState } from "react"; +import { useForm } from "react-hook-form"; +import { ErrorCode } from "@calcom/lib/auth"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import Button from "@calcom/ui/Button"; import { Dialog, DialogContent } from "@calcom/ui/Dialog"; +import { Form } from "@calcom/ui/v2/core/form/fields"; -import { ErrorCode } from "@lib/auth"; -import { useLocale } from "@lib/hooks/useLocale"; +import TwoFactor from "@components/auth/TwoFactor"; import TwoFactorAuthAPI from "./TwoFactorAuthAPI"; import TwoFactorModalHeader from "./TwoFactorModalHeader"; @@ -39,8 +42,14 @@ const WithStep = ({ return step === current ? children : null; }; +interface EnableTwoFactorValues { + totpCode: string; +} + const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => { const { t } = useLocale(); + const form = useForm(); + const setupDescriptions = { [SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"), [SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"), @@ -48,13 +57,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) }; const [step, setStep] = useState(SetupStep.ConfirmPassword); const [password, setPassword] = useState(""); - const [totpCode, setTotpCode] = useState(""); const [dataUri, setDataUri] = useState(""); const [secret, setSecret] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - async function handleSetup(e: SyntheticEvent) { + async function handleSetup(e: React.FormEvent) { e.preventDefault(); if (isSubmitting) { @@ -88,10 +96,10 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) } } - async function handleEnable(e: SyntheticEvent) { - e.preventDefault(); + async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) { + e?.preventDefault(); - if (isSubmitting || totpCode.length !== 6) { + if (isSubmitting) { return; } @@ -158,64 +166,43 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)

{secret}

- -
+ +
- -
- setTotpCode(e.currentTarget.value)} - className="block w-full rounded-sm border-gray-300 text-sm" - autoComplete="one-time-code" - /> -
+ {errorMessage &&

{errorMessage}

}
- -
+
-
- - + + + + + + + + - - - - - - - - -
+
+ ); diff --git a/apps/web/components/security/TwoFactorAuthAPI.ts b/apps/web/components/security/TwoFactorAuthAPI.ts index eb01d59c4c..35ef630575 100644 --- a/apps/web/components/security/TwoFactorAuthAPI.ts +++ b/apps/web/components/security/TwoFactorAuthAPI.ts @@ -19,10 +19,10 @@ const TwoFactorAuthAPI = { }); }, - async disable(password: string) { + async disable(password: string, code: string) { return fetch("/api/auth/two-factor/totp/disable", { method: "POST", - body: JSON.stringify({ password }), + body: JSON.stringify({ password, code }), headers: { "Content-Type": "application/json", }, diff --git a/apps/web/components/security/TwoFactorModalHeader.tsx b/apps/web/components/security/TwoFactorModalHeader.tsx index 65474e55c4..1df439cc74 100644 --- a/apps/web/components/security/TwoFactorModalHeader.tsx +++ b/apps/web/components/security/TwoFactorModalHeader.tsx @@ -6,7 +6,7 @@ const TwoFactorModalHeader = ({ title, description }: { title: string; descripti return (
- +
- {twoFactorRequired && } + {twoFactorRequired && } {errorMessage && }
diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index dd2f1e9140..ab1b28241f 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -2,7 +2,17 @@ import crypto from "crypto"; import { GetServerSidePropsContext } from "next"; import { signOut } from "next-auth/react"; import { useRouter } from "next/router"; -import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { + ComponentProps, + RefObject, + FormEvent, + useEffect, + useMemo, + useRef, + useState, + BaseSyntheticEvent, +} from "react"; +import { useForm } from "react-hook-form"; import TimezoneSelect, { ITimezone } from "react-timezone-select"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -17,16 +27,19 @@ import Button from "@calcom/ui/Button"; import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent"; import { Dialog, DialogTrigger } from "@calcom/ui/Dialog"; import { Icon } from "@calcom/ui/Icon"; +import { Form, PasswordField } from "@calcom/ui/form/fields"; +import { Label } from "@calcom/ui/form/fields"; import { withQuery } from "@lib/QueryCell"; import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull"; -import { getSession } from "@lib/auth"; +import { ErrorCode, getSession } from "@lib/auth"; import { nameOfDay } from "@lib/core/i18n/weekday"; import { isBrandingHidden } from "@lib/isBrandingHidden"; import { inferSSRProps } from "@lib/types/inferSSRProps"; import ImageUploader from "@components/ImageUploader"; import SettingsShell from "@components/SettingsShell"; +import TwoFactor from "@components/auth/TwoFactor"; import Avatar from "@components/ui/Avatar"; import InfoBadge from "@components/ui/InfoBadge"; import { UsernameAvailability } from "@components/ui/UsernameAvailability"; @@ -68,9 +81,14 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject ); } +interface DeleteAccountValues { + totpCode: string; +} function SettingsView(props: ComponentProps & { localeProp: string }) { const { user } = props; + const form = useForm(); + const { t } = useLocale(); const router = useRouter(); const utils = trpc.useContext(); @@ -93,15 +111,11 @@ function SettingsView(props: ComponentProps & { localeProp: str }, }); - const deleteAccount = async () => { - await fetch("/api/user/me", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - }).catch((e) => { - console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e); - }); + const onDeleteMeSuccessMutation = async () => { + await utils.invalidateQueries(["viewer.me"]); + showToast(t("Your account was deleted"), "success"); + + setHasDeleteErrors(false); // dismiss any open errors if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") { signOut({ callbackUrl: "/auth/logout?survey=true" }); } else { @@ -109,6 +123,18 @@ function SettingsView(props: ComponentProps & { localeProp: str } }; + const onDeleteMeErrorMutation = (error: TRPCClientErrorLike) => { + setHasDeleteErrors(true); + setDeleteErrorMessage(errorMessages[error.message]); + }; + const deleteMeMutation = trpc.useMutation("viewer.deleteMe", { + onSuccess: onDeleteMeSuccessMutation, + onError: onDeleteMeErrorMutation, + async onSettled() { + await utils.invalidateQueries(["viewer.me"]); + }, + }); + const localeOptions = useMemo(() => { return (router.locales || []).map((locale) => ({ value: locale, @@ -126,6 +152,7 @@ function SettingsView(props: ComponentProps & { localeProp: str { value: 24, label: t("24_hour") }, ]; const usernameRef = useRef(null!); + const passwordRef = useRef(null!); const nameRef = useRef(null!); const emailRef = useRef(null!); const descriptionRef = useRef(null!); @@ -149,7 +176,19 @@ function SettingsView(props: ComponentProps & { localeProp: str }); const [imageSrc, setImageSrc] = useState(user.avatar || ""); const [hasErrors, setHasErrors] = useState(false); + const [hasDeleteErrors, setHasDeleteErrors] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + + const errorMessages: { [key: string]: string } = { + [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"), + [ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`, + [ErrorCode.UserNotFound]: t("no_account_exists"), + [ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`, + [ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`, + [ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"), + }; + + const [deleteErrorMessage, setDeleteErrorMessage] = useState(""); const [brandColor, setBrandColor] = useState(user.brandColor); const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor); @@ -161,6 +200,17 @@ function SettingsView(props: ComponentProps & { localeProp: str // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const onConfirmButton = (e: FormEvent) => { + e.preventDefault(); + const totpCode = form.getValues("totpCode"); + const password = passwordRef.current.value; + deleteMeMutation.mutate({ password, totpCode }); + }; + const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => { + e?.preventDefault(); + const password = passwordRef.current.value; + deleteMeMutation.mutate({ password, totpCode }); + }; async function updateProfileHandler(event: FormEvent) { event.preventDefault(); @@ -487,8 +537,26 @@ function SettingsView(props: ComponentProps & { localeProp: str {t("confirm_delete_account")} } - onConfirm={() => deleteAccount()}> - {t("delete_account_confirmation_message")} + onConfirm={onConfirmButton}> +

{t("delete_account_confirmation_message")}

+ + + {user.twoFactorEnabled && ( +
+ + + )} + + {hasDeleteErrors && }
@@ -547,6 +615,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => brandColor: true, darkBrandColor: true, metadata: true, + twoFactorEnabled: true, timeFormat: true, allowDynamicBooking: true, }, diff --git a/apps/web/playwright/auth/delete-account.test.ts b/apps/web/playwright/auth/delete-account.test.ts index 2bc1bb7a8c..821cf514ec 100644 --- a/apps/web/playwright/auth/delete-account.test.ts +++ b/apps/web/playwright/auth/delete-account.test.ts @@ -12,8 +12,9 @@ test("Can delete user account", async ({ page, users }) => { await page.goto(`/settings/profile`); await page.click("[data-testid=delete-account]"); - await expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible(); + if (!user.username) throw Error(`Test user doesn't have a username`); + await page.fill("[data-testid=password]", user.username); await Promise.all([ page.waitForNavigation({ url: "/auth/logout" }), diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index dbbbef9ddf..f7f54ef848 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1068,6 +1068,8 @@ "select_which_cal":"Select which calendar to add bookings to", "custom_event_name":"Custom event name", "custom_event_name_description":"Create customised event names to display on calendar event", + "2fa_required": "Two factor authentication required", + "incorrect_2fa": "Incorrect two factor authentication code", "which_event_type_apply": "Which event type will this apply to?", "no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.", "create_workflow": "Create a workflow", diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 2667985901..7a3359b1fa 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -1,9 +1,11 @@ -import { AppCategories, BookingStatus, MembershipRole, Prisma } from "@prisma/client"; +import { AppCategories, BookingStatus, IdentityProvider, MembershipRole, Prisma } from "@prisma/client"; import _ from "lodash"; +import { authenticator } from "otplib"; import { JSONObject } from "superjson/dist/types"; import { z } from "zod"; import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router"; +import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server"; import getApps, { getLocationOptions } from "@calcom/app-store/utils"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; @@ -12,6 +14,8 @@ import { DailyLocationType } from "@calcom/core/location"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; +import { ErrorCode, verifyPassword } from "@calcom/lib/auth"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import jackson from "@calcom/lib/jackson"; import { @@ -28,8 +32,8 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { isTeamOwner } from "@calcom/lib/server/queries/teams"; import slugify from "@calcom/lib/slugify"; import { - updateWebUser as syncServicesUpdateWebUser, deleteWebUser as syncServicesDeleteWebUser, + updateWebUser as syncServicesUpdateWebUser, } from "@calcom/lib/sync/SyncServiceManager"; import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma"; import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image"; @@ -108,18 +112,76 @@ const loggedInViewerRouter = createProtectedRouter() }, }) .mutation("deleteMe", { - async resolve({ ctx }) { - // Remove me from Stripe - - // Remove my account - const deletedUser = await ctx.prisma.user.delete({ + input: z.object({ + password: z.string(), + totpCode: z.string().optional(), + }), + async resolve({ input, ctx }) { + // Check if input.password is correct + const user = await prisma.user.findUnique({ where: { - id: ctx.user.id, + email: ctx.user.email.toLowerCase(), }, }); + if (!user) { + throw new Error(ErrorCode.UserNotFound); + } - // Sync Services - syncServicesDeleteWebUser(deletedUser); + if (user.identityProvider !== IdentityProvider.CAL) { + throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled); + } + + if (!user.password) { + throw new Error(ErrorCode.UserMissingPassword); + } + + const isCorrectPassword = await verifyPassword(input.password, user.password); + if (!isCorrectPassword) { + throw new Error(ErrorCode.IncorrectPassword); + } + + if (user.twoFactorEnabled) { + if (!input.totpCode) { + throw new Error(ErrorCode.SecondFactorRequired); + } + + if (!user.twoFactorSecret) { + console.error(`Two factor is enabled for user ${user.id} but they have no secret`); + throw new Error(ErrorCode.InternalServerError); + } + + if (!process.env.CALENDSO_ENCRYPTION_KEY) { + console.error(`"Missing encryption key; cannot proceed with two factor login."`); + throw new Error(ErrorCode.InternalServerError); + } + + const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY); + if (secret.length !== 32) { + console.error( + `Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}` + ); + throw new Error(ErrorCode.InternalServerError); + } + + const isValidToken = authenticator.check(input.totpCode, secret); + if (!isValidToken) { + throw new Error(ErrorCode.IncorrectTwoFactorCode); + } + // If user has 2fa enabled, check if input.totpCode is correct + // If it is, delete the user from stripe and database + + // Remove me from Stripe + await deleteStripeCustomer(user).catch(console.warn); + + // Remove my account + const deletedUser = await ctx.prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + // Sync Services + syncServicesDeleteWebUser(deletedUser); + } return; }, @@ -1280,6 +1342,7 @@ const loggedInViewerRouter = createProtectedRouter() export const viewerRouter = createRouter() .merge("public.", publicViewerRouter) .merge(loggedInViewerRouter) + .merge("auth.", authRouter) .merge("bookings.", bookingsRouter) .merge("eventTypes.", eventTypesRouter) .merge("availability.", availabilityRouter) @@ -1288,7 +1351,6 @@ export const viewerRouter = createRouter() .merge("apiKeys.", apiKeysRouter) .merge("slots.", slotsRouter) .merge("workflows.", workflowsRouter) - .merge("auth.", authRouter) // NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved. // After that there would just one merge call here for all the apps. diff --git a/packages/trpc/server/routers/viewer/auth.tsx b/packages/trpc/server/routers/viewer/auth.tsx index 2e1d73f033..e8959d4360 100644 --- a/packages/trpc/server/routers/viewer/auth.tsx +++ b/packages/trpc/server/routers/viewer/auth.tsx @@ -1,7 +1,7 @@ import { IdentityProvider } from "@prisma/client"; import { z } from "zod"; -import { hashPassword, verifyPassword, validPassword } from "@calcom/lib/auth"; +import { hashPassword, validPassword, verifyPassword } from "@calcom/lib/auth"; import prisma from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; diff --git a/packages/ui/form/fields.tsx b/packages/ui/form/fields.tsx index 79cb3b578b..af8c3431b0 100644 --- a/packages/ui/form/fields.tsx +++ b/packages/ui/form/fields.tsx @@ -104,7 +104,9 @@ export const PasswordField = forwardRef(funct props, ref ) { - return ; + return ( + + ); }); export const EmailInput = forwardRef(function EmailInput(props, ref) {