diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index bbafd10e8f..de185c48d6 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -176,6 +176,7 @@ export class InputBookingsService_2024_08_13 { metadata: inputBooking.metadata || {}, hasHashedBookingLink: false, guests, + verificationCode: inputBooking.emailVerificationCode, // note(Lauris): responses with name and email are required by the handleNewBooking responses: { ...(inputBooking.bookingFieldsResponses || {}), @@ -471,6 +472,7 @@ export class InputBookingsService_2024_08_13 { metadata: inputBooking.metadata || {}, hasHashedBookingLink: false, guests, + verificationCode: inputBooking.emailVerificationCode, // note(Lauris): responses with name and email are required by the handleNewBooking responses: { ...(inputBooking.bookingFieldsResponses || {}), @@ -600,6 +602,7 @@ export class InputBookingsService_2024_08_13 { guests: [], responses: { ...bookingResponses }, rescheduleUid: inputBooking.seatUid, + verificationCode: inputBooking.emailVerificationCode, }; } @@ -661,6 +664,7 @@ export class InputBookingsService_2024_08_13 { guests: bookingResponses.guests, responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason }, rescheduleUid: bookingUid, + verificationCode: inputBooking.emailVerificationCode, }; } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 08b937b7f1..31e858bb57 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3635,6 +3635,8 @@ "user_name": "User Name", "expand_panel": "Expand Panel", "collapse_panel": "Collapse Panel", + "email_verification_required": "Email verification is required for this event type", + "invalid_verification_code": "Invalid verification code provided", "you_have_one_team": "You have one team", "consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.", "consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.", diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index fba42cead2..39d97e5b40 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -62,6 +62,12 @@ export type BookerStore = { */ verifiedEmail: string | null; setVerifiedEmail: (email: string | null) => void; + /** + * Verification code for email verification. + * Stored after successful verification to be included in booking request + */ + verificationCode: string | null; + setVerificationCode: (code: string | null) => void; /** * Current month being viewed. Format is YYYY-MM. */ @@ -251,6 +257,10 @@ export const createBookerStore = () => setVerifiedEmail: (email: string | null) => { set({ verifiedEmail: email }); }, + verificationCode: null, + setVerificationCode: (code: string | null) => { + set({ verificationCode: code }); + }, month: getQueryParam("month") || (getQueryParam("date") && dayjs(getQueryParam("date")).isValid() diff --git a/packages/features/bookings/components/VerifyCodeDialog.tsx b/packages/features/bookings/components/VerifyCodeDialog.tsx index 11bdc3e611..d25215ddc4 100644 --- a/packages/features/bookings/components/VerifyCodeDialog.tsx +++ b/packages/features/bookings/components/VerifyCodeDialog.tsx @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useCallback, useEffect, useState } from "react"; import useDigitInput from "react-digit-input"; +import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { Dialog } from "@calcom/features/components/controlled-dialog"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button } from "@calcom/ui/components/button"; @@ -36,6 +37,7 @@ export const VerifyCodeDialog = ({ const { t } = useLocale(); const [value, setValue] = useState(""); const [hasVerified, setHasVerified] = useState(false); + const setVerificationCode = useBookerStoreContext((state) => state.setVerificationCode); const digits = useDigitInput({ acceptedCharacters: /^[0-9]$/, @@ -56,6 +58,7 @@ export const VerifyCodeDialog = ({ } else { verifyCodeWithSessionNotRequired(value, email); } + setVerificationCode(value); setHasVerified(true); }, [ resetErrors, diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index 158c4576a3..caadc7c3e9 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -31,6 +31,7 @@ export type BookingOptions = { orgSlug?: string; routingFormSearchParams?: RoutingFormSearchParams; isDryRunProp?: boolean; + verificationCode?: string; }; export const mapBookingToMutationInput = ({ @@ -54,6 +55,7 @@ export const mapBookingToMutationInput = ({ orgSlug, routingFormSearchParams, isDryRunProp, + verificationCode, }: BookingOptions): BookingCreateBody => { const searchParams = new URLSearchParams(routingFormSearchParams ?? window.location.search); const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams); @@ -98,6 +100,7 @@ export const mapBookingToMutationInput = ({ _isDryRun, _shouldServeCache, dub_id, + verificationCode, }; }; diff --git a/packages/features/bookings/lib/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index 79b54fbf09..9da43a9239 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -52,6 +52,7 @@ export const bookingCreateBodySchema = z.object({ .optional(), dub_id: z.string().nullish(), creationSource: z.nativeEnum(CreationSource).optional(), + verificationCode: z.string().optional(), }); export type BookingCreateBody = z.input; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 8556326ac8..97ee75a97d 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -78,6 +78,7 @@ import { CreationSource, } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/util"; import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { AdditionalInformation, @@ -499,6 +500,25 @@ async function handler( }); } + if (eventType.requiresBookerEmailVerification) { + const verificationCode = reqBody.verificationCode; + if (!verificationCode) { + throw new HttpError({ + statusCode: 400, + message: "email_verification_required", + }); + } + + try { + await verifyCodeUnAuthenticated(bookerEmail, verificationCode); + } catch (error) { + throw new HttpError({ + statusCode: 400, + message: "invalid_verification_code", + }); + } + } + if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) { logger.settings.minLevel = 0; } diff --git a/packages/features/bookings/lib/handleNewBooking/test/email-verification-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/email-verification-booking.test.ts new file mode 100644 index 0000000000..07ff10c9e0 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/email-verification-booking.test.ts @@ -0,0 +1,343 @@ +/** + * These tests are unit tests that test the email verification functionality in the booking flow + * - Test missing verification code scenario + * - Test invalid verification code scenario + * - Test successful verification scenario + * - Test rate limiting behavior + */ +import { + createBookingScenario, + getBooker, + getOrganizer, + TestData, + getScenarioData, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { vi, describe, expect, beforeEach } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +vi.mock("@calcom/trpc/server/routers/viewer/auth/util", () => ({ + verifyCodeUnAuthenticated: vi.fn(), +})); + +const timeout = process.env.CI ? 5000 : 20000; + +describe("handleNewBooking - Email Verification", () => { + setupAndTeardown(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Email Verification Required", () => { + test( + "should throw error when email verification is required but no verification code is provided", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 30, + users: [ + { + id: 101, + }, + ], + requiresBookerEmailVerification: true, + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + await expect(handleNewBooking({ bookingData: mockBookingData })).rejects.toThrow( + expect.objectContaining({ + statusCode: 400, + message: "email_verification_required", + }) + ); + + expect(verifyCodeUnAuthenticated).not.toHaveBeenCalled(); + }, + timeout + ); + + test( + "should throw error when email verification is required and verification code is invalid", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + vi.mocked(verifyCodeUnAuthenticated).mockRejectedValue(new Error("Invalid verification code")); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 30, + users: [ + { + id: 101, + }, + ], + requiresBookerEmailVerification: true, + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + verificationCode: "invalid-code", + }, + }); + + await expect(handleNewBooking({ bookingData: mockBookingData })).rejects.toThrow( + expect.objectContaining({ + statusCode: 400, + message: "invalid_verification_code", + }) + ); + + expect(verifyCodeUnAuthenticated).toHaveBeenCalledWith(booker.email, "invalid-code"); + }, + timeout + ); + + test( + "should successfully create booking when email verification is required and valid verification code is provided", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + vi.mocked(verifyCodeUnAuthenticated).mockResolvedValue(undefined); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 30, + users: [ + { + id: 101, + }, + ], + requiresBookerEmailVerification: true, + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + verificationCode: "valid-code", + }, + }); + + const result = await handleNewBooking({ bookingData: mockBookingData }); + + expect(result).toBeDefined(); + expect(result.uid).toBeDefined(); + + expect(verifyCodeUnAuthenticated).toHaveBeenCalledWith(booker.email, "valid-code"); + }, + timeout + ); + + test( + "should handle rate limiting error from verification service", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + const rateLimitError = new Error("Rate limit exceeded"); + rateLimitError.name = "RateLimitError"; + vi.mocked(verifyCodeUnAuthenticated).mockRejectedValue(rateLimitError); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 30, + users: [ + { + id: 101, + }, + ], + requiresBookerEmailVerification: true, + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + verificationCode: "some-code", + }, + }); + + await expect(handleNewBooking({ bookingData: mockBookingData })).rejects.toThrow( + expect.objectContaining({ + statusCode: 400, + message: "invalid_verification_code", + }) + ); + + expect(verifyCodeUnAuthenticated).toHaveBeenCalledWith(booker.email, "some-code"); + }, + timeout + ); + + test( + "should proceed normally when email verification is not required", + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const { verifyCodeUnAuthenticated } = await import("@calcom/trpc/server/routers/viewer/auth/util"); + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 15, + length: 30, + users: [ + { + id: 101, + }, + ], + requiresBookerEmailVerification: false, + }, + ], + organizer, + }); + + await createBookingScenario(scenarioData); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const result = await handleNewBooking({ bookingData: mockBookingData }); + + expect(result).toBeDefined(); + expect(result.uid).toBeDefined(); + + expect(verifyCodeUnAuthenticated).not.toHaveBeenCalled(); + }, + timeout + ); + }); +}); diff --git a/packages/lib/server/PiiHasher.ts b/packages/lib/server/PiiHasher.ts index 95d9cc8647..3d89417d4f 100644 --- a/packages/lib/server/PiiHasher.ts +++ b/packages/lib/server/PiiHasher.ts @@ -7,7 +7,9 @@ export interface PiiHasher { export class Md5PiiHasher implements PiiHasher { constructor(private readonly salt: string) {} hash(input: string) { - return createHash("md5").update(this.salt + input).digest("hex"); + return createHash("md5") + .update(this.salt + input) + .digest("hex"); } } @@ -16,5 +18,5 @@ export const piiHasher: PiiHasher = new Md5PiiHasher(process.env.CALENDSO_ENCRYP export const hashEmail = (email: string, hasher: PiiHasher = piiHasher): string => { const [localPart, domain] = email.split("@"); // Simple hash function for email, can be replaced with a more complex one if needed - return hasher.hash(localPart) + "@" + domain; -} \ No newline at end of file + return `${hasher.hash(localPart)}@${domain}`; +}; diff --git a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts index b78c40d846..8260e05cb8 100644 --- a/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts +++ b/packages/platform/atoms/hooks/bookings/useHandleBookEvent.ts @@ -63,6 +63,7 @@ export const useHandleBookEvent = ({ const crmOwnerRecordType = useBookerStoreContext((state) => state.crmOwnerRecordType); const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug); const crmRecordId = useBookerStoreContext((state) => state.crmRecordId); + const verificationCode = useBookerStoreContext((state) => state.verificationCode); const handleError = (err: any) => { const errorMessage = err?.message ? t(err.message) : t("can_you_try_again"); showToast(errorMessage, "error"); @@ -113,6 +114,7 @@ export const useHandleBookEvent = ({ orgSlug: orgSlug ? orgSlug : undefined, routingFormSearchParams, isDryRunProp: isBookingDryRun, + verificationCode: verificationCode || undefined, }; const tracking = getUtmTrackingParameters(searchParams); diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts index b95e49ff74..205520cb3a 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts @@ -371,6 +371,15 @@ export class CreateBookingInput_2024_08_13 { @ValidateNested() @Type(() => Routing) routing?: Routing; + + @ApiPropertyOptional({ + type: String, + description: "Email verification code required when event type has email verification enabled.", + example: "123456", + }) + @IsOptional() + @IsString() + emailVerificationCode?: string; } export class CreateInstantBookingInput_2024_08_13 extends CreateBookingInput_2024_08_13 { diff --git a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts index 61d50bd06b..73fedc366f 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/reschedule-booking.input.ts @@ -27,6 +27,15 @@ export class RescheduleBookingInput_2024_08_13 { description: "Reason for rescheduling the booking", }) reschedulingReason?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: "Email verification code required when event type has email verification enabled.", + example: "123456", + }) + emailVerificationCode?: string; } export class RescheduleSeatedBookingInput_2024_08_13 { @@ -51,4 +60,13 @@ export class RescheduleSeatedBookingInput_2024_08_13 { }) @IsString() seatUid!: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ + type: String, + description: "Email verification code required when event type has email verification enabled.", + example: "123456", + }) + emailVerificationCode?: string; } diff --git a/packages/trpc/server/routers/viewer/auth/util.ts b/packages/trpc/server/routers/viewer/auth/util.ts new file mode 100644 index 0000000000..0b7be0643e --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/util.ts @@ -0,0 +1,28 @@ +import { createHash } from "crypto"; + +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; +import { hashEmail } from "@calcom/lib/server/PiiHasher"; +import { totpRawCheck } from "@calcom/lib/totp"; + +export const verifyCodeUnAuthenticated = async (email: string, code: string) => { + if (!email || !code) { + throw new Error("Email and code are required"); + } + + await checkRateLimitAndThrowError({ + rateLimitingType: "core", + identifier: `emailVerifyCode.${hashEmail(email)}`, + }); + + const secret = createHash("md5") + .update(email + (process.env.CALENDSO_ENCRYPTION_KEY || "")) + .digest("hex"); + + const isValidToken = totpRawCheck(code, secret, { step: 900 }); + + if (!isValidToken) { + throw new Error("Invalid verification code"); + } + + return; +}; diff --git a/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts b/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts index 27a7edcd5c..2cdd0b90f1 100644 --- a/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts +++ b/packages/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler.ts @@ -1,30 +1,19 @@ -import { createHash } from "crypto"; - -import { totpRawCheck } from "@calcom/lib/totp"; import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; +import { verifyCodeUnAuthenticated } from "./util"; + type VerifyTokenOptions = { input: ZVerifyCodeInputSchema; }; export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => { - return verifyCodeUnAuthenticated(input); -}; - -export const verifyCodeUnAuthenticated = async (input: ZVerifyCodeInputSchema) => { const { email, code } = input; - - if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" }); - - const secret = createHash("md5") - .update(email + process.env.CALENDSO_ENCRYPTION_KEY) - .digest("hex"); - - const isValidToken = totpRawCheck(code, secret, { step: 900 }); - - if (!isValidToken) throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" }); - - return isValidToken; + try { + await verifyCodeUnAuthenticated(email, code); + return true; + } catch (error) { + throw new TRPCError({ code: "BAD_REQUEST", message: "invalid_code" }); + } };