""" control/mfa.py -------------- TOTP two-factor helpers for the standalone superadmin panel. Mirrors app/utils/mfa.py (the panel must not import from app/, per the MT-4 standalone-app boundary — same rationale as control/time_utils.py). Pure functions; no Flask-app or model dependency. """ import io import secrets import pyotp import qrcode import qrcode.image.svg from werkzeug.security import generate_password_hash, check_password_hash ISSUER = 'JQC Admin' _RECOVERY_CODE_COUNT = 10 def new_secret() -> str: return pyotp.random_base32() def provisioning_uri(secret: str, account_name: str, issuer: str = ISSUER) -> str: return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer) def verify_totp(secret: str, code: str) -> bool: if not secret or not code: return False code = code.strip().replace(' ', '') if not code.isdigit(): return False try: return pyotp.totp.TOTP(secret).verify(code, valid_window=1) except Exception: return False def qr_svg(uri: str) -> str: buf = io.BytesIO() qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage).save(buf) return buf.getvalue().decode('utf-8') def generate_recovery_codes(n: int = _RECOVERY_CODE_COUNT): plaintext, hashed = [], [] for _ in range(n): raw = secrets.token_hex(4) code = f'{raw[:4]}-{raw[4:]}' plaintext.append(code) hashed.append(generate_password_hash(code)) return plaintext, hashed def _normalise(code: str) -> str: return (code or '').strip().lower().replace(' ', '') def check_and_consume_recovery(hashed_codes, code): remaining = list(hashed_codes or []) candidate = _normalise(code) if not candidate: return False, remaining for h in list(remaining): if check_password_hash(h, candidate): remaining.remove(h) return True, remaining return False, remaining