feat(auth): prevent soft-deleted users from authenticating (GOO-15)
Some checks failed
CI / AI Services (Python) — Smoke (push) Failing after 26s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m37s
Deploy / Build Web Image (push) Failing after 1m9s
Deploy / Build AI Services Image (push) Failing after 37s
E2E Tests / Playwright E2E (push) Failing after 56s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11m58s
Deploy / Build API Image (push) Failing after 12m43s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 9s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m27s
Security Scanning / Trivy Scan — Web Image (push) Failing after 43s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 30s
Security Scanning / Trivy Filesystem Scan (push) Failing after 32s
Security Scanning / Security Gate (push) Failing after 1s
CI / E2E Tests (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled

- Add deletedAt field to UserProps interface and UserEntity
- Map raw.deletedAt in PrismaUserRepository.toDomain()
- Check deletedAt !== null in LocalStrategy.validate() → 401 Tài khoản đã bị xóa
- Update existing LocalStrategy tests with deletedAt: null on valid mocks
- Add test: soft-deleted user login → 401

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-22 23:40:43 +07:00
parent 23af73496d
commit 8706fff92f
4 changed files with 69 additions and 0 deletions

View File

@@ -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<string> {
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<string> {
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<string> {
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<string> {
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: [],

View File

@@ -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',
});

View File

@@ -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,

View File

@@ -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');