From ccff0045fcc61244a6e36f2f8a120b94407a3abf Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:21:11 +0530 Subject: [PATCH] fix: Add email verification requirement for API v1 and v2 email updates (#24988) * fix: Add email verification requirement for API v1 and v2 email updates - API v2 (/v2/me): Implement email verification check when updating primary email - Store new email in metadata as emailChangeWaitingForVerification when verification is required - Send verification email using sendChangeOfEmailVerification - Allow immediate email change if new email is already a verified secondary email - Add hasEmailBeenChanged and sendEmailVerification flags to response - Add tests to verify email verification behavior - Add findVerifiedSecondaryEmail method to UsersRepository - Add PrismaFeaturesRepository to MeModule providers - API v1 (/v1/users/{userId}): Implement same email verification logic - Check if email-verification feature flag is enabled - Store new email in metadata when verification is required - Send verification email for unverified email changes - Allow immediate change for verified secondary emails - Preserve existing API v1 response format - Test fixtures: Add createSecondaryEmail method to UserRepositoryFixture This fixes the vulnerability where users could update their primary email without verification via API endpoints. Co-Authored-By: anik@cal.com * update * update * refactor: Extract business logic to MeService and add platform-managed bypass - Create MeService with all business logic extracted from MeController - Update MeController to delegate to MeService (thin controller pattern) - Add PrismaWorkerModule to MeModule imports to provide PrismaWriteService - Add isPlatformManaged bypass: platform-managed users can update email without verification - Fix test cleanup to use delete by ID instead of email to avoid failures when email changes - Remove hasEmailBeenChanged and sendEmailVerification response flags per reviewer feedback - Add comprehensive documentation to @ApiOperation describing email verification behavior - Update tests to remove flag assertions Addresses PR comments: - supalarry: Extract logic to service layer - supalarry: Allow platform-managed users to update email without verification - supalarry: Remove response flags and document behavior in @ApiOperation instead - cubic-dev-ai: Fix module dependency for PrismaFeaturesRepository - cubic-dev-ai: Fix test cleanup issue Co-Authored-By: anik@cal.com * docs: Simplify API operation description Remove internal implementation details about metadata staging and shorten the description to focus on API consumer behavior. Co-Authored-By: anik@cal.com * fix * update * update * update * remove comment * addressed commit * review addressed * use repository --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../api/v1/pages/api/users/[userId]/_patch.ts | 65 ++++++++++++- .../v2/src/ee/me/me.controller.e2e-spec.ts | 44 ++++++++- apps/api/v2/src/ee/me/me.controller.ts | 26 +++-- apps/api/v2/src/ee/me/me.module.ts | 6 +- apps/api/v2/src/ee/me/services/me.service.ts | 94 +++++++++++++++++++ .../v2/src/modules/users/users.repository.ts | 44 +++++++++ .../repository/users.repository.fixture.ts | 10 ++ .../users/repositories/UserRepository.ts | 59 ++++++++++++ packages/platform/libraries/emails.ts | 6 +- 9 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 apps/api/v2/src/ee/me/services/me.service.ts diff --git a/apps/api/v1/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts index 3f1cf43a04..96553d1f43 100644 --- a/apps/api/v1/pages/api/users/[userId]/_patch.ts +++ b/apps/api/v1/pages/api/users/[userId]/_patch.ts @@ -1,5 +1,8 @@ import type { NextApiRequest } from "next"; +import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { HttpError } from "@calcom/lib/http-error"; import { uploadAvatar } from "@calcom/lib/server/avatar"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; @@ -130,10 +133,70 @@ export async function patchHandler(req: NextApiRequest) { }); } + const userRepository = new UserRepository(prisma); + const currentUser = await userRepository.findById({ id: query.userId }); + + if (!currentUser) { + throw new HttpError({ statusCode: 404, message: "User not found" }); + } + + const featuresRepository = new FeaturesRepository(prisma); + const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification"); + + const hasEmailBeenChanged = typeof body.email === "string" && currentUser.email !== body.email; + const newEmail = typeof body.email === "string" ? body.email : undefined; + + const prismaData: Prisma.UserUpdateInput = { ...body }; + + if (hasEmailBeenChanged && newEmail) { + const secondaryEmail = await userRepository.findSecondaryEmailByUserIdAndEmail({ + userId: query.userId, + email: newEmail, + }); + + if (emailVerification) { + if (secondaryEmail && secondaryEmail.emailVerified) { + const data = await userRepository.swapPrimaryEmailWithSecondaryEmail({ + userId: query.userId, + secondaryEmailId: secondaryEmail.id, + oldPrimaryEmail: currentUser.email, + oldPrimaryEmailVerified: currentUser.emailVerified, + newPrimaryEmail: newEmail, + userUpdateData: prismaData, + }); + + const user = schemaUserReadPublic.parse(data); + return { user }; + } else { + prismaData.metadata = { + ...(currentUser.metadata as Prisma.JsonObject), + ...(typeof prismaData.metadata === "object" && prismaData.metadata ? prismaData.metadata : {}), + emailChangeWaitingForVerification: newEmail.toLowerCase(), + }; + + delete prismaData.email; + } + } + } + const data = await prisma.user.update({ where: { id: query.userId }, - data: body, + data: prismaData, }); + + const shouldSendEmailVerification = + hasEmailBeenChanged && emailVerification && newEmail && !prismaData.email; + + if (shouldSendEmailVerification) { + await sendChangeOfEmailVerification({ + user: { + username: data.username ?? "Nameless User", + emailFrom: currentUser.email, + emailTo: newEmail, + }, + }); + } + const user = schemaUserReadPublic.parse(data); return { user }; } diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts index 0aa8f01832..a4e2520fdb 100644 --- a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -166,8 +166,50 @@ describe("Me Endpoints", () => { return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400); }); + it("should not update primary email without verification when email-verification is enabled", async () => { + const newEmail = `new-email-${randomString()}@api.com`; + const body: UpdateManagedUserInput = { email: newEmail }; + + return request(app.getHttpServer()) + .patch("/v2/me") + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.email).toEqual(user.email); + + const updatedUser = await userRepositoryFixture.get(user.id); + expect(updatedUser?.email).toEqual(user.email); + expect(updatedUser?.metadata).toHaveProperty("emailChangeWaitingForVerification", newEmail); + }); + }); + + it("should update primary email immediately when changing to a verified secondary email", async () => { + const verifiedSecondaryEmail = `verified-secondary-${randomString()}@api.com`; + + await userRepositoryFixture.createSecondaryEmail(user.id, verifiedSecondaryEmail, new Date()); + + const body: UpdateManagedUserInput = { email: verifiedSecondaryEmail }; + + return request(app.getHttpServer()) + .patch("/v2/me") + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.email).toEqual(verifiedSecondaryEmail); + + const updatedUser = await userRepositoryFixture.get(user.id); + expect(updatedUser?.email).toEqual(verifiedSecondaryEmail); + }); + }); + afterAll(async () => { - await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.delete(user.id); await organizationsRepositoryFixture.delete(org.id); await app.close(); }); diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts index 4ae533b887..16602654b4 100644 --- a/apps/api/v2/src/ee/me/me.controller.ts +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -1,6 +1,6 @@ import { GetMeOutput } from "@/ee/me/outputs/get-me.output"; import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output"; -import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { MeService } from "@/ee/me/services/me.service"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -9,7 +9,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UsersService } from "@/modules/users/services/users.service"; -import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; @@ -25,9 +25,8 @@ import { userSchemaResponse } from "@calcom/platform-types"; @ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER) export class MeController { constructor( - private readonly usersRepository: UsersRepository, - private readonly schedulesService: SchedulesService_2024_04_15, - private readonly usersService: UsersService + private readonly usersService: UsersService, + private readonly meService: MeService ) {} @Get("/") @@ -55,19 +54,18 @@ export class MeController { @Patch("/") @Permissions([PROFILE_WRITE]) - @ApiOperation({ summary: "Update my profile" }) + @ApiOperation({ + summary: "Update my profile", + description: + "Updates the authenticated user's profile. Email changes require verification and the primary email stays unchanged until verification completes, unless the new email is already a verified secondary email or the user is platform-managed.", + }) async updateMe( @GetUser() user: UserWithProfile, - @Body() bodySchedule: UpdateManagedUserInput + @Body() updateData: UpdateManagedUserInput ): Promise { - const updatedUser = await this.usersRepository.update(user.id, bodySchedule); - if (bodySchedule.timeZone && user.defaultScheduleId) { - await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, { - timeZone: bodySchedule.timeZone, - }); - } + const result = await this.meService.updateMe({ user, updateData }); - const me = userSchemaResponse.parse(updatedUser); + const me = userSchemaResponse.parse(result.updatedUser); return { status: SUCCESS_STATUS, diff --git a/apps/api/v2/src/ee/me/me.module.ts b/apps/api/v2/src/ee/me/me.module.ts index d762faba77..ccef7ad838 100644 --- a/apps/api/v2/src/ee/me/me.module.ts +++ b/apps/api/v2/src/ee/me/me.module.ts @@ -1,12 +1,16 @@ import { MeController } from "@/ee/me/me.controller"; +import { MeService } from "@/ee/me/services/me.service"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { PrismaWorkerModule } from "@/modules/prisma/prisma-worker.module"; import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; @Module({ - imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule], + imports: [PrismaWorkerModule, UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule], + providers: [PrismaFeaturesRepository, MeService], controllers: [MeController], }) export class MeModule {} diff --git a/apps/api/v2/src/ee/me/services/me.service.ts b/apps/api/v2/src/ee/me/services/me.service.ts new file mode 100644 index 0000000000..9320b4dcc5 --- /dev/null +++ b/apps/api/v2/src/ee/me/services/me.service.ts @@ -0,0 +1,94 @@ +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +import { sendChangeOfEmailVerification } from "@calcom/platform-libraries/emails"; +import type { Prisma } from "@calcom/prisma/client"; + +export interface UpdateMeResult { + updatedUser: UserWithProfile; +} + +@Injectable() +export class MeService { + constructor( + private readonly usersRepository: UsersRepository, + private readonly schedulesService: SchedulesService_2024_04_15, + private readonly featuresRepository: PrismaFeaturesRepository + ) {} + + async updateMe(params: { + user: UserWithProfile; + updateData: UpdateManagedUserInput; + }): Promise { + const { user, updateData } = params; + const update = { ...updateData }; + + if (update.timeZone && user.defaultScheduleId) { + await this.schedulesService.updateUserSchedule( + user, + user.defaultScheduleId, + { + timeZone: update.timeZone, + } + ); + } + + const isEmailVerificationEnabled = user.isPlatformManaged + ? false + : await this.featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification"); + + const hasEmailBeenChanged = update.email && user.email !== update.email; + const newEmail = update.email; + let sendEmailVerification = false; + + if (hasEmailBeenChanged && newEmail && isEmailVerificationEnabled) { + const secondaryEmail = await this.usersRepository.findVerifiedSecondaryEmail(user.id, newEmail); + + if (secondaryEmail && secondaryEmail.emailVerified) { + const updatedUser = + await this.usersRepository.swapPrimaryEmailWithSecondaryEmail( + user.id, + secondaryEmail.id, + user.email, + user.emailVerified, + newEmail + ); + + return { + updatedUser, + }; + } else { + update.metadata = { + ...(user.metadata as Prisma.JsonObject), + ...(update.metadata || {}), + emailChangeWaitingForVerification: newEmail.toLowerCase(), + }; + + delete update.email; + sendEmailVerification = true; + } + } + + const updatedUser = + Object.keys(update).length > 0 + ? await this.usersRepository.update(user.id, update) + : user; + + if (sendEmailVerification && newEmail) { + await sendChangeOfEmailVerification({ + user: { + username: updatedUser.username ?? "Nameless User", + emailFrom: user.email, + emailTo: newEmail, + }, + }); + } + + return { + updatedUser, + }; + } +} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index 1604fd38de..5fdcb18773 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -434,4 +434,48 @@ export class UsersRepository { }, }); } + + async findVerifiedSecondaryEmail(userId: number, email: string) { + return this.dbRead.prisma.secondaryEmail.findUnique({ + where: { + userId_email: { + userId: userId, + email: email, + }, + }, + select: { + id: true, + emailVerified: true, + }, + }); + } + + async swapPrimaryEmailWithSecondaryEmail( + userId: number, + secondaryEmailId: number, + oldPrimaryEmail: string, + oldPrimaryEmailVerified: Date | null, + newPrimaryEmail: string + ) { + const [, updatedUser] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.secondaryEmail.update({ + where: { + id: secondaryEmailId, + userId: userId, + }, + data: { + email: oldPrimaryEmail, + emailVerified: oldPrimaryEmailVerified, + }, + }), + this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + email: newPrimaryEmail, + }, + }), + ]); + + return updatedUser; + } } diff --git a/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts index 0c21b21669..c6e526d2db 100644 --- a/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts @@ -49,4 +49,14 @@ export class UserRepositoryFixture { async deleteByEmail(email: User["email"]) { return this.prismaWriteClient.user.delete({ where: { email } }); } + + async createSecondaryEmail(userId: User["id"], email: string, emailVerified: Date | null) { + return this.prismaWriteClient.secondaryEmail.create({ + data: { + userId, + email, + emailVerified, + }, + }); + } } diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index 1fad91aaea..e37b19e170 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -389,6 +389,27 @@ export class UserRepository { }; } + async findSecondaryEmailByUserIdAndEmail({ + userId, + email, + }: { + userId: number; + email: string; + }) { + return this.prismaClient.secondaryEmail.findUnique({ + where: { + userId_email: { + userId, + email, + }, + }, + select: { + id: true, + emailVerified: true, + }, + }); + } + async findByUuid({ uuid }: { uuid: string }) { return this.prismaClient.user.findUnique({ where: { @@ -460,6 +481,44 @@ export class UserRepository { return !!user.movedToProfileId; } + async swapPrimaryEmailWithSecondaryEmail({ + userId, + secondaryEmailId, + oldPrimaryEmail, + oldPrimaryEmailVerified, + newPrimaryEmail, + userUpdateData, + }: { + userId: number; + secondaryEmailId: number; + oldPrimaryEmail: string; + oldPrimaryEmailVerified: Date | null; + newPrimaryEmail: string; + userUpdateData?: Prisma.UserUpdateInput; + }) { + const [, updatedUser] = await this.prismaClient.$transaction([ + this.prismaClient.secondaryEmail.update({ + where: { + id: secondaryEmailId, + userId: userId, + }, + data: { + email: oldPrimaryEmail, + emailVerified: oldPrimaryEmailVerified, + }, + }), + this.prismaClient.user.update({ + where: { id: userId }, + data: { + ...userUpdateData, + email: newPrimaryEmail, + }, + }), + ]); + + return updatedUser; + } + async enrichUserWithTheProfile({ user, upId, diff --git a/packages/platform/libraries/emails.ts b/packages/platform/libraries/emails.ts index 80d66acf05..5a968790c3 100644 --- a/packages/platform/libraries/emails.ts +++ b/packages/platform/libraries/emails.ts @@ -12,7 +12,10 @@ import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassig import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email"; import OrganizerRescheduledEmail from "@calcom/emails/templates/organizer-rescheduled-email"; import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-email"; -import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail"; +import { + sendChangeOfEmailVerification, + sendEmailVerificationByCode, +} from "@calcom/features/auth/lib/verifyEmail"; import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { verifyEmailCodeHandler } from "@calcom/trpc/server/routers/viewer/workflows/verifyEmailCode.handler"; @@ -47,5 +50,6 @@ export { AttendeeRequestEmail }; export { sendSignupToOrganizationEmail }; export { sendEmailVerificationByCode }; +export { sendChangeOfEmailVerification }; export { verifyEmailCodeHandler };