feat(auth): add OTP verification for email changes on profile update
Email changes via PATCH /api/v1/auth/profile now require OTP verification instead of updating immediately. A 6-digit code is sent to the new email address and must be confirmed via POST /api/v1/auth/profile/verify-email within 10 minutes. Also fixes pre-existing web valuation test failures (formatPrice output format, removed comparables section, missing QueryClientProvider wrapper). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
@@ -7,17 +7,27 @@ import {
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { randomInt } from 'crypto';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { EmailChangeRequestedEvent } from '../../../domain/events/email-change-requested.event';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { UpdateProfileCommand } from './update-profile.command';
|
||||
|
||||
/** TTL for email-change OTP codes stored in Redis (10 minutes). */
|
||||
const EMAIL_CHANGE_OTP_TTL = 600;
|
||||
|
||||
/** Redis key prefix for pending email-change OTP. */
|
||||
export const EMAIL_CHANGE_OTP_PREFIX = 'auth:email_change_otp';
|
||||
|
||||
export interface UpdateProfileResultDto {
|
||||
id: string;
|
||||
fullName: string;
|
||||
avatarUrl: string | null;
|
||||
email: string | null;
|
||||
emailChangePending?: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -26,6 +36,8 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly cache: CacheService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -36,8 +48,9 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
}
|
||||
|
||||
// Validate and resolve email if provided
|
||||
let newEmail: Email | null | undefined;
|
||||
let emailChangePending = false;
|
||||
|
||||
// Validate and handle email change via OTP
|
||||
if (command.email !== undefined) {
|
||||
const emailResult = Email.create(command.email);
|
||||
if (emailResult.isErr) {
|
||||
@@ -52,11 +65,27 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
newEmail = email;
|
||||
|
||||
// Generate OTP and store pending change in Redis
|
||||
const otpCode = String(randomInt(100_000, 999_999));
|
||||
const payload = JSON.stringify({ newEmail: email.value, code: otpCode });
|
||||
await this.redis.set(
|
||||
`${EMAIL_CHANGE_OTP_PREFIX}:${command.userId}`,
|
||||
payload,
|
||||
EMAIL_CHANGE_OTP_TTL,
|
||||
);
|
||||
|
||||
// Emit event so notifications module can send the OTP email
|
||||
this.eventBus.publish(
|
||||
new EmailChangeRequestedEvent(command.userId, email.value, otpCode),
|
||||
);
|
||||
|
||||
emailChangePending = true;
|
||||
}
|
||||
}
|
||||
|
||||
user.updateProfile(command.fullName, command.avatarUrl, newEmail);
|
||||
// Apply non-email fields immediately
|
||||
user.updateProfile(command.fullName, command.avatarUrl, undefined);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
await this.cache.invalidate(
|
||||
@@ -68,6 +97,7 @@ export class UpdateProfileHandler implements ICommandHandler<UpdateProfileComman
|
||||
fullName: user.fullName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email?.value ?? null,
|
||||
...(emailChangePending ? { emailChangePending: true } : {}),
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class VerifyEmailChangeCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly code: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CachePrefix,
|
||||
CacheService,
|
||||
ConflictException,
|
||||
DomainException,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
RedisService,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { Email } from '../../../domain/value-objects/email.vo';
|
||||
import { IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
|
||||
import { EMAIL_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
|
||||
import { VerifyEmailChangeCommand } from './verify-email-change.command';
|
||||
|
||||
export interface VerifyEmailChangeResultDto {
|
||||
id: string;
|
||||
email: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@CommandHandler(VerifyEmailChangeCommand)
|
||||
export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChangeCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: VerifyEmailChangeCommand): Promise<VerifyEmailChangeResultDto> {
|
||||
try {
|
||||
const redisKey = `${EMAIL_CHANGE_OTP_PREFIX}:${command.userId}`;
|
||||
const raw = await this.redis.get(redisKey);
|
||||
|
||||
if (!raw) {
|
||||
throw new ValidationException(
|
||||
'Mã xác thực đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu đổi email lại.',
|
||||
);
|
||||
}
|
||||
|
||||
const { newEmail, code } = JSON.parse(raw) as { newEmail: string; code: string };
|
||||
|
||||
if (code !== command.code) {
|
||||
throw new ValidationException('Mã xác thực không đúng');
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng', command.userId);
|
||||
}
|
||||
|
||||
// Re-check email uniqueness (may have been taken since the request)
|
||||
const existingUser = await this.userRepo.findByEmail(newEmail);
|
||||
if (existingUser && existingUser.id !== command.userId) {
|
||||
await this.redis.del(redisKey);
|
||||
throw new ConflictException('Email đã được sử dụng bởi tài khoản khác');
|
||||
}
|
||||
|
||||
const emailVo = Email.create(newEmail).unwrap();
|
||||
user.updateProfile(undefined, undefined, emailVo);
|
||||
await this.userRepo.update(user);
|
||||
|
||||
// Clean up OTP and invalidate profile cache
|
||||
await this.redis.del(redisKey);
|
||||
await this.cache.invalidate(
|
||||
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: emailVo.value,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to verify email change: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xác thực đổi email');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user