97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
"""
|
|
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
|