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
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:
@@ -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: [],
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user