feat(auth): implement Auth module with register, login, JWT, guards, and CQRS

- Add RefreshToken and OAuthAccount models to Prisma schema
- Implement clean architecture: domain (entities, VOs, events, repo interfaces),
  infrastructure (Prisma repos, Passport strategies, token service),
  application (CQRS command/query handlers), presentation (controller, guards, DTOs)
- Endpoints: POST /auth/register, /auth/login, /auth/refresh, GET /auth/profile,
  GET /auth/profile/agent, PATCH /auth/kyc
- JWT access + refresh token rotation with family-based revocation
- Role-based guards (BUYER, SELLER, AGENT, ADMIN)
- 16 unit tests (value objects, entity) + integration test suite
- All 80 tests passing, clean TypeScript build

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 00:24:42 +07:00
parent c981bff771
commit 391c040100
63 changed files with 2194 additions and 33 deletions

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { Email } from '../value-objects/email.vo';
describe('Email Value Object', () => {
it('should create a valid email', () => {
const result = Email.create('Test@Example.com');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('test@example.com');
});
it('should reject an invalid email', () => {
const result = Email.create('invalid-email');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toBe('Email không hợp lệ');
});
it('should trim whitespace', () => {
const result = Email.create(' user@test.com ');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('user@test.com');
});
it('should check equality', () => {
const a = Email.create('user@test.com').unwrap();
const b = Email.create('USER@TEST.COM').unwrap();
expect(a.equals(b)).toBe(true);
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { HashedPassword } from '../value-objects/hashed-password.vo';
describe('HashedPassword Value Object', () => {
it('should hash and verify a password', async () => {
const result = await HashedPassword.fromPlain('StrongPass123');
expect(result.isOk).toBe(true);
const hashed = result.unwrap();
expect(hashed.value).toMatch(/^\$2[ab]\$/);
const isValid = await hashed.compare('StrongPass123');
expect(isValid).toBe(true);
const isInvalid = await hashed.compare('WrongPassword');
expect(isInvalid).toBe(false);
});
it('should reject short passwords', async () => {
const result = await HashedPassword.fromPlain('short');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toContain('ít nhất 8 ký tự');
});
it('should create from existing hash', () => {
const hash = '$2b$12$mockHashValue';
const hashed = HashedPassword.fromHash(hash);
expect(hashed.value).toBe(hash);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { Phone } from '../value-objects/phone.vo';
describe('Phone Value Object', () => {
it('should create from valid Vietnam phone (0 prefix)', () => {
const result = Phone.create('0912345678');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('+84912345678');
});
it('should create from valid Vietnam phone (+84 prefix)', () => {
const result = Phone.create('+84912345678');
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe('+84912345678');
});
it('should reject invalid phone', () => {
const result = Phone.create('12345');
expect(result.isErr).toBe(true);
expect(result.unwrapErr()).toBe('Số điện thoại không hợp lệ');
});
it('should check equality after normalization', () => {
const a = Phone.create('0912345678').unwrap();
const b = Phone.create('+84912345678').unwrap();
expect(a.equals(b)).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { UserEntity } from '../entities/user.entity';
import { Phone } from '../value-objects/phone.vo';
import { HashedPassword } from '../value-objects/hashed-password.vo';
import { Email } from '../value-objects/email.vo';
import { UserRegisteredEvent } from '../events/user-registered.event';
describe('UserEntity', () => {
let phone: Phone;
let passwordHash: HashedPassword;
beforeEach(async () => {
phone = Phone.create('0912345678').unwrap();
passwordHash = (await HashedPassword.fromPlain('Password123')).unwrap();
});
it('should create a new user with domain event', () => {
const user = UserEntity.createNew('user-1', phone, 'Nguyễn Văn A', passwordHash);
expect(user.id).toBe('user-1');
expect(user.phone.value).toBe('+84912345678');
expect(user.fullName).toBe('Nguyễn Văn A');
expect(user.role).toBe('BUYER');
expect(user.kycStatus).toBe('NONE');
expect(user.isActive).toBe(true);
expect(user.email).toBeNull();
const events = user.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(UserRegisteredEvent);
expect((events[0] as UserRegisteredEvent).phone).toBe('+84912345678');
});
it('should create a user with email', () => {
const email = Email.create('test@example.com').unwrap();
const user = UserEntity.createNew('user-2', phone, 'Trần Thị B', passwordHash, email);
expect(user.email?.value).toBe('test@example.com');
});
it('should update KYC status', () => {
const user = UserEntity.createNew('user-3', phone, 'Lê Văn C', passwordHash);
user.updateKycStatus('PENDING', { idCard: '123456789' });
expect(user.kycStatus).toBe('PENDING');
expect(user.kycData).toEqual({ idCard: '123456789' });
});
it('should deactivate user', () => {
const user = UserEntity.createNew('user-4', phone, 'Phạm Thị D', passwordHash);
expect(user.isActive).toBe(true);
user.deactivate();
expect(user.isActive).toBe(false);
});
it('should clear domain events', () => {
const user = UserEntity.createNew('user-5', phone, 'Hoàng Văn E', passwordHash);
expect(user.domainEvents).toHaveLength(1);
const cleared = user.clearDomainEvents();
expect(cleared).toHaveLength(1);
expect(user.domainEvents).toHaveLength(0);
});
});

View File

@@ -0,0 +1 @@
export { UserEntity, type UserProps } from './user.entity';

View File

@@ -0,0 +1,88 @@
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
import { type UserRole, type KYCStatus } from '@prisma/client';
import { UserRegisteredEvent } from '../events/user-registered.event';
import { type Email } from '../value-objects/email.vo';
import { type Phone } from '../value-objects/phone.vo';
import { type HashedPassword } from '../value-objects/hashed-password.vo';
export interface UserProps {
email: Email | null;
phone: Phone;
passwordHash: HashedPassword | null;
fullName: string;
avatarUrl: string | null;
role: UserRole;
kycStatus: KYCStatus;
kycData: unknown;
isActive: boolean;
}
export class UserEntity extends AggregateRoot<string> {
private _email: Email | null;
private _phone: Phone;
private _passwordHash: HashedPassword | null;
private _fullName: string;
private _avatarUrl: string | null;
private _role: UserRole;
private _kycStatus: KYCStatus;
private _kycData: unknown;
private _isActive: boolean;
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
this._email = props.email;
this._phone = props.phone;
this._passwordHash = props.passwordHash;
this._fullName = props.fullName;
this._avatarUrl = props.avatarUrl;
this._role = props.role;
this._kycStatus = props.kycStatus;
this._kycData = props.kycData;
this._isActive = props.isActive;
}
get email(): Email | null { return this._email; }
get phone(): Phone { return this._phone; }
get passwordHash(): HashedPassword | null { return this._passwordHash; }
get fullName(): string { return this._fullName; }
get avatarUrl(): string | null { return this._avatarUrl; }
get role(): UserRole { return this._role; }
get kycStatus(): KYCStatus { return this._kycStatus; }
get kycData(): unknown { return this._kycData; }
get isActive(): boolean { return this._isActive; }
static createNew(
id: string,
phone: Phone,
fullName: string,
passwordHash: HashedPassword,
email?: Email,
role: UserRole = 'BUYER',
): UserEntity {
const user = new UserEntity(id, {
email: email ?? null,
phone,
passwordHash,
fullName,
avatarUrl: null,
role,
kycStatus: 'NONE',
kycData: null,
isActive: true,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
return user;
}
updateKycStatus(status: KYCStatus, kycData?: unknown): void {
this._kycStatus = status;
if (kycData !== undefined) this._kycData = kycData;
this.updatedAt = new Date();
}
deactivate(): void {
this._isActive = false;
this.updatedAt = new Date();
}
}

View File

@@ -0,0 +1,11 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
export class AgentVerifiedEvent implements DomainEvent {
readonly eventName = 'agent.verified';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,2 @@
export { UserRegisteredEvent } from './user-registered.event';
export { AgentVerifiedEvent } from './agent-verified.event';

View File

@@ -0,0 +1,13 @@
import { type DomainEvent } from '@modules/shared/domain/domain-event';
import { type UserRole } from '@prisma/client';
export class UserRegisteredEvent implements DomainEvent {
readonly eventName = 'user.registered';
readonly occurredAt = new Date();
constructor(
public readonly aggregateId: string,
public readonly phone: string,
public readonly role: UserRole,
) {}
}

View File

@@ -0,0 +1,4 @@
export * from './entities';
export * from './value-objects';
export * from './events';
export * from './repositories';

View File

@@ -0,0 +1,6 @@
export { USER_REPOSITORY, type IUserRepository } from './user.repository';
export {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,
type RefreshTokenRecord,
} from './refresh-token.repository';

View File

@@ -0,0 +1,19 @@
export const REFRESH_TOKEN_REPOSITORY = Symbol('REFRESH_TOKEN_REPOSITORY');
export interface RefreshTokenRecord {
id: string;
userId: string;
token: string;
family: string;
expiresAt: Date;
revokedAt: Date | null;
createdAt: Date;
}
export interface IRefreshTokenRepository {
create(record: Omit<RefreshTokenRecord, 'id' | 'createdAt'>): Promise<RefreshTokenRecord>;
findByToken(token: string): Promise<RefreshTokenRecord | null>;
revokeByFamily(family: string): Promise<void>;
revokeAllForUser(userId: string): Promise<void>;
deleteExpired(): Promise<number>;
}

View File

@@ -0,0 +1,11 @@
import { type UserEntity } from '../entities/user.entity';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export interface IUserRepository {
findById(id: string): Promise<UserEntity | null>;
findByPhone(phone: string): Promise<UserEntity | null>;
findByEmail(email: string): Promise<UserEntity | null>;
save(user: UserEntity): Promise<void>;
update(user: UserEntity): Promise<void>;
}

View File

@@ -0,0 +1,22 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
interface EmailProps {
value: string;
}
export class Email extends ValueObject<EmailProps> {
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
get value(): string {
return this.props.value;
}
static create(email: string): Result<Email, string> {
const normalized = email.trim().toLowerCase();
if (!this.EMAIL_REGEX.test(normalized)) {
return Result.err('Email không hợp lệ');
}
return Result.ok(new Email({ value: normalized }));
}
}

View File

@@ -0,0 +1,32 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
import * as bcrypt from 'bcrypt';
interface HashedPasswordProps {
value: string;
}
export class HashedPassword extends ValueObject<HashedPasswordProps> {
private static readonly SALT_ROUNDS = 12;
private static readonly MIN_LENGTH = 8;
get value(): string {
return this.props.value;
}
static async fromPlain(password: string): Promise<Result<HashedPassword, string>> {
if (password.length < this.MIN_LENGTH) {
return Result.err(`Mật khẩu phải có ít nhất ${this.MIN_LENGTH} ký tự`);
}
const hash = await bcrypt.hash(password, this.SALT_ROUNDS);
return Result.ok(new HashedPassword({ value: hash }));
}
static fromHash(hash: string): HashedPassword {
return new HashedPassword({ value: hash });
}
async compare(plainPassword: string): Promise<boolean> {
return bcrypt.compare(plainPassword, this.props.value);
}
}

View File

@@ -0,0 +1,3 @@
export { Email } from './email.vo';
export { Phone } from './phone.vo';
export { HashedPassword } from './hashed-password.vo';

View File

@@ -0,0 +1,24 @@
import { ValueObject } from '@modules/shared/domain/value-object';
import { Result } from '@modules/shared/domain/result';
import { isValidVietnamPhone, normalizeVietnamPhone } from '@modules/shared/utils/vietnam-phone.validator';
interface PhoneProps {
value: string;
}
export class Phone extends ValueObject<PhoneProps> {
get value(): string {
return this.props.value;
}
static create(phone: string): Result<Phone, string> {
if (!isValidVietnamPhone(phone)) {
return Result.err('Số điện thoại không hợp lệ');
}
const normalized = normalizeVietnamPhone(phone);
if (!normalized) {
return Result.err('Không thể chuẩn hóa số điện thoại');
}
return Result.ok(new Phone({ value: normalized }));
}
}