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>
This commit is contained in:
@@ -22,6 +22,8 @@ export interface UserProps {
|
||||
totpEnabled: boolean;
|
||||
totpBackupCodes: string[];
|
||||
totpEnabledAt: Date | null;
|
||||
mfaGraceStartedAt: Date | null;
|
||||
mfaLastVerifiedAt: Date | null;
|
||||
}
|
||||
|
||||
export class UserEntity extends AggregateRoot<string> {
|
||||
@@ -39,6 +41,8 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
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);
|
||||
@@ -56,6 +60,8 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
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; }
|
||||
@@ -72,6 +78,8 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
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,
|
||||
@@ -96,6 +104,8 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
mfaGraceStartedAt: null,
|
||||
mfaLastVerifiedAt: null,
|
||||
});
|
||||
|
||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||
@@ -133,6 +143,8 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
totpEnabled: false,
|
||||
totpBackupCodes: [],
|
||||
totpEnabledAt: null,
|
||||
mfaGraceStartedAt: null,
|
||||
mfaLastVerifiedAt: null,
|
||||
});
|
||||
|
||||
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
|
||||
|
||||
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
28
apps/api/src/modules/auth/domain/mfa-policy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* MFA enrolment policy — central source of truth for which roles require
|
||||
* TOTP and how long the grace period lasts.
|
||||
*
|
||||
* Backed by `User.mfaGraceStartedAt` and `User.mfaLastVerifiedAt` columns.
|
||||
*
|
||||
* Policy summary:
|
||||
* - On first login under enforcement, `mfaGraceStartedAt` is stamped.
|
||||
* - For `MFA_GRACE_PERIOD_DAYS` after that timestamp, the user keeps full
|
||||
* access but receives `mfa: 'grace'` in their JWT (UI nudges enrollment).
|
||||
* - After grace expires, the JWT carries `mfa: 'enrollment_required'` and
|
||||
* sensitive routes (admin guards) reject until the user enrols.
|
||||
*/
|
||||
|
||||
/** Roles for which TOTP is mandatory after the grace window expires. */
|
||||
export const MFA_REQUIRED_ROLES: ReadonlyArray<UserRole> = ['ADMIN'];
|
||||
|
||||
/** Length of the grace window before MFA enrolment becomes mandatory. */
|
||||
export const MFA_GRACE_PERIOD_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Re-auth window for "step-up" admin operations (e.g. user impersonation,
|
||||
* mass actions). After this many minutes since `mfaLastVerifiedAt`, the
|
||||
* admin re-auth interceptor must challenge again.
|
||||
*/
|
||||
export const MFA_REAUTH_WINDOW_MINUTES = 15;
|
||||
@@ -12,4 +12,6 @@ export interface IUserRepository {
|
||||
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
|
||||
updateMfaDisabled(userId: string): Promise<void>;
|
||||
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
|
||||
updateMfaGraceStartedAt(userId: string, date: Date): Promise<void>;
|
||||
updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user