Per-user opt-in 2FA: enroll from the Two-factor option next to Change password (QR + confirm code + 8 one-time recovery codes), login becomes two-step for enrolled users, disable requires the account password. Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS action. Second-factor guesses share the login rate limiter; the pending login window expires after 5 minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
72 lines
2.2 KiB
Python
72 lines
2.2 KiB
Python
"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes.
|
|
|
|
Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest —
|
|
unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful
|
|
match removes its hash from the stored list.
|
|
"""
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import secrets
|
|
|
|
import pyotp
|
|
import qrcode
|
|
import qrcode.image.svg
|
|
|
|
ISSUER = "Ten31 Portal"
|
|
RECOVERY_CODE_COUNT = 8
|
|
|
|
|
|
def new_secret() -> str:
|
|
return pyotp.random_base32()
|
|
|
|
|
|
def otpauth_uri(secret: str, account_name: str) -> str:
|
|
return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER)
|
|
|
|
|
|
def qr_svg(uri: str) -> str:
|
|
"""The enrollment QR as a standalone SVG document (no raster deps needed)."""
|
|
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14)
|
|
buf = io.BytesIO()
|
|
img.save(buf)
|
|
return buf.getvalue().decode()
|
|
|
|
|
|
def verify_code(secret: str, code: str) -> bool:
|
|
# valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift.
|
|
return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1)
|
|
|
|
|
|
def _normalize_recovery(code: str) -> str:
|
|
return code.strip().replace("-", "").replace(" ", "").lower()
|
|
|
|
|
|
def _digest(code: str) -> str:
|
|
return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest()
|
|
|
|
|
|
def generate_recovery_codes() -> tuple[list[str], str]:
|
|
"""Return (plaintext codes to show once, JSON of their digests to store)."""
|
|
codes = []
|
|
for _ in range(RECOVERY_CODE_COUNT):
|
|
raw = secrets.token_hex(10) # 20 hex chars, 80 bits
|
|
codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}")
|
|
return codes, json.dumps([_digest(c) for c in codes])
|
|
|
|
|
|
def consume_recovery_code(stored_json: str | None, code: str) -> str | None:
|
|
"""If ``code`` matches an unused recovery code, return the updated JSON without it.
|
|
|
|
Returns None when the code doesn't match (or none are stored).
|
|
"""
|
|
if not stored_json:
|
|
return None
|
|
digests: list[str] = json.loads(stored_json)
|
|
d = _digest(code)
|
|
if d not in digests:
|
|
return None
|
|
digests.remove(d)
|
|
return json.dumps(digests)
|