diff --git a/.changeset/adr-0069-d3-enforced-mfa.md b/.changeset/adr-0069-d3-enforced-mfa.md new file mode 100644 index 0000000000..f6d60d5ca3 --- /dev/null +++ b/.changeset/adr-0069-d3-enforced-mfa.md @@ -0,0 +1,17 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +--- + +Auth: enforced MFA (ADR-0069 D3, P1) + +Completes the session-validation gate: when `mfa_required` (new `auth` setting) is on, an authenticated user without TOTP enrolled is blocked from protected resources with `403 MFA_REQUIRED` once their `mfa_grace_period_days` (default 7) window elapses — while the two-factor enrollment endpoints stay reachable so they can comply. Reuses the `authGate` seam shipped in #2388 (a second posture branch in `computeAuthGate`). + +- New `auth` settings `mfa_required` (toggle) + `mfa_grace_period_days`; enabling `mfa_required` also force-enables the `twoFactor` plugin so `/two-factor/*` enrollment exists. +- New `sys_user.mfa_required_at` column — the grace clock, stamped lazily the first time a user is seen required-but-unenrolled. +- `isAuthGateActive()` now also trips on `mfa_required` (still zero-overhead when off). + +Default-off / additive (no upgrade behavior change); per ADR-0049 the setting ships with its enforcement. + +**Needs an objectui follow-up**: the Console should handle a `403 MFA_REQUIRED` by showing the TOTP-enrollment prompt. Per-org `sys_organization.require_mfa` and the dispatcher/MCP gate remain follow-ups (#2375). diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index ce2f715eb5..e60cf51511 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -458,6 +458,16 @@ export const SysUser = ObjectSchema.create({ description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.', }), + // ADR-0069 D3 — when enforced MFA first applied to this user; starts the + // grace clock. Stamped lazily at session validation; system-managed. + mfa_required_at: Field.datetime({ + label: 'MFA Required At', + required: false, + readonly: true, + group: 'Admin', + description: 'When enforced MFA first applied to this user (grace-period clock). Backs mfa_required; system-managed.', + }), + ai_access: Field.boolean({ label: 'AI Access', defaultValue: false, diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 35a5e95833..99c00a12f2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1908,4 +1908,71 @@ describe('AuthManager', () => { expect(arg.password_changed_at instanceof Date).toBe(true); }); }); + + // ADR-0069 D3: enforced MFA — reuses the auth-gate seam (same computeAuthGate). + describe('enforced MFA gate (ADR-0069 D3)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + const makeEngine = (user: any) => ({ + findOne: vi.fn(async (_o: string, q: any) => { + const w = q?.where ?? {}; + if (!user) return null; + return Object.entries(w).every(([k, v]) => (user as any)[k] === v) ? user : null; + }), + update: vi.fn(async () => ({})), + }); + const mgr = (engine: any, extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra }); + warn.mockRestore(); + return m; + }; + + it('isAuthGateActive is true when mfaRequired (even with expiry off)', () => { + expect(mgr(makeEngine(null), { mfaRequired: true }).isAuthGateActive()).toBe(true); + expect(mgr(makeEngine(null), {}).isAuthGateActive()).toBe(false); + }); + + it('blocks MFA_REQUIRED for an un-enrolled user past the grace window', async () => { + const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: old }), { + mfaRequired: true, mfaGracePeriodDays: 7, + }); + const g = await (m as any).computeAuthGate('u1', undefined, false); + expect(g?.code).toBe('MFA_REQUIRED'); + }); + + it('does NOT block within the grace window', async () => { + const recent = new Date(Date.now() - 1 * 86_400_000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: recent }), { + mfaRequired: true, mfaGracePeriodDays: 7, + }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); + + it('stamps mfa_required_at the first time (null) and does not block yet', async () => { + const engine = makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: null }); + const m = mgr(engine, { mfaRequired: true, mfaGracePeriodDays: 7 }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + const wrote = engine.update.mock.calls.find((c: any[]) => 'mfa_required_at' in (c[1] ?? {})); + expect(wrote).toBeTruthy(); + expect(wrote[1].mfa_required_at instanceof Date).toBe(true); + }); + + it('does NOT block an enrolled user (two_factor_enabled)', async () => { + const old = new Date(Date.now() - 30 * 86_400_000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: true, mfa_required_at: old }), { + mfaRequired: true, mfaGracePeriodDays: 7, + }); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); + + it('grace 0 blocks an un-enrolled user immediately (after the clock is set)', async () => { + const past = new Date(Date.now() - 1000).toISOString(); + const m = mgr(makeEngine({ id: 'u1', two_factor_enabled: false, mfa_required_at: past }), { + mfaRequired: true, mfaGracePeriodDays: 0, + }); + const g = await (m as any).computeAuthGate('u1', undefined, false); + expect(g?.code).toBe('MFA_REQUIRED'); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 17c10c7d56..3273b366e4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -311,6 +311,17 @@ export interface AuthManagerOptions extends Partial { */ passwordExpiryDays?: number; + /** + * ADR-0069 D3 — enforced MFA. When true, an authenticated user without TOTP + * enrolled (`sys_user.two_factor_enabled`) is gated out of protected resources + * (`MFA_REQUIRED`) once their grace window elapses, until they enroll. Shares + * the `customSession` → `user.authGate` seam with password expiry. + */ + mfaRequired?: boolean; + + /** Days a user may defer MFA enrollment before the hard block. Default 7. */ + mfaGracePeriodDays?: number; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -2275,7 +2286,10 @@ export class AuthManager { * default), keeping the gate zero-overhead until an admin opts in. */ public isAuthGateActive(): boolean { - return (Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0); + return ( + Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || + this.config.mfaRequired === true + ); } /** @@ -2288,28 +2302,56 @@ export class AuthManager { private async computeAuthGate( userId: string, _activeOrgId: string | undefined, - _twoFactorEnabled: boolean, + _twoFactorEnabledHint: boolean, ): Promise<{ code: string; message: string } | undefined> { const expiryDays = Math.floor(Number(this.config.passwordExpiryDays) || 0); - if (expiryDays <= 0) return undefined; // no gate feature active + const mfaRequired = this.config.mfaRequired === true; + if (expiryDays <= 0 && !mfaRequired) return undefined; // no gate feature active const engine = this.getDataEngine(); if (!engine || !userId) return undefined; try { const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; const u = await engine.findOne('sys_user', { - where: { id: userId }, fields: ['password_changed_at'], context: SYSTEM_CTX, + where: { id: userId }, + fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at'], + context: SYSTEM_CTX, } as any); - const changed = u?.password_changed_at; - // Null = never expires (existing accounts on upgrade) until the next change. - if (changed) { - const ageMs = Date.now() - new Date(changed).getTime(); - if (ageMs > expiryDays * 86_400_000) { + + // ── Password expiry ─────────────────────────────────────────────── + if (expiryDays > 0) { + const changed = u?.password_changed_at; + // Null = never expires (existing accounts on upgrade) until next change. + if (changed && Date.now() - new Date(changed).getTime() > expiryDays * 86_400_000) { return { code: 'PASSWORD_EXPIRED', message: 'Your password has expired. Please change it to continue.', }; } } + + // ── Enforced MFA ────────────────────────────────────────────────── + // A user without TOTP enrolled is blocked once their grace window + // elapses. The clock (`mfa_required_at`) starts the first time we see + // them required-but-unenrolled (stamped lazily, best-effort). + if (mfaRequired && !(u?.two_factor_enabled === true || u?.two_factor_enabled === 1)) { + const graceDays = Math.max(0, Math.floor(Number(this.config.mfaGracePeriodDays ?? 7))); + let requiredAt = u?.mfa_required_at; + if (!requiredAt) { + requiredAt = new Date(); + // Best-effort: start the grace clock; never block on the write. + engine + .update('sys_user', { id: userId, mfa_required_at: requiredAt }, { context: SYSTEM_CTX } as any) + .catch(() => undefined); + } + const elapsedMs = Date.now() - new Date(requiredAt).getTime(); + if (elapsedMs > graceDays * 86_400_000) { + return { + code: 'MFA_REQUIRED', + message: + 'Multi-factor authentication is required. Please set up an authenticator app to continue.', + }; + } + } } catch { return undefined; // fail-open } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 028c31cf6c..d6acd46a9b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -694,6 +694,24 @@ describe('AuthPlugin', () => { expect((manager as any).isAuthGateActive()).toBe(true); }); + it('binds mfa_required and force-enables the twoFactor plugin (ADR-0069 D3)', async () => { + const { manager } = await bootWithAuthSettings({ mfa_required: { value: true, source: 'global' } }); + const cfg = (manager as any).config; + expect(cfg.mfaRequired).toBe(true); + expect(cfg.plugins?.twoFactor).toBe(true); + expect((manager as any).isAuthGateActive()).toBe(true); + }); + + it('binds mfa_grace_period_days', async () => { + const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 14, source: 'global' } }); + expect((manager as any).config.mfaGracePeriodDays).toBe(14); + }); + + it('clamps mfa_grace_period_days to a max of 90', async () => { + const { manager } = await bootWithAuthSettings({ mfa_grace_period_days: { value: 999, source: 'global' } }); + expect((manager as any).config.mfaGracePeriodDays).toBe(90); + }); + it('binds account-lockout settings (ADR-0069 D2)', async () => { const { manager } = await bootWithAuthSettings({ lockout_threshold: { value: 5, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index b2d8cfcef2..97eeb658be 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -518,6 +518,24 @@ export class AuthPlugin implements Plugin { if (Number.isFinite(n) && n >= 0) patch.passwordExpiryDays = Math.min(3650, n); } + // Enforced MFA (ADR-0069 D3). Enabling it also turns the twoFactor + // plugin on so the /two-factor/* enrollment endpoints exist — otherwise + // gated users would have no way to comply. + if (isExplicit('mfa_required')) { + const on = asBoolean(values.mfa_required, false); + patch.mfaRequired = on; + if (on) { + patch.plugins = { + ...(patch.plugins ?? {}), + twoFactor: true, + } as AuthManagerOptions['plugins']; + } + } + if (isExplicit('mfa_grace_period_days')) { + const n = Math.floor(Number(values.mfa_grace_period_days)); + if (Number.isFinite(n) && n >= 0) patch.mfaGracePeriodDays = Math.min(90, n); + } + // Session lifetime — days → seconds for better-auth's `session` // (`expiresIn` = absolute lifetime; `updateAge` = refresh threshold). const session: { expiresIn?: number; updateAge?: number } = {}; diff --git a/packages/services/service-settings/src/manifests/auth.manifest.test.ts b/packages/services/service-settings/src/manifests/auth.manifest.test.ts index f202e584f3..f1f2be9e55 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.test.ts @@ -25,6 +25,7 @@ describe('authSettingsManifest', () => { expect(keys).toEqual([ 'email_password_enabled', 'google_enabled', + 'mfa_required', 'password_reject_breached', 'password_require_complexity', 'require_email_verification', @@ -66,6 +67,7 @@ describe('authSettingsManifest', () => { expect(byKey('rate_limit_window_seconds').default).toBe(60); expect(byKey('password_history_count').default).toBe(0); expect(byKey('password_expiry_days').default).toBe(0); + expect(byKey('mfa_grace_period_days').default).toBe(7); }); it('exposes encrypted Google OAuth credential fields', () => { diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index a369f4b9e8..04ec2ce9ce 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -180,6 +180,34 @@ const manifest = { max: 3600, description: 'Sliding window over which the request cap above is counted.', }, + { + type: 'group', + id: 'multi_factor', + label: 'Multi-factor', + required: false, + description: 'Require members to protect their account with an authenticator app (TOTP).', + }, + { + type: 'toggle', + key: 'mfa_required', + label: 'Require multi-factor authentication', + required: false, + default: false, + description: + 'Users without an authenticator enrolled are blocked from data once their grace period ends. Enabling this also turns on the two-factor feature so users can enroll.', + visible: "${data.email_password_enabled !== false}", + }, + { + type: 'number', + key: 'mfa_grace_period_days', + label: 'MFA grace period (days)', + required: false, + default: 7, + min: 0, + max: 90, + description: 'How long users may defer enrollment before the hard block. 0 blocks immediately.', + visible: "${data.mfa_required === true}", + }, { type: 'group', id: 'sessions',