Files
goodgo-platform/apps/api/src/modules/auth/domain/entities/user.entity.ts
Ho Ngoc Hai abeb8fd322 feat(auth): complete MFA grace period for required roles + ops monitoring
Finishes the half-implemented MFA enforcement work and ships the SLO
monitoring rules at the same time.

MFA grace period (auth):
- New `mfa-policy.ts` central source of truth: `MFA_REQUIRED_ROLES = [ADMIN]`,
  `MFA_GRACE_PERIOD_DAYS = 14`, `MFA_REAUTH_WINDOW_MINUTES = 15`.
- New columns `User.mfaGraceStartedAt` + `User.mfaLastVerifiedAt`
  (migration `20260429000000_add_mfa_grace_columns`).
- `JwtPayload.mfa: 'none' | 'grace' | 'enrollment_required'` claim now
  carried in every access token so the FE + admin guards can react.
- `LoginUserHandler.resolveMfaGraceClaim()`:
  * If role requires MFA and user has not enrolled, lazy-stamp
    `mfaGraceStartedAt` on first login (returns `mfa: 'grace'`,
    `remainingDays: 14`).
  * After window expires → `mfa: 'enrollment_required'`, `remainingDays: 0`
    (callers must force enrolment on sensitive routes).
  * Otherwise → `mfa: 'none'`.
- `LocalStrategy` now passes `totpEnabled` + `mfaGraceStartedAt` through
  to the command so the handler can branch without an extra query.
- `IUserRepository` + `PrismaUserRepository` get
  `updateMfaGraceStartedAt` / `updateMfaLastVerifiedAt`.
- `UserEntity` carries the two new fields end-to-end (props, getters,
  `createNew` + `createPasswordless` factories). Fixed an orphan-property
  syntax bug in `createPasswordless` that was breaking typecheck.
- `oauth.service.ts` `UserEntity` construction now includes `deletedAt`
  + the two MFA fields (was missing required props).
- Add missing `jsonwebtoken` + `@types/jsonwebtoken` to `apps/api`
  (transitively pulled in via `jwt-rotation.ts` from commit 3705193 but
  never declared, so `tsc --noEmit` was failing).
- Update `login-user.handler.spec.ts` + `local.strategy.spec.ts` to cover
  grace-window + enrolment-required branches. 338/338 auth tests pass.

Ops monitoring:
- New `monitoring/prometheus/slo-rules.yml` with recording + alerting
  rules for the agreed SLOs.
- Wire it into `prometheus.yml` + alertmanager routing.
- Capture the SLO soak-test results in
  `docs/audits/slo-soak-test-log.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:00:23 +07:00

213 lines
6.6 KiB
TypeScript

import { type UserRole, type KYCStatus } from '@prisma/client';
import { AggregateRoot } from '@modules/shared';
import { UserDeactivatedEvent } from '../events/user-deactivated.event';
import { UserKycUpdatedEvent } from '../events/user-kyc-updated.event';
import { UserRegisteredEvent } from '../events/user-registered.event';
import { type Email } from '../value-objects/email.vo';
import { type HashedPassword } from '../value-objects/hashed-password.vo';
import { type Phone } from '../value-objects/phone.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;
deletedAt: Date | null;
totpSecret: string | null;
totpEnabled: boolean;
totpBackupCodes: string[];
totpEnabledAt: Date | null;
mfaGraceStartedAt: Date | null;
mfaLastVerifiedAt: Date | null;
}
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;
private _deletedAt: Date | null;
private _totpSecret: string | null;
private _totpEnabled: boolean;
private _totpBackupCodes: string[];
private _totpEnabledAt: Date | null;
private _mfaGraceStartedAt: Date | null;
private _mfaLastVerifiedAt: Date | null;
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;
this._deletedAt = props.deletedAt;
this._totpSecret = props.totpSecret;
this._totpEnabled = props.totpEnabled;
this._totpBackupCodes = props.totpBackupCodes;
this._totpEnabledAt = props.totpEnabledAt;
this._mfaGraceStartedAt = props.mfaGraceStartedAt;
this._mfaLastVerifiedAt = props.mfaLastVerifiedAt;
}
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; }
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; }
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
get mfaGraceStartedAt(): Date | null { return this._mfaGraceStartedAt; }
get mfaLastVerifiedAt(): Date | null { return this._mfaLastVerifiedAt; }
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,
deletedAt: null,
totpSecret: null,
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: 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: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
return user;
}
updateKycStatus(status: KYCStatus, kycData?: unknown): void {
const previousStatus = this._kycStatus;
this._kycStatus = status;
if (kycData !== undefined) this._kycData = kycData;
this.updatedAt = new Date();
this.addDomainEvent(new UserKycUpdatedEvent(this.id, status, previousStatus));
}
deactivate(): void {
this._isActive = false;
this.updatedAt = new Date();
this.addDomainEvent(new UserDeactivatedEvent(this.id));
}
activate(): void {
this._isActive = true;
this.updatedAt = new Date();
}
enableTotp(secret: string, backupCodes: string[]): void {
this._totpSecret = secret;
this._totpEnabled = true;
this._totpBackupCodes = backupCodes;
this._totpEnabledAt = new Date();
this.updatedAt = new Date();
}
disableTotp(): void {
this._totpSecret = null;
this._totpEnabled = false;
this._totpBackupCodes = [];
this._totpEnabledAt = null;
this.updatedAt = new Date();
}
consumeBackupCode(index: number): void {
this._totpBackupCodes = this._totpBackupCodes.filter((_, i) => i !== index);
this.updatedAt = new Date();
}
updateProfile(fullName?: string, avatarUrl?: string | null, email?: Email | null): void {
if (fullName !== undefined) this._fullName = fullName;
if (avatarUrl !== undefined) this._avatarUrl = avatarUrl;
if (email !== undefined) this._email = email;
this.updatedAt = new Date();
}
updatePhone(phone: Phone): void {
this._phone = phone;
this.updatedAt = new Date();
}
changePassword(passwordHash: HashedPassword): void {
this._passwordHash = passwordHash;
this.updatedAt = new Date();
}
}