feat: 2FA, Passwortrichtlinie und wiederherstellbares JSON-Backup

Zwei-Faktor per TOTP mit zehn Einmal-Wiederherstellungscodes. Der
Zwischenzustand "Passwort stimmt, Code fehlt" haengt an der vorhandenen
Sitzungstabelle (pending_2fa, zehn Minuten Laufzeit) und gilt nirgends als
angemeldet; nach dem Code wird das Cookie getauscht statt weiterverwendet.
Der Wiederverwendungsschutz merkt sich das tatsaechlich getroffene
Zeitfenster, nicht die aktuelle Uhrzeit - sonst liesse sich ein Code aus dem
naechsten Fenster gleich darauf nochmal einloesen.

Passwortrichtlinie (Laenge, Zeichenklassen, MFA-Pflicht) in der neuen
globalen Tabelle app_settings, durchgesetzt beim Anlegen, Zuruecksetzen und
Aendern von Passwoertern. Bestehende Passwoerter lassen sich nicht gegen eine
neue Richtlinie pruefen - der Hash gibt nichts her. Beim Anmelden liegt das
Passwort aber kurz im Klartext vor: dort wird geprueft und bei Verstoss ein
Wechsel verlangt, mit passendem Grund fuer den Hinweistext.

Nebenbei geschlossen: must_change_password war bisher nur im Frontend
erzwungen, wer die API direkt ansprach kam vorbei. enforce_account_state
haengt jetzt am /api-Sammelrouter und laesst nur noch durch, was man zum
Erfuellen der Pflicht braucht.

Das JSON-Backup liess sich bisher grundsaetzlich nicht zurueckladen: keine
Version, Kategorien nur ueber mehrdeutige Namen, sieben Tabellen fehlten.
Daneben steht nun ein Format v2, das Beziehungen ueber lokale IDs abbildet,
je Benutzer oder systemweit. Zurueckgeladen wird nach einer Pruefansicht,
wahlweise ersetzend oder ergaenzend; vor dem Ersetzen legt der Server einen
Schnappschuss der Datenbank an. Der alte Ansichts-Export bleibt fuer
Tabellenkalkulationen bestehen und wird beim Hochladen ausdruecklich
abgewiesen, statt halb zu raten.

Frontend: wiederverwendbares Passwortfeld mit Augensymbol, animierte
Anforderungsliste, 2FA-Seite, Admin-Karte fuer die Richtlinie und Karte zum
Zurueckladen. Neue Bewegung ist in prefers-reduced-motion hinterlegt; Secret
und Codes sind markierbar, sonst verhinderte das globale user-select das
Kopieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-20 10:11:22 +02:00
co-authored by Claude Opus 5
parent 1d5b23c127
commit 7fc8763d57
40 changed files with 3539 additions and 117 deletions
+144
View File
@@ -0,0 +1,144 @@
"""Zwei-Faktor per TOTP (Authenticator-App) plus Einmal-Wiederherstellungscodes.
Der QR-Code wird hier serverseitig als PNG-Data-URI erzeugt. Das spart eine
JavaScript-Abhängigkeit im Frontend und erspart es, fremdes SVG ins DOM zu
schreiben – das Bild landet einfach in einem ``<img src=…>``.
"""
from __future__ import annotations
import base64
import io
import secrets
import time
from datetime import datetime, timezone
import pyotp
import qrcode
from sqlalchemy.orm import Session
from ..branding import APP_NAME
from ..models import RecoveryCode, User
from ..security import hash_session_token
STEP_SECONDS = 30
# Ein Schritt Toleranz nach vorn und hinten – deckt übliche Uhrendrift ab,
# ohne das Zeitfenster unnötig aufzuweiten.
VALID_WINDOW = 1
RECOVERY_CODE_COUNT = 10
RECOVERY_CODE_LENGTH = 10
# Ohne 0/O/1/I/L – abgeschriebene Codes sollen nicht an Verwechslungen scheitern.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def new_secret() -> str:
return pyotp.random_base32()
def otpauth_uri(user: User, secret: str) -> str:
return pyotp.TOTP(secret).provisioning_uri(name=user.username, issuer_name=APP_NAME)
def qr_data_uri(uri: str) -> str:
"""QR-Code als ``data:image/png;base64,…``."""
img = qrcode.make(uri)
puffer = io.BytesIO()
img.save(puffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(puffer.getvalue()).decode("ascii")
def matching_step(secret: str, code: str) -> int | None:
"""Zu welchem 30-Sekunden-Fenster gehört dieser Code? ``None`` = zu keinem.
Nicht ``TOTP.verify`` benutzt, weil das nur ja/nein sagt. Für den
Wiederverwendungsschutz muss aber feststehen, *welches* Fenster verbraucht
wurde – sonst liesse sich ein Code aus dem nächsten Fenster im nächsten
Moment erneut einlösen."""
code = (code or "").strip().replace(" ", "")
if not code.isdigit():
return None
totp = pyotp.TOTP(secret)
jetzt = int(time.time()) // STEP_SECONDS
for versatz in range(-VALID_WINDOW, VALID_WINDOW + 1):
schritt = jetzt + versatz
if secrets.compare_digest(totp.at(schritt * STEP_SECONDS), code):
return schritt
return None
def consume_code(db: Session, user: User, code: str) -> bool:
"""Prüft den Code und verbraucht sein Zeitfenster.
Ein einmal benutzter Code bleibt zwar bis zu 30 Sekunden rechnerisch gültig,
darf aber kein zweites Mal zählen – sonst könnte ein abgefangener Code sofort
nachgespielt werden."""
if not user.totp_secret:
return False
schritt = matching_step(user.totp_secret, code)
if schritt is None:
return False
if user.totp_last_step is not None and schritt <= user.totp_last_step:
return False
user.totp_last_step = schritt
db.commit()
return True
def _format_code(raw: str) -> str:
"""Fünfergruppen mit Bindestrich – deutlich leichter abzuschreiben."""
return f"{raw[:5]}-{raw[5:]}"
def generate_recovery_codes(db: Session, user: User) -> list[str]:
"""Erzeugt einen frischen Satz Codes; alte werden dabei ungültig.
Die Klartext-Codes gibt es nur dieses eine Mal – in der Datenbank steht
ausschliesslich ihr Hash."""
db.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).delete()
codes: list[str] = []
for _ in range(RECOVERY_CODE_COUNT):
roh = "".join(secrets.choice(_ALPHABET) for _ in range(RECOVERY_CODE_LENGTH))
codes.append(_format_code(roh))
db.add(RecoveryCode(user_id=user.id, code_hash=hash_session_token(roh)))
db.commit()
return codes
def consume_recovery_code(db: Session, user: User, code: str) -> bool:
"""Setzt einen Wiederherstellungscode ein. Jeder Code zählt genau einmal."""
roh = (code or "").strip().upper().replace("-", "").replace(" ", "")
if not roh:
return False
treffer = (
db.query(RecoveryCode)
.filter(
RecoveryCode.user_id == user.id,
RecoveryCode.code_hash == hash_session_token(roh),
RecoveryCode.used_at.is_(None),
)
.first()
)
if treffer is None:
return False
treffer.used_at = datetime.now(timezone.utc)
db.commit()
return True
def codes_left(db: Session, user: User) -> int:
return (
db.query(RecoveryCode)
.filter(RecoveryCode.user_id == user.id, RecoveryCode.used_at.is_(None))
.count()
)
def disable(db: Session, user: User) -> None:
"""Schaltet 2FA ab und räumt Secret sowie Wiederherstellungscodes weg."""
db.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).delete()
user.totp_secret = None
user.totp_enabled = False
user.totp_confirmed_at = None
user.totp_last_step = None
db.commit()