""" app/utils/mfa.py ---------------- TOTP two-factor helpers, shared by the main app (admin/director accounts) and the superadmin control panel. Design notes ------------ * TOTP secret is a base32 string (RFC 6238). It is a *shared* secret by nature; we store it as-is, exactly like every standard authenticator integration. * Recovery codes are one-time backup codes shown ONCE at enrollment and stored only as salted hashes (werkzeug). A consumed code is removed from the list. * The QR is rendered as an inline SVG (no Pillow / no external request), so it works under the app's strict CSP and in the standalone panel app alike. This module has no Flask-app or model dependencies — pure functions — so both apps and the test-suite can use it directly. """ import io import pyotp import qrcode import qrcode.image.svg from werkzeug.security import generate_password_hash, check_password_hash ISSUER = 'JQC' _RECOVERY_CODE_COUNT = 10 def new_secret() -> str: """Return a fresh base32 TOTP secret.""" return pyotp.random_base32() def provisioning_uri(secret: str, account_name: str, issuer: str = ISSUER) -> str: """otpauth:// URI to encode in the enrollment QR / manual entry.""" return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer) def verify_totp(secret: str, code: str) -> bool: """Validate a 6-digit TOTP code. valid_window=1 tolerates ±30s clock drift.""" 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: """Return an inline SVG string for the given otpauth URI (no Pillow needed).""" 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): """Return (plaintext_codes, hashed_codes). Plaintext is shown to the user ONCE. Only the hashes are persisted. Codes are formatted xxxx-xxxx for readability. """ import secrets plaintext, hashed = [], [] for _ in range(n): raw = secrets.token_hex(4) # 8 hex chars 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): """Check a recovery code against the stored hashes. Returns (matched: bool, remaining_hashes: list). On a match the consumed hash is removed so each recovery code works exactly once. `hashed_codes` is never mutated in place. """ 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