diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index d3f7de93..67a2bc25 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -71,6 +71,7 @@ RUN pip install --no-cache-dir \ opentelemetry-instrumentation-httpx==0.50b0 \ opentelemetry-instrumentation-redis==0.50b0 \ python-magic==0.4.27 \ + pyotp==2.9.0 \ twilio==9.10.5 \ "audioop-lts; python_version >= '3.13'" diff --git a/docs/memory/architecture.md b/docs/memory/architecture.md index 3cd7d970..afd1f33f 100644 --- a/docs/memory/architecture.md +++ b/docs/memory/architecture.md @@ -698,6 +698,7 @@ Open-core seam: enterprise backend code lives in the private `trinity-enterprise | `audit` | (#941) | Entitlement only — flips the OSS audit-log dashboard route visible; `/api/audit-log/*` stays OSS | | `user_management` | `enterprise/backend/user_management/` (#995) | Org lifecycle: invite (whitelist + email), deactivate/reactivate (over the OSS `users.suspended_at` primitive), per-user activity view (reads OSS `audit_log`). `/api/enterprise/user-management/*`; Settings → User Management UI | | `siem` | `enterprise/backend/siem/` (#997) | SIEM log export — ships OSS `audit_log` to a customer SIEM over HTTP/JSON webhook. Private `enterprise_siem_config` (destination + AES-encrypted token + export cursor); Redis-lock-serialised background pusher; at-least-once (cursor advances only on successful POST). `/api/enterprise/siem/*`; no OSS/UI surface | +| `2fa` | `enterprise/backend/two_factor/` (#5) | Two-factor auth via **TOTP** (RFC 6238; Google Authenticator is one compatible app — chosen over Google-OIDC to avoid a runtime IdP dependency). Private `enterprise_user_2fa` (AES-256-GCM secret + monotonic `last_used_step` replay guard), `enterprise_2fa_recovery_codes` (single-use, SHA-256-hashed), `enterprise_2fa_config` (per-role policy). `/api/enterprise/2fa/*` — self-service enroll/confirm/disable/recovery + admin policy + the `login/*` challenge-completion endpoints. Login seam is edition-agnostic: `services/mfa_gate.py` (no provider in OSS → no-op, fail-open) + `create_mfa_challenge_token`/`decode_mfa_challenge` in `dependencies.py`; on a required second factor the OSS auth routers (`/token`, `/api/auth/email/verify`) return a short-lived challenge token instead of an access token. Settings → Security UI | --- diff --git a/src/backend/dependencies.py b/src/backend/dependencies.py index c6b1d34b..7c15d5a2 100644 --- a/src/backend/dependencies.py +++ b/src/backend/dependencies.py @@ -65,6 +65,47 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, m return encoded_jwt +# Scope marker for the short-lived token issued between password/email +# verification and second-factor completion (enterprise 2FA, #5). A token +# carrying this scope is NOT a valid access token — it only authorizes the +# /api/enterprise/2fa/login/* endpoints. +MFA_CHALLENGE_SCOPE = "mfa_challenge" +MFA_CHALLENGE_EXPIRE_MINUTES = 5 + + +def create_mfa_challenge_token(username: str, mode: str = "prod") -> str: + """Mint a short-lived challenge token binding a half-authenticated session + to its eventual login ``mode``. Generic (OSS) — the enterprise module + decides *whether* to require it; this only encodes it. The carried ``mode`` + is replayed into the final access token so admin/email tokens keep their + original mode after the second factor.""" + return create_access_token( + data={"sub": username, "scope": MFA_CHALLENGE_SCOPE}, + expires_delta=timedelta(minutes=MFA_CHALLENGE_EXPIRE_MINUTES), + mode=mode, + ) + + +def decode_mfa_challenge(token: str) -> Optional[dict]: + """Validate a challenge token. Returns ``{"username", "mode"}`` if the + token is a non-expired challenge token for an existing, non-suspended + user; ``None`` otherwise. Used by the enterprise 2FA login endpoints to + resolve the half-authenticated identity before minting the real token.""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError: + return None + if payload.get("scope") != MFA_CHALLENGE_SCOPE: + return None + username = payload.get("sub") + if not username: + return None + user = db.get_user_by_username(username) + if not user or user.get("suspended_at"): + return None + return {"username": username, "mode": payload.get("mode", "prod")} + + def decode_token(token: str) -> Optional[dict]: """ Decode a JWT token without FastAPI dependency. @@ -82,6 +123,10 @@ def decode_token(token: str) -> Optional[dict]: if not username: return None + # #5 — a half-authenticated 2FA challenge token is not a session token. + if payload.get("scope") == MFA_CHALLENGE_SCOPE: + return None + # Get full user record from database user = db.get_user_by_username(username) if not user: @@ -117,6 +162,12 @@ async def get_current_user(request: Request, token: str = Depends(oauth2_scheme) if username is None: raise credentials_exception + # #5 — reject a 2FA challenge token used as a session token. It only + # authorizes /api/enterprise/2fa/login/*; the second factor must be + # completed there to obtain a real access token. + if payload.get("scope") == MFA_CHALLENGE_SCOPE: + raise credentials_exception + user = db.get_user_by_username(username) if user is None: raise credentials_exception diff --git a/src/backend/models.py b/src/backend/models.py index e567f4a8..27f61957 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -65,9 +65,20 @@ class User(BaseModel): class Token(BaseModel): - """JWT token response.""" - access_token: str - token_type: str + """JWT token response. + + Normally carries ``access_token``. When enterprise 2FA (#5) requires a + second factor, the login endpoint instead returns ``mfa_required`` + + ``challenge_token`` and no ``access_token`` — the client completes the + flow at ``/api/enterprise/2fa/login/*`` to obtain the real token. The 2FA + fields are always absent in OSS-only builds. + """ + access_token: Optional[str] = None + token_type: str = "bearer" + mfa_required: Optional[bool] = None + mfa_enrolled: Optional[bool] = None + enrollment_required: Optional[bool] = None + challenge_token: Optional[str] = None class ChatMessageRequest(BaseModel): diff --git a/src/backend/routers/auth.py b/src/backend/routers/auth.py index 7e6140b7..9cf6d7d1 100644 --- a/src/backend/routers/auth.py +++ b/src/backend/routers/auth.py @@ -289,6 +289,26 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends # Update last login timestamp db.update_last_login(user["username"]) + # #5 — enterprise 2FA gate. Password is the first factor; if a second + # factor is required (user enrolled OR policy mandates it for the role) + # return a challenge instead of an access token. OSS-only builds have no + # provider registered → returns None → unchanged behaviour. + from services import mfa_gate + challenge = mfa_gate.gate_login(user, mode="admin") + if challenge: + await platform_audit_service.log( + event_type=AuditEventType.AUTHENTICATION, + event_action="mfa_challenge_issued", + source="api", + actor_ip=client_ip, + target_type="user", + target_id=user["username"], + endpoint=str(request.url.path), + request_id=getattr(request.state, "request_id", None), + details={"method": "admin"}, + ) + return challenge + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user["username"]}, @@ -506,6 +526,25 @@ async def verify_email_login_code(request: Request): # Update last login db.update_last_login(user["username"]) + # #5 — enterprise 2FA gate. The verified email code is the first factor; + # if a second factor is required, return a challenge instead of a token. + # OSS-only builds have no provider → returns None → unchanged behaviour. + from services import mfa_gate + challenge = mfa_gate.gate_login(user, mode="email") + if challenge: + await platform_audit_service.log( + event_type=AuditEventType.AUTHENTICATION, + event_action="mfa_challenge_issued", + source="api", + actor_ip=client_ip, + target_type="user", + target_id=user["username"], + endpoint=str(request.url.path), + request_id=getattr(request.state, "request_id", None), + details={"method": "email", "email": email}, + ) + return challenge + # Create JWT token access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( diff --git a/src/backend/services/mfa_gate.py b/src/backend/services/mfa_gate.py new file mode 100644 index 00000000..161960d6 --- /dev/null +++ b/src/backend/services/mfa_gate.py @@ -0,0 +1,90 @@ +"""MFA gate — the OSS seam the enterprise 2FA module (#5) plugs into. + +The login path (``routers/auth.py``) must stay edition-agnostic: OSS-only +builds have no second factor and behave exactly as before. This module is the +single hook point. An enterprise module registers a *provider* at startup; the +auth routers call :func:`gate_login` after primary credentials are verified. + +* OSS-only build → no provider registered → :func:`gate_login` returns ``None`` + → the router issues the JWT normally. Zero behavioural change. +* Enterprise build with 2FA entitled → the provider decides whether this user + must complete a second factor; if so, :func:`gate_login` returns a *challenge + response* (a short-lived challenge token + flags) and the router returns that + instead of an access token. The frontend then completes the flow against + ``/api/enterprise/2fa/login/*``, which mints the real access token. + +The provider holds all the IP (TOTP verify, policy). This module only knows +the *protocol*: + + provider.gate_decision(user: dict) -> {"enrolled": bool, "required": bool} + +``user`` is the OSS user row dict (``id``, ``username``, ``role``, ``email``). +""" +from __future__ import annotations + +import logging +from typing import Any, Optional, Protocol + +logger = logging.getLogger(__name__) + + +class MfaProvider(Protocol): + def gate_decision(self, user: dict) -> dict: # {"enrolled": bool, "required": bool} + ... + + +_provider: Optional[MfaProvider] = None + + +def register_provider(provider: MfaProvider) -> None: + """Register the enterprise MFA provider. Idempotent (last wins).""" + global _provider + _provider = provider + logger.info("[mfa_gate] provider registered: %s", type(provider).__name__) + + +def get_provider() -> Optional[MfaProvider]: + return _provider + + +def clear_provider() -> None: + """Drop the provider — used by tests to restore the OSS no-op path.""" + global _provider + _provider = None + + +def gate_login(user: dict, mode: str) -> Optional[dict[str, Any]]: + """Decide whether ``user`` must complete a second factor before a token + is issued. Returns ``None`` to proceed normally, or a challenge response. + + Fail-open by design (consistent with Trinity's availability bias for + cross-cutting gates): if the provider errors we log and let the password + factor stand rather than locking everyone out. This is a deliberate + tradeoff documented for #5 — a hard fail-closed would turn any 2FA bug + into a full-platform login outage. + """ + provider = _provider + if provider is None: + return None # OSS-only build — no second factor + try: + decision = provider.gate_decision(user) or {} + except Exception: # noqa: BLE001 — never let a 2FA bug block all logins + logger.exception("[mfa_gate] provider.gate_decision failed; failing open") + return None + + enrolled = bool(decision.get("enrolled")) + required = bool(decision.get("required")) + if not enrolled and not required: + return None # user has no 2FA and policy doesn't force it + + # Late import: keeps this module importable without the full auth stack + # (e.g. isolated unit tests of the registry). + from dependencies import create_mfa_challenge_token + + challenge = create_mfa_challenge_token(user["username"], mode) + return { + "mfa_required": True, + "mfa_enrolled": enrolled, + "enrollment_required": required and not enrolled, + "challenge_token": challenge, + } diff --git a/src/frontend/package.json b/src/frontend/package.json index 77868f89..f57fb452 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -31,6 +31,7 @@ "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "uplot": "^1.6.32", "vue": "^3.5.35", "vue-chartjs": "^5.3.2", diff --git a/src/frontend/src/components/QrCode.vue b/src/frontend/src/components/QrCode.vue new file mode 100644 index 00000000..52ed16cb --- /dev/null +++ b/src/frontend/src/components/QrCode.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/frontend/src/components/settings/TwoFactorPanel.vue b/src/frontend/src/components/settings/TwoFactorPanel.vue new file mode 100644 index 00000000..9c3ba9d1 --- /dev/null +++ b/src/frontend/src/components/settings/TwoFactorPanel.vue @@ -0,0 +1,209 @@ + + + diff --git a/src/frontend/src/stores/auth.js b/src/frontend/src/stores/auth.js index 43451b70..3b095060 100644 --- a/src/frontend/src/stores/auth.js +++ b/src/frontend/src/stores/auth.js @@ -11,6 +11,10 @@ export const useAuthStore = defineStore('auth', { // Runtime mode detection (from backend) emailAuthEnabled: null, // Email-based authentication modeDetected: false, + // Enterprise 2FA (#5): set when a login returns an MFA challenge instead + // of a token. { token, enrolled, enrollmentRequired }. The Login view + // switches to the second-factor step while this is non-null. + mfaChallenge: null, // Promise that resolves when initializeAuth() completes (PERF-269) _initResolve: null, _initPromise: null @@ -185,7 +189,12 @@ export const useAuthStore = defineStore('auth', { formData.append('password', password) const response = await axios.post('/api/token', formData) - this.token = response.data.access_token + + // Enterprise 2FA (#5): a second factor is required — defer the token. + if (response.data?.mfa_required) { + this._setMfaChallenge(response.data) + return false + } // Create a dev user profile const devUser = { @@ -195,16 +204,7 @@ export const useAuthStore = defineStore('auth', { email_verified: true } - this.user = devUser - this.isAuthenticated = true - - localStorage.setItem('token', this.token) - localStorage.setItem('auth0_user', JSON.stringify(devUser)) - this.setupAxiosAuth() - - // Pull the canonical role from the backend (#302). - await this.fetchUserProfile() - + await this._finalizeLogin(response.data.access_token, devUser) console.log('🔐 Admin login: authenticated as', username) return true } catch (error) { @@ -251,15 +251,14 @@ export const useAuthStore = defineStore('auth', { try { const response = await axios.post('/api/auth/email/verify', { email, code }) - this.token = response.data.access_token - this.user = response.data.user - this.isAuthenticated = true - - localStorage.setItem('token', this.token) - localStorage.setItem('auth0_user', JSON.stringify(this.user)) - this.setupAxiosAuth() + // Enterprise 2FA (#5): a second factor is required — defer the token. + if (response.data?.mfa_required) { + this._setMfaChallenge(response.data) + return false + } - console.log('📧 Email auth: authenticated as', this.user.email) + await this._finalizeLogin(response.data.access_token, response.data.user) + console.log('📧 Email auth: authenticated as', this.user?.email) return true } catch (error) { console.error('Verify email code failed:', error) @@ -269,12 +268,96 @@ export const useAuthStore = defineStore('auth', { } }, + // ========================================================================= + // Enterprise Two-Factor Authentication (#5) + // ========================================================================= + + // Shared finalize step: persist the real access token, hydrate the user, + // and pull the canonical role. Used by every login path (admin, email, + // post-2FA). `seedUser` is an optimistic profile overwritten by + // fetchUserProfile(). + async _finalizeLogin(token, seedUser = null) { + this.token = token + if (seedUser) this.user = seedUser + this.isAuthenticated = true + this.mfaChallenge = null + localStorage.setItem('token', token) + if (seedUser) localStorage.setItem('auth0_user', JSON.stringify(seedUser)) + this.setupAxiosAuth() + // Pull the canonical profile/role from the backend (#302). + await this.fetchUserProfile() + }, + + _setMfaChallenge(data) { + this.mfaChallenge = { + token: data.challenge_token, + enrolled: !!data.mfa_enrolled, + enrollmentRequired: !!data.enrollment_required, + } + }, + + cancelMfa() { + this.mfaChallenge = null + }, + + // Complete login by verifying a TOTP or recovery code against the + // outstanding challenge. Returns true on success. + async verifyMfaCode(code) { + if (!this.mfaChallenge) return false + try { + const r = await axios.post('/api/enterprise/2fa/login/verify', { + challenge_token: this.mfaChallenge.token, + code, + }) + await this._finalizeLogin(r.data.access_token) + return true + } catch (error) { + const detail = error.response?.data?.detail || 'Invalid verification code' + this.authError = detail + return false + } + }, + + // Forced enrollment during login (policy requires 2FA, user not enrolled). + // Returns the provisioning payload { secret, otpauth_uri, ... } or null. + async startMfaEnrollment() { + if (!this.mfaChallenge) return null + try { + const r = await axios.post('/api/enterprise/2fa/login/enroll/start', { + challenge_token: this.mfaChallenge.token, + }) + return r.data + } catch (error) { + this.authError = error.response?.data?.detail || 'Failed to start enrollment' + return null + } + }, + + // Confirm forced enrollment with the first code → finalize login. + // Returns { ok, recoveryCodes } so the UI can show the backup codes once. + async confirmMfaEnrollment(code) { + if (!this.mfaChallenge) return { ok: false } + try { + const r = await axios.post('/api/enterprise/2fa/login/enroll/confirm', { + challenge_token: this.mfaChallenge.token, + code, + }) + const recoveryCodes = r.data.recovery_codes || [] + await this._finalizeLogin(r.data.access_token) + return { ok: true, recoveryCodes } + } catch (error) { + this.authError = error.response?.data?.detail || 'Invalid verification code' + return { ok: false } + } + }, + // Logout logout() { this.token = null this.user = null this.isAuthenticated = false this.authError = null + this.mfaChallenge = null localStorage.removeItem('token') localStorage.removeItem('auth0_user') diff --git a/src/frontend/src/views/Login.vue b/src/frontend/src/views/Login.vue index 262ced18..5bb82c0e 100644 --- a/src/frontend/src/views/Login.vue +++ b/src/frontend/src/views/Login.vue @@ -16,6 +16,71 @@

{{ loadingMessage }}

+ +
+ +
+

Save your recovery codes

+

+ Store these somewhere safe — each works once if you lose your authenticator. They won't be shown again. +

+
+ {{ c }} +
+ +
+ + +
+

Set up two-factor authentication

+

+ Your account requires 2FA. Scan this QR with an authenticator app, then enter the 6-digit code. +

+ +

Preparing enrollment…

+ +
+ + +
+
+ + +
+

Two-factor authentication

+

+ Enter the 6-digit code from your authenticator app, or a recovery code. +

+
+ + +
+
+ +

{{ authError }}

+ +
+
@@ -175,9 +240,10 @@