fix: RateLimit verify email and fix (#23718)

* fix: RateLimit verify email

* update

* client chnages

* type error

* fix

* add test

* Update event-types.e2e.ts

* minor update

* Update event-types.e2e.ts

* fix test

* handle it in api v2
This commit is contained in:
Anik Dhabal Babu
2025-09-12 18:27:05 +01:00
committed by GitHub
parent 79169de8d8
commit ee70111c45
14 changed files with 456 additions and 22 deletions
@@ -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,
};
}
@@ -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.",
@@ -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()
@@ -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,
@@ -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,
};
};
@@ -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<typeof bookingCreateBodySchema>;
@@ -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;
}
@@ -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
);
});
});
+5 -3
View File
@@ -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;
}
return `${hasher.hash(localPart)}@${domain}`;
};
@@ -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);
@@ -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 {
@@ -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;
}
@@ -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;
};
@@ -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" });
}
};