July 4 - Implement TOTP 2FA
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user