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.
105 lines
3.5 KiB
Python
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
|