Files
vulncheck/app/auth/mfa_policy.py
T
vulncheck 4036350794 feat(auth): policy-driven MFA enforcement with forced enrolment
Settings → Security card lets admins require TOTP for selected roles
(admin/editor by default). Affected local users hit a dedicated
forced-enrolment page at next login with no session issued until they
scan the QR and submit a valid code. SSO/LDAP users exempt by default;
opt-in via mfa_enforce_sso_users=true redirects them to the same page
after the IdP callback.

Backend
- app/auth/mfa_policy.py: reads mfa_enforced / mfa_enforced_roles /
  mfa_enforce_sso_users from the settings KV. Fail-open on any DB
  hiccup to avoid lockouts.
- AuthResult gains mfa_setup_required (mutually exclusive with
  mfa_required). Orchestrator sets it in both credential and SSO paths.
- /auth/login short-circuits to a setup-token (10 min TTL) before any
  session cookies are issued.
- New endpoints POST /auth/mfa/forced-setup/{start,activate}.
  Activate issues a full session — the user has just proven a fresh
  TOTP code and authenticated with their password seconds earlier.
- OIDC + SAML callbacks redirect to /auth/forced-mfa-setup?token=...
  when the enforced-SSO toggle is on.

Frontend
- Login page handles mfa_setup_required by redirecting.
- New page /auth/forced-mfa-setup shows QR + secret + code form.
- Settings → Security card with three toggles (enforced / roles /
  enforce-sso). Saves to the existing settings KV — no new endpoint.

Three new settings keys, no schema migration. Existing deploys stay
unchanged until an admin flips mfa_enforced=true.
2026-05-24 13:54:34 +02:00

105 lines
3.5 KiB
Python

"""
MFA enforcement policy.
Reads the relevant `settings` rows and decides whether a freshly-
authenticated user has to enrol TOTP before being allowed into a full
session. Centralised here so login + SSO callbacks call the exact same
logic.
Settings (KV in `settings` table):
- `mfa_enforced` "true" | "false" default: "false"
- `mfa_enforced_roles` JSON list or csv default: '["admin","editor"]'
- `mfa_enforce_sso_users` "true" | "false" default: "false"
(SAML/OIDC/LDAP users normally rely on
the IdP/AD-side MFA; flip on if you want
an additional TOTP layer.)
If `mfa_enforced=false` the function is a no-op — existing deploys
stay unchanged until an admin flips the toggle.
"""
from __future__ import annotations
import json
import logging
from typing import List
from sqlalchemy.orm import Session
from app.models.setting import Setting
from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__)
_KEY_ENFORCED = "mfa_enforced"
_KEY_ROLES = "mfa_enforced_roles"
_KEY_ENFORCE_SSO = "mfa_enforce_sso_users"
_DEFAULT_ROLES = [UserRole.ADMIN.value, UserRole.EDITOR.value]
def _bool(value: str | None) -> bool:
return (value or "").strip().lower() in ("true", "1", "yes", "on")
def _read_setting(db: Session, key: str) -> str | None:
row = db.query(Setting).filter(Setting.key == key).first()
return row.value if row else None
def _parse_roles(value: str | None) -> List[str]:
if not value:
return list(_DEFAULT_ROLES)
raw = value.strip()
# Accept JSON array, csv, or single role string.
if raw.startswith("["):
try:
arr = json.loads(raw)
return [str(r).strip().lower() for r in arr if str(r).strip()]
except json.JSONDecodeError:
logger.warning("mfa_enforced_roles is not valid JSON: %r", raw)
return list(_DEFAULT_ROLES)
return [r.strip().lower() for r in raw.split(",") if r.strip()]
def is_enforced(db: Session) -> bool:
"""Global on/off toggle. Returns False unless setting explicitly true."""
return _bool(_read_setting(db, _KEY_ENFORCED))
def enforced_roles(db: Session) -> List[str]:
return _parse_roles(_read_setting(db, _KEY_ROLES))
def enforce_sso_users(db: Session) -> bool:
return _bool(_read_setting(db, _KEY_ENFORCE_SSO))
def requires_setup(db: Session, user: User) -> bool:
"""
True when this user MUST enrol TOTP right now before getting a JWT.
Logic:
- Master toggle off → False
- User already has totp_enabled → False
- User's role not in enforced roles list → False
- User authenticated via SSO/LDAP AND enforce_sso → True
- User authenticated via local → True
Returns False on any DB hiccup — fail-open here is safer than
locking everyone out if the settings table becomes unreadable.
"""
try:
if not is_enforced(db):
return False
if user.totp_enabled and user.totp_secret:
return False
role_val = user.role.value if hasattr(user.role, "value") else str(user.role)
if role_val.lower() not in enforced_roles(db):
return False
if user.auth_provider != AuthProvider.LOCAL and not enforce_sso_users(db):
return False
return True
except Exception as e:
logger.warning("MFA policy check failed (fail-open): %s", e)
return False