feat: implement secure email change workflow with verification token and event handling
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
VerifyEmailDto,
|
VerifyEmailDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
|
ChangeEmailDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import {
|
import {
|
||||||
VerifyTwoFactorDto,
|
VerifyTwoFactorDto,
|
||||||
@@ -232,6 +233,28 @@ export class AuthController {
|
|||||||
return this.authService.changePassword(userId, dto);
|
return this.authService.changePassword(userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REQUEST EMAIL CHANGE
|
||||||
|
// ==========================================
|
||||||
|
@Post('change-email')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Request an email change — sends verification link to the new email. Email updates only after link is clicked.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: ChangeEmailDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Verification email sent' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Incorrect password or invalid email' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email already in use' })
|
||||||
|
async requestEmailChange(
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
@Body() dto: ChangeEmailDto,
|
||||||
|
) {
|
||||||
|
return this.authService.requestEmailChange(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// VERIFY EMAIL
|
// VERIFY EMAIL
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
VerifyEmailDto,
|
VerifyEmailDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
|
ChangeEmailDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
||||||
import { TwoFactorService } from './two-factor/two-factor.service';
|
import { TwoFactorService } from './two-factor/two-factor.service';
|
||||||
@@ -539,6 +540,44 @@ export class AuthService {
|
|||||||
secret: this.configService.get<string>('jwt.secret'),
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Email-change token — swap user's email to the pending one
|
||||||
|
if (payload.type === 'email-change') {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmail = (payload.newEmail as string)?.toLowerCase();
|
||||||
|
if (!newEmail) {
|
||||||
|
throw new BadRequestException('Invalid verification token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the email hasn't been claimed by another account in the meantime
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { email: newEmail },
|
||||||
|
});
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
throw new ConflictException('Email is already in use');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email === newEmail && user.emailVerified) {
|
||||||
|
return { message: 'Email is already verified' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
email: newEmail,
|
||||||
|
emailVerified: true,
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'Email changed and verified successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.type !== 'email-verification') {
|
if (payload.type !== 'email-verification') {
|
||||||
throw new BadRequestException('Invalid verification token');
|
throw new BadRequestException('Invalid verification token');
|
||||||
}
|
}
|
||||||
@@ -594,6 +633,72 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REQUEST EMAIL CHANGE
|
||||||
|
// ==========================================
|
||||||
|
async requestEmailChange(userId: string, dto: ChangeEmailDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { userProfile: true, agentProfile: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.authProvider !== AuthProvider.LOCAL) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Email cannot be changed for social login accounts',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw new BadRequestException('Password is not set on this account');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordOk = await argon2.verify(user.password, dto.password);
|
||||||
|
if (!passwordOk) {
|
||||||
|
throw new BadRequestException('Incorrect password');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmail = dto.newEmail.toLowerCase().trim();
|
||||||
|
if (newEmail === user.email) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'New email must be different from current email',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { email: newEmail },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Email is already in use');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.jwtService.sign(
|
||||||
|
{ sub: user.id, newEmail, type: 'email-change' },
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
expiresIn: '24h',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile =
|
||||||
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|
||||||
|
// Emit event to send the verification email to the NEW address
|
||||||
|
this.eventEmitter.emit('user.email-change-requested', {
|
||||||
|
newEmail,
|
||||||
|
token,
|
||||||
|
name: profile?.firstName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'A verification link has been sent to your new email. Please click the link to confirm the change.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// RESEND VERIFICATION EMAIL
|
// RESEND VERIFICATION EMAIL
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
20
src/auth/dto/change-email.dto.ts
Normal file
20
src/auth/dto/change-email.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChangeEmailDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'New email address',
|
||||||
|
example: 'new-email@example.com',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ message: 'New email is required' })
|
||||||
|
@IsEmail({}, { message: 'Invalid email format' })
|
||||||
|
newEmail: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Current password (for verification)',
|
||||||
|
example: 'CurrentPass123!',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ message: 'Password is required' })
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from './reset-password.dto';
|
|||||||
export * from './refresh-token.dto';
|
export * from './refresh-token.dto';
|
||||||
export * from './verify-email.dto';
|
export * from './verify-email.dto';
|
||||||
export * from './change-password.dto';
|
export * from './change-password.dto';
|
||||||
|
export * from './change-email.dto';
|
||||||
|
|||||||
@@ -99,4 +99,26 @@ export class EmailListener {
|
|||||||
this.logger.error(`Failed to send welcome email to ${payload.email}:`, error);
|
this.logger.error(`Failed to send welcome email to ${payload.email}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent('user.email-change-requested')
|
||||||
|
async handleEmailChangeRequested(payload: {
|
||||||
|
newEmail: string;
|
||||||
|
token: string;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
this.logger.log(`Email change requested for: ${payload.newEmail}`);
|
||||||
|
try {
|
||||||
|
await this.emailService.sendVerificationEmail(
|
||||||
|
payload.newEmail,
|
||||||
|
payload.token,
|
||||||
|
payload.name,
|
||||||
|
);
|
||||||
|
this.logger.log(`Email-change verification sent to: ${payload.newEmail}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send email-change verification to ${payload.newEmail}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user