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:
28
apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts
Normal file
28
apps/api/src/modules/auth/domain/__tests__/email.vo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
28
apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts
Normal file
28
apps/api/src/modules/auth/domain/__tests__/phone.vo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/auth/domain/entities/index.ts
Normal file
1
apps/api/src/modules/auth/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UserEntity, type UserProps } from './user.entity';
|
||||
88
apps/api/src/modules/auth/domain/entities/user.entity.ts
Normal file
88
apps/api/src/modules/auth/domain/entities/user.entity.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
2
apps/api/src/modules/auth/domain/events/index.ts
Normal file
2
apps/api/src/modules/auth/domain/events/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UserRegisteredEvent } from './user-registered.event';
|
||||
export { AgentVerifiedEvent } from './agent-verified.event';
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
4
apps/api/src/modules/auth/domain/index.ts
Normal file
4
apps/api/src/modules/auth/domain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './entities';
|
||||
export * from './value-objects';
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
6
apps/api/src/modules/auth/domain/repositories/index.ts
Normal file
6
apps/api/src/modules/auth/domain/repositories/index.ts
Normal 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';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
22
apps/api/src/modules/auth/domain/value-objects/email.vo.ts
Normal file
22
apps/api/src/modules/auth/domain/value-objects/email.vo.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
3
apps/api/src/modules/auth/domain/value-objects/index.ts
Normal file
3
apps/api/src/modules/auth/domain/value-objects/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Email } from './email.vo';
|
||||
export { Phone } from './phone.vo';
|
||||
export { HashedPassword } from './hashed-password.vo';
|
||||
24
apps/api/src/modules/auth/domain/value-objects/phone.vo.ts
Normal file
24
apps/api/src/modules/auth/domain/value-objects/phone.vo.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user