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 <adhabal2002@gmail.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 <adhabal2002@gmail.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 <adhabal2002@gmail.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>
This commit is contained in:
Anik Dhabal Babu
2026-01-08 15:51:11 +00:00
committed by GitHub
co-authored by anik@cal.com <adhabal2002@gmail.com> anik@cal.com <adhabal2002@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 03bb9481a2
commit ccff0045fc
9 changed files with 336 additions and 18 deletions
+64 -1
View File
@@ -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 };
}
@@ -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<UserResponse> = 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<UserResponse> = 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();
});
+12 -14
View File
@@ -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<UpdateMeOutput> {
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,
+5 -1
View File
@@ -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 {}
@@ -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<UpdateMeResult> {
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,
};
}
}
@@ -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;
}
}
@@ -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,
},
});
}
}
@@ -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<T extends { username: string | null; id: number }>({
user,
upId,
+5 -1
View File
@@ -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 };