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>
145 lines
4.7 KiB
Python
145 lines
4.7 KiB
Python
"""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()
|