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