0.2.38: optional two-factor authentication (authenticator-app TOTP)

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>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
+71
View File
@@ -0,0 +1,71 @@
"""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)