shinkan-jinkendo/backend/totp_auth.py
Lars 32b28a93f3
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Failing after 5m43s
Harden login, sessions, and media access before the dev deploy.
Limit brute-force attempts, require admin TOTP, and keep session tokens out of media URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 07:45:53 +02:00

64 lines
2.0 KiB
Python

"""TOTP (RFC 6238) und einmalige Wiederherstellungscodes — nur Standardbibliothek."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import struct
import time
from typing import Optional
from urllib.parse import quote
def generate_totp_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
def _decode_secret(secret: str) -> bytes:
raw = "".join(ch for ch in (secret or "").upper() if ch.isalnum())
pad = "=" * ((8 - len(raw) % 8) % 8)
return base64.b32decode(raw + pad, casefold=True)
def totp_code(secret: str, counter: int) -> str:
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
return f"{binary % 1_000_000:06d}"
def verify_totp(secret: Optional[str], code: str, *, window: int = 1, now: Optional[int] = None) -> bool:
if not secret:
return False
digits = "".join(ch for ch in (code or "") if ch.isdigit())
if len(digits) != 6:
return False
moment = int(time.time() if now is None else now)
counter = moment // 30
for delta in range(-window, window + 1):
if hmac.compare_digest(totp_code(secret, counter + delta), digits):
return True
return False
def otpauth_uri(*, secret: str, account_name: str, issuer: str = "Shinkan Jinkendo") -> str:
label = quote(f"{issuer}:{account_name}")
return (
f"otpauth://totp/{label}?secret={quote(secret)}"
f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30"
)
def generate_recovery_codes(count: int = 8) -> list[str]:
codes = []
for _ in range(count):
raw = secrets.token_hex(4)
codes.append(f"{raw[:4]}-{raw[4:]}")
return codes
def hash_recovery_code(code: str) -> str:
normalized = "".join(ch for ch in (code or "").lower() if ch.isalnum())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()