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>
213 lines
6.6 KiB
TypeScript
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();
|
|
}
|
|
}
|