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;
|
kycStatus: KYCStatus;
|
||||||
kycData: unknown;
|
kycData: unknown;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
deletedAt: Date | null;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
totpBackupCodes: string[];
|
totpBackupCodes: string[];
|
||||||
@@ -33,6 +34,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
private _kycStatus: KYCStatus;
|
private _kycStatus: KYCStatus;
|
||||||
private _kycData: unknown;
|
private _kycData: unknown;
|
||||||
private _isActive: boolean;
|
private _isActive: boolean;
|
||||||
|
private _deletedAt: Date | null;
|
||||||
private _totpSecret: string | null;
|
private _totpSecret: string | null;
|
||||||
private _totpEnabled: boolean;
|
private _totpEnabled: boolean;
|
||||||
private _totpBackupCodes: string[];
|
private _totpBackupCodes: string[];
|
||||||
@@ -49,6 +51,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
this._kycStatus = props.kycStatus;
|
this._kycStatus = props.kycStatus;
|
||||||
this._kycData = props.kycData;
|
this._kycData = props.kycData;
|
||||||
this._isActive = props.isActive;
|
this._isActive = props.isActive;
|
||||||
|
this._deletedAt = props.deletedAt;
|
||||||
this._totpSecret = props.totpSecret;
|
this._totpSecret = props.totpSecret;
|
||||||
this._totpEnabled = props.totpEnabled;
|
this._totpEnabled = props.totpEnabled;
|
||||||
this._totpBackupCodes = props.totpBackupCodes;
|
this._totpBackupCodes = props.totpBackupCodes;
|
||||||
@@ -64,6 +67,7 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
get kycStatus(): KYCStatus { return this._kycStatus; }
|
get kycStatus(): KYCStatus { return this._kycStatus; }
|
||||||
get kycData(): unknown { return this._kycData; }
|
get kycData(): unknown { return this._kycData; }
|
||||||
get isActive(): boolean { return this._isActive; }
|
get isActive(): boolean { return this._isActive; }
|
||||||
|
get deletedAt(): Date | null { return this._deletedAt; }
|
||||||
get totpSecret(): string | null { return this._totpSecret; }
|
get totpSecret(): string | null { return this._totpSecret; }
|
||||||
get totpEnabled(): boolean { return this._totpEnabled; }
|
get totpEnabled(): boolean { return this._totpEnabled; }
|
||||||
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
|
||||||
@@ -87,6 +91,44 @@ export class UserEntity extends AggregateRoot<string> {
|
|||||||
kycStatus: 'NONE',
|
kycStatus: 'NONE',
|
||||||
kycData: null,
|
kycData: null,
|
||||||
isActive: true,
|
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,
|
totpSecret: null,
|
||||||
totpEnabled: false,
|
totpEnabled: false,
|
||||||
totpBackupCodes: [],
|
totpBackupCodes: [],
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -101,6 +102,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
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 () => {
|
it('throws when password is wrong', async () => {
|
||||||
mockUserRepo.findByPhone.mockResolvedValue({
|
mockUserRepo.findByPhone.mockResolvedValue({
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(false) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(false) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -129,6 +147,8 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
passwordHash: { compare: vi.fn().mockResolvedValue(true) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
|
totpEnabled: false,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
@@ -139,6 +159,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
phone: '+84912345678',
|
phone: '+84912345678',
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
|
isMfaRequired: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +194,7 @@ describe('LocalStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) },
|
passwordHash: { compare: vi.fn().mockRejectedValue(new Error('bcrypt internal error')) },
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
phone: { value: '+84912345678' },
|
phone: { value: '+84912345678' },
|
||||||
role: 'BUYER',
|
role: 'BUYER',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export class PrismaUserRepository implements IUserRepository {
|
|||||||
kycStatus: raw.kycStatus,
|
kycStatus: raw.kycStatus,
|
||||||
kycData: raw.kycData,
|
kycData: raw.kycData,
|
||||||
isActive: raw.isActive,
|
isActive: raw.isActive,
|
||||||
|
deletedAt: raw.deletedAt,
|
||||||
totpSecret: raw.totpSecret,
|
totpSecret: raw.totpSecret,
|
||||||
totpEnabled: raw.totpEnabled,
|
totpEnabled: raw.totpEnabled,
|
||||||
totpBackupCodes: raw.totpBackupCodes,
|
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');
|
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);
|
const isValid = await user.passwordHash.compare(password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
throw new UnauthorizedException('Số điện thoại hoặc mật khẩu không đúng');
|
||||||
|
|||||||
Reference in New Issue
Block a user