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:
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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user