diff --git a/apps/api/src/modules/auth/domain/entities/user.entity.ts b/apps/api/src/modules/auth/domain/entities/user.entity.ts index 2e6b473..ecd5180 100644 --- a/apps/api/src/modules/auth/domain/entities/user.entity.ts +++ b/apps/api/src/modules/auth/domain/entities/user.entity.ts @@ -17,6 +17,7 @@ export interface UserProps { kycStatus: KYCStatus; kycData: unknown; isActive: boolean; + deletedAt: Date | null; totpSecret: string | null; totpEnabled: boolean; totpBackupCodes: string[]; @@ -33,6 +34,7 @@ export class UserEntity extends AggregateRoot { private _kycStatus: KYCStatus; private _kycData: unknown; private _isActive: boolean; + private _deletedAt: Date | null; private _totpSecret: string | null; private _totpEnabled: boolean; private _totpBackupCodes: string[]; @@ -49,6 +51,7 @@ export class UserEntity extends AggregateRoot { this._kycStatus = props.kycStatus; this._kycData = props.kycData; this._isActive = props.isActive; + this._deletedAt = props.deletedAt; this._totpSecret = props.totpSecret; this._totpEnabled = props.totpEnabled; this._totpBackupCodes = props.totpBackupCodes; @@ -64,6 +67,7 @@ export class UserEntity extends AggregateRoot { get kycStatus(): KYCStatus { return this._kycStatus; } get kycData(): unknown { return this._kycData; } get isActive(): boolean { return this._isActive; } + get deletedAt(): Date | null { return this._deletedAt; } get totpSecret(): string | null { return this._totpSecret; } get totpEnabled(): boolean { return this._totpEnabled; } get totpBackupCodes(): string[] { return this._totpBackupCodes; } @@ -87,6 +91,44 @@ export class UserEntity extends AggregateRoot { kycStatus: 'NONE', kycData: null, isActive: true, + deletedAt: null, + totpSecret: null, + totpEnabled: false, + totpBackupCodes: [], + totpEnabledAt: null, + }); + + user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role)); + return user; + } + + /** + * Create a passwordless user (e.g. via Phone-OTP login auto-register). + * `passwordHash` is null so password login is not possible until the user + * sets one via the password-reset / profile flow. A fullName fallback is + * used since OTP signup does not collect a name. + */ + static createPasswordless( + id: string, + phone: Phone, + fullName?: string, + role: UserRole = 'BUYER', + ): UserEntity { + const displayName = + fullName && fullName.trim().length > 0 + ? fullName + : `Người dùng ${phone.value.slice(-4)}`; + const user = new UserEntity(id, { + email: null, + phone, + passwordHash: null, + fullName: displayName, + avatarUrl: null, + role, + kycStatus: 'NONE', + kycData: null, + isActive: true, + deletedAt: null, totpSecret: null, totpEnabled: false, totpBackupCodes: [], diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts index 24b6245..8d01bd1 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/local.strategy.spec.ts @@ -87,6 +87,7 @@ describe('LocalStrategy', () => { id: 'user-1', passwordHash: null, isActive: true, + deletedAt: null, phone: { value: '+84912345678' }, role: 'BUYER', }); @@ -101,6 +102,7 @@ describe('LocalStrategy', () => { id: 'user-1', passwordHash: { compare: vi.fn().mockResolvedValue(true) }, isActive: false, + deletedAt: null, phone: { value: '+84912345678' }, role: 'BUYER', }); @@ -110,11 +112,27 @@ describe('LocalStrategy', () => { ); }); + it('throws 401 when user is soft-deleted (deletedAt set)', async () => { + mockUserRepo.findByPhone.mockResolvedValue({ + id: 'user-1', + passwordHash: { compare: vi.fn().mockResolvedValue(true) }, + isActive: true, + deletedAt: new Date('2026-01-01T00:00:00.000Z'), + phone: { value: '+84912345678' }, + role: 'BUYER', + }); + + await expect(strategy.validate('0912345678', 'password')).rejects.toThrow( + 'Tài khoản đã bị xóa', + ); + }); + it('throws when password is wrong', async () => { mockUserRepo.findByPhone.mockResolvedValue({ id: 'user-1', passwordHash: { compare: vi.fn().mockResolvedValue(false) }, isActive: true, + deletedAt: null, phone: { value: '+84912345678' }, role: 'BUYER', }); @@ -129,6 +147,8 @@ describe('LocalStrategy', () => { id: 'user-1', passwordHash: { compare: vi.fn().mockResolvedValue(true) }, isActive: true, + deletedAt: null, + totpEnabled: false, phone: { value: '+84912345678' }, role: 'BUYER', }); @@ -139,6 +159,7 @@ describe('LocalStrategy', () => { id: 'user-1', phone: '+84912345678', role: 'BUYER', + isMfaRequired: false, }); }); @@ -173,6 +194,7 @@ describe('LocalStrategy', () => { id: 'user-1', passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) }, isActive: true, + deletedAt: null, phone: { value: '+84912345678' }, role: 'BUYER', }); diff --git a/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts index b6e22fd..91e591a 100644 --- a/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts +++ b/apps/api/src/modules/auth/infrastructure/repositories/prisma-user.repository.ts @@ -140,6 +140,7 @@ export class PrismaUserRepository implements IUserRepository { kycStatus: raw.kycStatus, kycData: raw.kycData, isActive: raw.isActive, + deletedAt: raw.deletedAt, totpSecret: raw.totpSecret, totpEnabled: raw.totpEnabled, totpBackupCodes: raw.totpBackupCodes, diff --git a/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts index 5022228..d222a81 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/local.strategy.ts @@ -42,6 +42,10 @@ export class LocalStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('Tài khoản đã bị vô hiệu hóa'); } + if (user.deletedAt !== null) { + throw new UnauthorizedException('Tài khoản đã bị xóa'); + } + const isValid = await user.passwordHash.compare(password); if (!isValid) { throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');