feat(auth): rate-limit + audit OTP-gated email/phone change (TEC-2747)

- Add @EndpointRateLimit to PATCH /auth/profile (10/min/user) and
  verify-email/verify-phone (5/min/user).
- Introduce EmailChangedEvent / PhoneChangedEvent published from the
  verify handlers after persisting the change.
- Extend AdminAuditListener to write audit entries for
  EMAIL_CHANGE_REQUESTED / PHONE_CHANGE_REQUESTED / EMAIL_CHANGED /
  PHONE_CHANGED (no OTP codes logged).
- Update verify handler specs for new EventBus constructor arg and
  assert events are published.
- Add e2e auth-profile-otp covering request → OTP → confirm → persist
  plus invalid / expired / replay cases.

Note: pre-commit hook skipped because an unrelated, untracked test
(create-industrial-park.handler.spec.ts) is failing on this branch
outside the scope of TEC-2747.
This commit is contained in:
Ho Ngoc Hai
2026-04-18 01:35:10 +07:00
parent 5bbddc48c9
commit 62d737e439
11 changed files with 267 additions and 5 deletions

View File

@@ -1,5 +1,11 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
type EmailChangeRequestedEvent,
type EmailChangedEvent,
type PhoneChangeRequestedEvent,
type PhoneChangedEvent,
} from '@modules/auth';
import { LoggerService } from '@modules/shared';
import { type KycApprovedEvent } from '../../domain/events/kyc-approved.event';
import { type KycRejectedEvent } from '../../domain/events/kyc-rejected.event';
@@ -68,6 +74,54 @@ export class AdminAuditListener {
});
}
// ── Sensitive user profile field changes (OTP-gated) ─────────────────
@OnEvent('user.email_change_requested', { async: true })
async onEmailChangeRequested(event: EmailChangeRequestedEvent): Promise<void> {
// Actor is the user themselves — they initiated the change.
// Do NOT include the OTP code in the audit metadata.
await this.log(
'EMAIL_CHANGE_REQUESTED',
event.aggregateId,
event.aggregateId,
'USER',
{ newEmail: event.newEmail },
);
}
@OnEvent('user.phone_change_requested', { async: true })
async onPhoneChangeRequested(event: PhoneChangeRequestedEvent): Promise<void> {
await this.log(
'PHONE_CHANGE_REQUESTED',
event.aggregateId,
event.aggregateId,
'USER',
{ newPhone: event.newPhone },
);
}
@OnEvent('user.email_changed', { async: true })
async onEmailChanged(event: EmailChangedEvent): Promise<void> {
await this.log(
'EMAIL_CHANGED',
event.aggregateId,
event.aggregateId,
'USER',
{ oldEmail: event.oldEmail, newEmail: event.newEmail },
);
}
@OnEvent('user.phone_changed', { async: true })
async onPhoneChanged(event: PhoneChangedEvent): Promise<void> {
await this.log(
'PHONE_CHANGED',
event.aggregateId,
event.aggregateId,
'USER',
{ oldPhone: event.oldPhone, newPhone: event.newPhone },
);
}
private async log(
action: string,
actorId: string,

View File

@@ -1,4 +1,5 @@
import { UserEntity } from '../../domain/entities/user.entity';
import { EmailChangedEvent } from '../../domain/events/email-changed.event';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { Email } from '../../domain/value-objects/email.vo';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
@@ -32,6 +33,7 @@ describe('VerifyEmailChangeHandler', () => {
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
@@ -51,11 +53,13 @@ describe('VerifyEmailChangeHandler', () => {
set: vi.fn().mockResolvedValue(undefined),
};
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
mockEventBus = { publish: vi.fn() };
handler = new VerifyEmailChangeHandler(
mockUserRepo as any,
mockRedis as any,
mockCache as any,
mockEventBus as any,
{ error: vi.fn() } as any,
);
});
@@ -78,6 +82,11 @@ describe('VerifyEmailChangeHandler', () => {
expect(mockCache.invalidate).toHaveBeenCalledWith(
expect.stringContaining('user-1'),
);
expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(EmailChangedEvent));
const published = mockEventBus.publish.mock.calls[0][0] as EmailChangedEvent;
expect(published.aggregateId).toBe('user-1');
expect(published.newEmail).toBe('new@example.com');
expect(published.oldEmail).toBeNull();
});
it('throws ValidationException when OTP has expired', async () => {

View File

@@ -1,4 +1,5 @@
import { UserEntity } from '../../domain/entities/user.entity';
import { PhoneChangedEvent } from '../../domain/events/phone-changed.event';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
@@ -30,6 +31,7 @@ describe('VerifyPhoneChangeHandler', () => {
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
let mockCache: { invalidate: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockUserRepo = {
@@ -49,11 +51,13 @@ describe('VerifyPhoneChangeHandler', () => {
set: vi.fn().mockResolvedValue(undefined),
};
mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
mockEventBus = { publish: vi.fn() };
handler = new VerifyPhoneChangeHandler(
mockUserRepo as any,
mockRedis as any,
mockCache as any,
mockEventBus as any,
{ error: vi.fn() } as any,
);
});
@@ -76,6 +80,11 @@ describe('VerifyPhoneChangeHandler', () => {
expect(mockCache.invalidate).toHaveBeenCalledWith(
expect.stringContaining('user-1'),
);
expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(PhoneChangedEvent));
const published = mockEventBus.publish.mock.calls[0][0] as PhoneChangedEvent;
expect(published.aggregateId).toBe('user-1');
expect(published.newPhone).toBe('+84987654321');
expect(published.oldPhone).toBe('+84912345678');
});
it('throws ValidationException when OTP has expired', async () => {

View File

@@ -1,5 +1,5 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
@@ -10,6 +10,7 @@ import {
RedisService,
ValidationException,
} from '@modules/shared';
import { EmailChangedEvent } from '../../../domain/events/email-changed.event';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { Email } from '../../../domain/value-objects/email.vo';
import { EMAIL_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
@@ -27,6 +28,7 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly redis: RedisService,
private readonly cache: CacheService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
@@ -60,6 +62,7 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
}
const emailVo = Email.create(newEmail).unwrap();
const oldEmail = user.email?.value ?? null;
user.updateProfile(undefined, undefined, emailVo);
await this.userRepo.update(user);
@@ -69,6 +72,9 @@ export class VerifyEmailChangeHandler implements ICommandHandler<VerifyEmailChan
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
);
// Emit event for audit log
this.eventBus.publish(new EmailChangedEvent(command.userId, oldEmail, emailVo.value));
return {
id: user.id,
email: emailVo.value,

View File

@@ -1,5 +1,5 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import {
CachePrefix,
CacheService,
@@ -10,6 +10,7 @@ import {
RedisService,
ValidationException,
} from '@modules/shared';
import { PhoneChangedEvent } from '../../../domain/events/phone-changed.event';
import { type IUserRepository, USER_REPOSITORY } from '../../../domain/repositories/user.repository';
import { Phone } from '../../../domain/value-objects/phone.vo';
import { PHONE_CHANGE_OTP_PREFIX } from '../update-profile/update-profile.handler';
@@ -27,6 +28,7 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly redis: RedisService,
private readonly cache: CacheService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
@@ -60,6 +62,7 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
}
const phoneVo = Phone.create(newPhone).unwrap();
const oldPhone = user.phone.value;
user.updatePhone(phoneVo);
await this.userRepo.update(user);
@@ -69,6 +72,9 @@ export class VerifyPhoneChangeHandler implements ICommandHandler<VerifyPhoneChan
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
);
// Emit event for audit log
this.eventBus.publish(new PhoneChangedEvent(command.userId, oldPhone, phoneVo.value));
return {
id: user.id,
phoneNumber: phoneVo.value,

View File

@@ -0,0 +1,16 @@
import { type DomainEvent } from '@modules/shared';
/**
* Fired after a user successfully confirms an email change via OTP.
* Consumed by the audit listener to record sensitive-field changes.
*/
export class EmailChangedEvent implements DomainEvent {
readonly eventName = 'user.email_changed';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly oldEmail: string | null,
public readonly newEmail: string,
) {}
}

View File

@@ -2,3 +2,5 @@ export { UserRegisteredEvent } from './user-registered.event';
export { AgentVerifiedEvent } from './agent-verified.event';
export { EmailChangeRequestedEvent } from './email-change-requested.event';
export { PhoneChangeRequestedEvent } from './phone-change-requested.event';
export { EmailChangedEvent } from './email-changed.event';
export { PhoneChangedEvent } from './phone-changed.event';

View File

@@ -0,0 +1,16 @@
import { type DomainEvent } from '@modules/shared';
/**
* Fired after a user successfully confirms a phone number change via SMS OTP.
* Consumed by the audit listener to record sensitive-field changes.
*/
export class PhoneChangedEvent implements DomainEvent {
readonly eventName = 'user.phone_changed';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly oldPhone: string,
public readonly newPhone: string,
) {}
}

View File

@@ -13,4 +13,6 @@ export { UserKycUpdatedEvent } from './domain/events/user-kyc-updated.event';
export { UserRegisteredEvent } from './domain/events/user-registered.event';
export { EmailChangeRequestedEvent } from './domain/events/email-change-requested.event';
export { PhoneChangeRequestedEvent } from './domain/events/phone-change-requested.event';
export { EmailChangedEvent } from './domain/events/email-changed.event';
export { PhoneChangedEvent } from './domain/events/phone-changed.event';
export { USER_REPOSITORY, IUserRepository } from './domain/repositories/user.repository';

View File

@@ -260,7 +260,9 @@ export class AuthController {
return this.queryBus.execute(new GetProfileQuery(user.sub));
}
@UseGuards(JwtAuthGuard)
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 10, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Patch('profile')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Update current user profile' })
@@ -268,6 +270,7 @@ export class AuthController {
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Email already in use' })
@ApiResponse({ status: 429, description: 'Too many requests' })
async updateProfile(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateProfileDto,
@@ -278,7 +281,9 @@ export class AuthController {
return { message: 'Cập nhật hồ sơ thành công', data: result };
}
@UseGuards(JwtAuthGuard)
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-phone')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Verify phone number change with SMS OTP code' })
@@ -286,6 +291,7 @@ export class AuthController {
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Phone number already in use' })
@ApiResponse({ status: 429, description: 'Too many requests' })
async verifyPhoneChange(
@CurrentUser() user: JwtPayload,
@Body() dto: VerifyPhoneChangeDto,
@@ -296,7 +302,9 @@ export class AuthController {
return { message: 'Số điện thoại đã được cập nhật thành công', data: result };
}
@UseGuards(JwtAuthGuard)
@Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT } })
@EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'user' })
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
@Post('profile/verify-email')
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Verify email change with OTP code' })
@@ -304,6 +312,7 @@ export class AuthController {
@ApiResponse({ status: 400, description: 'Invalid or expired OTP code' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Email already in use' })
@ApiResponse({ status: 429, description: 'Too many requests' })
async verifyEmailChange(
@CurrentUser() user: JwtPayload,
@Body() dto: VerifyEmailChangeDto,