platform-core/modules/auth/mfaService.py
ValueOn AG 08fa70e4e0
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m4s
Deploy Plattform-Core (Int) / deploy (push) Successful in 42s
security and mfa
2026-06-03 23:21:29 +02:00

132 lines
4.3 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
MFA (Multi-Factor Authentication) Service.
TOTP-based MFA using pyotp. Secrets are encrypted at rest via
encryptValue/decryptValue from the configuration module.
MFA obligation is resolved by three OR-linked rules:
1. Any mandate the user belongs to has ``mfaRequired=True``.
2. User is sysAdmin OR platformAdmin AND config key ``MFA_REQUIRE_ADMINS``
is truthy.
3. User has opted in (``mfaEnabled=True`` without any mandate/admin rule).
"""
import logging
from typing import Optional
import pyotp
from modules.shared.configuration import APP_CONFIG, encryptValue, decryptValue
logger = logging.getLogger(__name__)
_MFA_DIGITS = 6
_MFA_INTERVAL = 30
_MFA_VALID_WINDOW = 1
def _getMfaIssuer() -> str:
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
if envType in ("prod", ""):
return "PowerOn"
return f"PowerOn ({envType.upper()})"
def _generateSecret() -> str:
"""Generate a fresh base32-encoded TOTP secret."""
return pyotp.random_base32()
def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
def _decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
def _buildTotp(plainSecret: str) -> pyotp.TOTP:
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
def generateSetup(userId: str, username: str) -> dict:
"""Start MFA enrolment: return secret + provisioning URI (for QR code).
Returns dict with keys ``secret`` (encrypted for DB storage) and
``provisioningUri`` (otpauth:// URI the frontend renders as QR).
The plaintext secret is NOT returned -- the URI already contains it.
"""
plain = _generateSecret()
encrypted = _encryptSecret(plain, userId=userId)
totp = _buildTotp(plain)
uri = totp.provisioning_uri(name=username, issuer_name=_getMfaIssuer())
return {
"encryptedSecret": encrypted,
"provisioningUri": uri,
}
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
try:
plain = _decryptSecret(encryptedSecret, userId=userId)
totp = _buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception:
logger.exception("MFA confirmSetup failed for userId=%s", userId)
return False
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code during login."""
try:
plain = _decryptSecret(encryptedSecret, userId=userId)
totp = _buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception:
logger.exception("MFA verifyCode failed for userId=%s", userId)
return False
def _isMfaRequireAdminsEnabled() -> bool:
"""Read ``MFA_REQUIRE_ADMINS`` from config / env."""
raw = (APP_CONFIG.get("MFA_REQUIRE_ADMINS") or "").strip().lower()
return raw in ("1", "true", "yes")
def isMfaRequired(user, userMandates=None, mandates=None) -> bool:
"""Resolve whether MFA is mandatory for *user*.
Rules (OR):
1. At least one of the user's mandates has ``mfaRequired=True``.
2. User is sysAdmin or platformAdmin AND ``MFA_REQUIRE_ADMINS`` config
key is truthy.
3. User already opted in (``mfaEnabled=True``).
Parameters
----------
user : User | UserInDB
The user object.
userMandates : list | None
List of UserMandate records for the user (each has ``mandateId``).
mandates : list | None
List of Mandate objects the user has access to. If provided directly
this avoids a second lookup.
"""
if getattr(user, "mfaEnabled", False):
return True
isSys = getattr(user, "isSysAdmin", False)
isPlat = getattr(user, "isPlatformAdmin", False)
if (isSys or isPlat) and _isMfaRequireAdminsEnabled():
return True
if mandates:
for m in mandates:
if getattr(m, "mfaRequired", False):
return True
return False