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:
co-authored by
Claude Fable 5
parent
053bfeab23
commit
0822eca887
@@ -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)
|
||||
Reference in New Issue
Block a user