From 5ea9a10d1972cf6865faa0c051ed9de595eb6d47 Mon Sep 17 00:00:00 2001 From: Rayaan Ghosh Date: Wed, 22 May 2024 17:12:14 +0530 Subject: [PATCH] feat(api): Added support for changing email of users (#233) --- apps/api/src/auth/service/auth.service.ts | 21 +- apps/api/src/common/generate-otp.ts | 34 +++ .../src/mail/services/interface.service.ts | 2 + apps/api/src/mail/services/mail.service.ts | 23 +- apps/api/src/mail/services/mock.service.ts | 4 + .../migration.sql | 24 ++ apps/api/src/prisma/schema.prisma | 19 +- .../src/user/controller/user.controller.ts | 13 + apps/api/src/user/service/user.service.ts | 141 ++++++++++- apps/api/src/user/user.e2e.spec.ts | 227 ++++++++++++++++++ 10 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/common/generate-otp.ts create mode 100644 apps/api/src/prisma/migrations/20240521090751_add_user_email_change/migration.sql diff --git a/apps/api/src/auth/service/auth.service.ts b/apps/api/src/auth/service/auth.service.ts index e4ce6846..5dac0e89 100644 --- a/apps/api/src/auth/service/auth.service.ts +++ b/apps/api/src/auth/service/auth.service.ts @@ -17,10 +17,10 @@ import { import { PrismaService } from '../../prisma/prisma.service' import createUser from '../../common/create-user' import { AuthProvider } from '@prisma/client' +import generateOtp from '../../common/generate-otp' @Injectable() export class AuthService { - private readonly OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes private readonly logger: LoggerService constructor( @@ -39,24 +39,7 @@ export class AuthService { const user = await this.createUserIfNotExists(email, AuthProvider.EMAIL_OTP) - const otp = await this.prisma.otp.upsert({ - where: { - userId: user.id - }, - update: { - code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`).toString().substring(0, 6), - expiresAt: new Date(new Date().getTime() + this.OTP_EXPIRY) - }, - create: { - code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`).toString().substring(0, 6), - expiresAt: new Date(new Date().getTime() + this.OTP_EXPIRY), - user: { - connect: { - email - } - } - } - }) + const otp = await generateOtp(email, user.id, this.prisma) await this.mailService.sendOtp(email, otp.code) this.logger.log(`Login code sent to ${email}`) diff --git a/apps/api/src/common/generate-otp.ts b/apps/api/src/common/generate-otp.ts new file mode 100644 index 00000000..ba5f7cd4 --- /dev/null +++ b/apps/api/src/common/generate-otp.ts @@ -0,0 +1,34 @@ +import { Otp, PrismaClient, User } from '@prisma/client' + +const OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes + +export default async function generateOtp( + email: User['email'], + userId: User['id'], + prisma: PrismaClient +): Promise { + const otp = await prisma.otp.upsert({ + where: { + userId: userId + }, + update: { + code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) + .toString() + .substring(0, 6), + expiresAt: new Date(new Date().getTime() + OTP_EXPIRY) + }, + create: { + code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) + .toString() + .substring(0, 6), + expiresAt: new Date(new Date().getTime() + OTP_EXPIRY), + user: { + connect: { + email + } + } + } + }) + + return otp +} diff --git a/apps/api/src/mail/services/interface.service.ts b/apps/api/src/mail/services/interface.service.ts index 983d420e..7f4e2689 100644 --- a/apps/api/src/mail/services/interface.service.ts +++ b/apps/api/src/mail/services/interface.service.ts @@ -3,6 +3,8 @@ export const MAIL_SERVICE = 'MAIL_SERVICE' export interface IMailService { sendOtp(email: string, otp: string): Promise + sendEmailChangedOtp(email: string, otp: string): Promise + workspaceInvitationMailForUsers( email: string, workspace: string, diff --git a/apps/api/src/mail/services/mail.service.ts b/apps/api/src/mail/services/mail.service.ts index f2c0dd85..05c48a83 100644 --- a/apps/api/src/mail/services/mail.service.ts +++ b/apps/api/src/mail/services/mail.service.ts @@ -70,7 +70,28 @@ export class MailService implements IMailService { ` await this.sendEmail(email, subject, body) } - + async sendEmailChangedOtp(email: string, otp: string): Promise { + const subject = 'Your OTP for Email Change' + const body = ` + + + OTP Verification + + +

Are you trying to change your email?

+

Hello there!

+

We have sent you this email to verify your new email.

+

Your One Time Password (OTP) is: ${otp}

+

This OTP will expire in 5 minutes.

+

Please enter this OTP in the application to verify your new email.

+

Thank you.

+

Best Regards,

+

keyshade Team

+ + + ` + await this.sendEmail(email, subject, body) + } async accountLoginEmail(email: string): Promise { const subject = 'LogIn Invitation Accepted' const body = ` diff --git a/apps/api/src/mail/services/mock.service.ts b/apps/api/src/mail/services/mock.service.ts index 0bb962b3..003cb520 100644 --- a/apps/api/src/mail/services/mock.service.ts +++ b/apps/api/src/mail/services/mock.service.ts @@ -34,4 +34,8 @@ export class MockMailService implements IMailService { async feedbackEmail(email: string, feedback: string): Promise { this.log.log(`Feedback is : ${feedback}, for email : ${email}`) } + + async sendEmailChangedOtp(email: string, otp: string): Promise { + this.log.log(`Email change OTP for email ${email} is ${otp}`) + } } diff --git a/apps/api/src/prisma/migrations/20240521090751_add_user_email_change/migration.sql b/apps/api/src/prisma/migrations/20240521090751_add_user_email_change/migration.sql new file mode 100644 index 00000000..743e753f --- /dev/null +++ b/apps/api/src/prisma/migrations/20240521090751_add_user_email_change/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - The required column `id` was added to the `Otp` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "Otp" ADD COLUMN "id" TEXT NOT NULL, +ADD CONSTRAINT "Otp_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "UserEmailChange" ( + "id" TEXT NOT NULL, + "otpId" TEXT NOT NULL, + "newEmail" TEXT NOT NULL, + + CONSTRAINT "UserEmailChange_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserEmailChange_otpId_key" ON "UserEmailChange"("otpId"); + +-- AddForeignKey +ALTER TABLE "UserEmailChange" ADD CONSTRAINT "UserEmailChange_otpId_fkey" FOREIGN KEY ("otpId") REFERENCES "Otp"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 09db8ca7..5ef5741b 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -452,11 +452,13 @@ model ApiKey { } model Otp { - code String @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) - userId String @unique - createdAt DateTime @default(now()) - expiresAt DateTime + id String @id @default(cuid()) + code String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + emailChange UserEmailChange? @@unique([userId, code], name: "userCode") @@index([expiresAt], name: "expiresAt") @@ -518,3 +520,10 @@ model ChangeNotificationSocketMap { @@index([environmentId, socketId]) } + +model UserEmailChange { + id String @id @default(cuid()) + otp Otp @relation(fields: [otpId], references: [id], onDelete: Cascade, onUpdate: Cascade) + otpId String @unique + newEmail String +} \ No newline at end of file diff --git a/apps/api/src/user/controller/user.controller.ts b/apps/api/src/user/controller/user.controller.ts index 5b3c9c98..cd9efe87 100644 --- a/apps/api/src/user/controller/user.controller.ts +++ b/apps/api/src/user/controller/user.controller.ts @@ -143,4 +143,17 @@ export class UserController { async createUser(@Body() dto: CreateUserDto) { return await this.userService.createUser(dto) } + + @Post('validate-email-change-otp') + async validateEmailChangeOtp( + @CurrentUser() user: User, + @Query('otp') otp: string + ) { + return await this.userService.validateEmailChangeOtp(user, otp.trim()) + } + + @Post('resend-email-change-otp') + async resendEmailChangeOtp(@CurrentUser() user: User) { + return await this.userService.resendEmailChangeOtp(user) + } } diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index 0b5f00a7..c026a437 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -1,4 +1,10 @@ -import { ConflictException, Inject, Injectable, Logger } from '@nestjs/common' +import { + ConflictException, + Inject, + Injectable, + Logger, + UnauthorizedException +} from '@nestjs/common' import { UpdateUserDto } from '../dto/update.user/update.user' import { AuthProvider, User } from '@prisma/client' import { PrismaService } from '../../prisma/prisma.service' @@ -8,6 +14,7 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import createUser from '../../common/create-user' +import generateOtp from '../../common/generate-otp' @Injectable() export class UserService { @@ -32,6 +39,36 @@ export class UserService { profilePictureUrl: dto?.profilePictureUrl, isOnboardingFinished: dto.isOnboardingFinished } + if (dto?.email) { + const userExists = + (await this.prisma.user.count({ + where: { + email: dto.email + } + })) > 0 + + if (userExists) { + throw new ConflictException('User with this email already exists') + } + + const otp = await generateOtp(user.email, user.id, this.prisma) + + await this.prisma.userEmailChange.upsert({ + where: { + otpId: otp.id + }, + update: { + newEmail: dto.email + }, + create: { + newEmail: dto.email, + otpId: otp.id + } + }) + + await this.mailService.sendEmailChangedOtp(dto.email, otp.code) + } + this.log.log(`Updating user ${user.id} with data ${dto}`) const updatedUser = await this.prisma.user.update({ where: { @@ -51,6 +88,31 @@ export class UserService { isActive: dto.isActive, isOnboardingFinished: dto.isOnboardingFinished } + + if (dto.email) { + const userExists = + (await this.prisma.user.count({ + where: { + email: dto.email + } + })) > 0 + + if (userExists) { + throw new ConflictException('User with this email already exists') + } + + //directly updating email when admin triggered + await this.prisma.user.update({ + where: { + id: userId + }, + data: { + email: dto.email, + authProvider: AuthProvider.EMAIL_OTP + } + }) + } + this.log.log(`Updating user ${userId} with data ${dto}`) return await this.prisma.user.update({ where: { @@ -60,6 +122,83 @@ export class UserService { }) } + async validateEmailChangeOtp(user: User, otpCode: string): Promise { + const otp = await this.prisma.otp.findUnique({ + where: { + userId: user.id, + code: otpCode + } + }) + + if (!otp || otp.expiresAt < new Date()) { + this.log.log(`OTP expired or invalid`) + throw new UnauthorizedException('Invalid or expired OTP') + } + const userEmailChange = await this.prisma.userEmailChange.findUnique({ + where: { + otpId: otp.id + } + }) + + const deleteEmailChangeRecord = this.prisma.userEmailChange.delete({ + where: { + otpId: otp.id + } + }) + + const deleteOtp = this.prisma.otp.delete({ + where: { + userId: user.id, + code: otpCode + } + }) + + const updateUserOp = this.prisma.user.update({ + where: { + id: user.id + }, + data: { + email: userEmailChange.newEmail, + authProvider: AuthProvider.EMAIL_OTP + } + }) + + this.log.log( + `Changing email to ${userEmailChange.newEmail} for user ${user.id}` + ) + const results = await this.prisma.$transaction([ + deleteEmailChangeRecord, + deleteOtp, + updateUserOp + ]) + + return results[2] + } + + async resendEmailChangeOtp(user: User) { + const oldOtp = await this.prisma.otp.findUnique({ + where: { + userId: user.id + }, + include: { + emailChange: true + } + }) + + if (!oldOtp?.emailChange) { + throw new ConflictException( + `No previous OTP for email change exists for user ${user.id}` + ) + } + + const newOtp = await generateOtp(user.email, user.id, this.prisma) + + await this.mailService.sendEmailChangedOtp( + oldOtp.emailChange.newEmail, + newOtp.code + ) + } + async getUserById(userId: string) { return await this.prisma.user.findUnique({ where: { diff --git a/apps/api/src/user/user.e2e.spec.ts b/apps/api/src/user/user.e2e.spec.ts index 3ccf6121..1b872de1 100644 --- a/apps/api/src/user/user.e2e.spec.ts +++ b/apps/api/src/user/user.e2e.spec.ts @@ -321,6 +321,233 @@ describe('User Controller Tests', () => { expect(result.statusCode).toEqual(204) }) + it('should send otp when user changes email', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user`, + headers: { + 'x-e2e-user-email': regularUser.email + }, + payload: { + email: 'newEmail@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(200) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser + }) + + const userEmailChange = await prisma.otp.findMany({ + where: { + userId: regularUser.id, + AND: { + emailChange: { + newEmail: 'newEmail@keyshade.xyz' + } + } + } + }) + + expect(userEmailChange.length).toEqual(1) + }) + + it('should allow admin to change an user email', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user/${regularUser.id}`, + headers: { + 'x-e2e-user-email': adminUser.email + }, + payload: { + email: 'newEmail@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(200) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser, + email: 'newEmail@keyshade.xyz' + }) + + const updatedUser = await prisma.user.findUnique({ + where: { + id: regularUser.id + } + }) + + expect(updatedUser.email).toEqual('newEmail@keyshade.xyz') + }) + + it('should give error when new email is used by an existing user', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user`, + headers: { + 'x-e2e-user-email': regularUser.email + }, + payload: { + email: 'john@keyshade.xyz' + } + }) + + expect(result.statusCode).toEqual(409) + }) + + it('should validate OTP successfully', async () => { + await prisma.otp.create({ + data: { + code: '123456', + userId: regularUser.id, + expiresAt: new Date(new Date().getTime() + 5 * 60 * 1000), + emailChange: { + create: { + newEmail: 'newjohn@keyshade.xyz' + } + } + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/validate-email-change-otp`, + query: { + otp: '123456' + }, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(201) + expect(JSON.parse(result.body)).toEqual({ + ...regularUser, + email: 'newjohn@keyshade.xyz' + }) + + const updatedUser = await prisma.user.findUnique({ + where: { + id: regularUser.id + } + }) + + expect(updatedUser.email).toEqual('newjohn@keyshade.xyz') + }) + + it('should fail to validate expired or invalid OTP', async () => { + await prisma.otp.create({ + data: { + code: '123456', + userId: regularUser.id, + expiresAt: new Date(new Date().getTime() - 1), + emailChange: { + create: { + newEmail: 'newjohn@keyshade.xyz' + } + } + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/validate-email-change-otp`, + query: { + otp: '123456' + }, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(401) + expect(JSON.parse(result.body)).toEqual({ + message: 'Invalid or expired OTP', + error: 'Unauthorized', + statusCode: 401 + }) + + const nonUpdatedUser = await prisma.user.findUnique({ + where: { + id: regularUser.id + } + }) + + expect(nonUpdatedUser.email).toEqual('john@keyshade.xyz') + }) + + it('should resend OTP successfully', async () => { + await prisma.otp.create({ + data: { + code: '123456', + userId: regularUser.id, + expiresAt: new Date(new Date().getTime() + 5 * 60 * 1000), + emailChange: { + create: { + newEmail: 'newjohn@keyshade.xyz' + } + } + } + }) + + const result = await app.inject({ + method: 'POST', + url: `/user/resend-email-change-otp`, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(201) + + const updatedOtp = await prisma.otp.findUnique({ + where: { + userId: regularUser.id, + emailChange: { + newEmail: 'newjohn@keyshade.xyz' + } + } + }) + + expect(updatedOtp.code).not.toEqual('123456') + }) + + it('should return 409 Conflict if the email already exists', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/user/${regularUser.id}`, + headers: { + 'x-e2e-user-email': adminUser.email + }, + payload: { + email: adminUser.email // existing email + } + }) + + expect(result.statusCode).toEqual(409) + expect(JSON.parse(result.body)).toEqual({ + statusCode: 409, + message: 'User with this email already exists', + error: 'Conflict' + }) + }) + + it('should return 409 Conflict if no previous OTP exists for email change', async () => { + const result = await app.inject({ + method: 'POST', + url: `/user/resend-email-change-otp`, + headers: { + 'x-e2e-user-email': regularUser.email + } + }) + + expect(result.statusCode).toEqual(409) + expect(JSON.parse(result.body)).toEqual({ + statusCode: 409, + message: `No previous OTP for email change exists for user ${regularUser.id}`, + error: 'Conflict' + }) + }) + // test('user should be able to delete their own account', async () => { // const result = await app.inject({ // method: 'DELETE',