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>
214 lines
8.2 KiB
Python
214 lines
8.2 KiB
Python
"""Zwei-Faktor: Einrichtung, zweistufige Anmeldung, Wiederherstellungscodes."""
|
||
|
||
import time
|
||
|
||
import pyotp
|
||
from fastapi.testclient import TestClient
|
||
|
||
from conftest import login_ready, new_user
|
||
|
||
|
||
def _client() -> TestClient:
|
||
from app.main import app
|
||
|
||
return TestClient(app)
|
||
|
||
|
||
def _code(secret: str, versatz: int = 0) -> str:
|
||
"""Code für das aktuelle (oder ein verschobenes) Zeitfenster.
|
||
|
||
Jeder Code verbraucht sein Fenster dauerhaft, deshalb muss ein Test, der
|
||
mehrfach anmeldet, jeweils ein späteres Fenster nehmen."""
|
||
schritt = int(time.time()) // 30 + versatz
|
||
return pyotp.TOTP(secret).at(schritt * 30)
|
||
|
||
|
||
def _mit_2fa(admin) -> tuple[str, str, str]:
|
||
"""Legt einen Nutzer an, richtet 2FA ein und gibt (name, passwort, secret) zurück.
|
||
|
||
Aktiviert wird mit dem Code des aktuellen Fensters – spätere Anmeldungen im
|
||
Test brauchen deshalb ``versatz >= 1``."""
|
||
username, password = new_user(admin)
|
||
c, gueltig = login_ready(username, password)
|
||
setup = c.post("/api/auth/2fa/setup").json()
|
||
r = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])})
|
||
assert r.status_code == 200, r.text
|
||
return username, gueltig, setup["secret"]
|
||
|
||
|
||
def test_setup_liefert_qr_und_secret(admin):
|
||
c, _ = login_ready(*new_user(admin))
|
||
r = c.post("/api/auth/2fa/setup")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["qr_png"].startswith("data:image/png;base64,")
|
||
assert body["otpauth_uri"].startswith("otpauth://totp/")
|
||
assert len(body["secret"]) >= 16
|
||
# Noch nicht aktiv, solange kein Code bestätigt wurde.
|
||
assert c.get("/api/auth/2fa/status").json()["enabled"] is False
|
||
|
||
|
||
def test_activate_liefert_wiederherstellungscodes(admin):
|
||
c, _ = login_ready(*new_user(admin))
|
||
setup = c.post("/api/auth/2fa/setup").json()
|
||
r = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])})
|
||
assert r.status_code == 200
|
||
codes = r.json()["codes"]
|
||
assert len(codes) == 10
|
||
assert len(set(codes)) == 10
|
||
status = c.get("/api/auth/2fa/status").json()
|
||
assert status["enabled"] is True
|
||
assert status["recovery_codes_left"] == 10
|
||
|
||
|
||
def test_activate_mit_falschem_code(admin):
|
||
c, _ = login_ready(*new_user(admin))
|
||
c.post("/api/auth/2fa/setup")
|
||
assert c.post("/api/auth/2fa/activate", json={"code": "000000"}).status_code == 400
|
||
|
||
|
||
def test_zweistufige_anmeldung(admin):
|
||
username, password, secret = _mit_2fa(admin)
|
||
|
||
c = _client()
|
||
r = c.post("/api/auth/login", data={"username": username, "password": password})
|
||
assert r.status_code == 200
|
||
assert r.json()["status"] == "2fa_required"
|
||
assert r.json()["user"] is None
|
||
|
||
# Das Zwischen-Cookie ist kein Zugang.
|
||
assert c.get("/api/auth/me").status_code == 401
|
||
assert c.get("/api/accounts").status_code == 401
|
||
|
||
r = c.post("/api/auth/2fa/verify", json={"code": _code(secret, 1)})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["status"] == "ok"
|
||
assert r.json()["user"]["username"] == username
|
||
assert c.get("/api/accounts").status_code == 200
|
||
|
||
|
||
def test_code_gilt_nur_einmal(admin):
|
||
username, password, secret = _mit_2fa(admin)
|
||
code = _code(secret, 1)
|
||
|
||
c = _client()
|
||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||
assert c.post("/api/auth/2fa/verify", json={"code": code}).status_code == 200
|
||
|
||
# Derselbe Code ein zweites Mal: das Fenster ist verbraucht.
|
||
zweiter = _client()
|
||
zweiter.post("/api/auth/login", data={"username": username, "password": password})
|
||
assert zweiter.post("/api/auth/2fa/verify", json={"code": code}).status_code == 401
|
||
|
||
|
||
def test_bereits_verbrauchtes_fenster_wird_abgelehnt(admin):
|
||
"""Der Code, mit dem aktiviert wurde, taugt nicht mehr zum Anmelden."""
|
||
username, password = new_user(admin)
|
||
c, gueltig = login_ready(username, password)
|
||
setup = c.post("/api/auth/2fa/setup").json()
|
||
aktivierungscode = _code(setup["secret"])
|
||
assert c.post("/api/auth/2fa/activate", json={"code": aktivierungscode}).status_code == 200
|
||
|
||
zweiter = _client()
|
||
zweiter.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||
assert zweiter.post(
|
||
"/api/auth/2fa/verify", json={"code": aktivierungscode}
|
||
).status_code == 401
|
||
|
||
|
||
def test_falscher_code_wird_abgelehnt(admin):
|
||
username, password, _ = _mit_2fa(admin)
|
||
c = _client()
|
||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||
assert c.post("/api/auth/2fa/verify", json={"code": "000000"}).status_code == 401
|
||
assert c.get("/api/auth/me").status_code == 401
|
||
|
||
|
||
def test_zu_viele_fehlversuche_beenden_den_versuch(admin):
|
||
username, password, secret = _mit_2fa(admin)
|
||
c = _client()
|
||
c.post("/api/auth/login", data={"username": username, "password": password})
|
||
for _ in range(5):
|
||
c.post("/api/auth/2fa/verify", json={"code": "000000"})
|
||
# Die Pending-Sitzung ist verbraucht – auch ein richtiger Code hilft nicht mehr.
|
||
r = c.post("/api/auth/2fa/verify", json={"code": _code(secret, 1)})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_wiederherstellungscode_funktioniert_einmal(admin):
|
||
username, password = new_user(admin)
|
||
c, gueltig = login_ready(username, password)
|
||
setup = c.post("/api/auth/2fa/setup").json()
|
||
codes = c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])}).json()["codes"]
|
||
|
||
erster = _client()
|
||
erster.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||
r = erster.post("/api/auth/2fa/verify", json={"code": codes[0], "recovery": True})
|
||
assert r.status_code == 200, r.text
|
||
assert erster.get("/api/auth/2fa/status").json()["recovery_codes_left"] == 9
|
||
|
||
zweiter = _client()
|
||
zweiter.post("/api/auth/login", data={"username": username, "password": gueltig})
|
||
r = zweiter.post("/api/auth/2fa/verify", json={"code": codes[0], "recovery": True})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_abschalten_verlangt_das_richtige_passwort(admin):
|
||
username, password = new_user(admin)
|
||
c, gueltig = login_ready(username, password)
|
||
setup = c.post("/api/auth/2fa/setup").json()
|
||
assert c.post("/api/auth/2fa/activate", json={"code": _code(setup["secret"])}).status_code == 200
|
||
|
||
assert c.post("/api/auth/2fa/disable", json={"password": "falsch"}).status_code == 400
|
||
r = c.post("/api/auth/2fa/disable", json={"password": gueltig})
|
||
assert r.status_code == 200
|
||
assert r.json()["totp_enabled"] is False
|
||
assert c.get("/api/auth/2fa/status").json()["recovery_codes_left"] == 0
|
||
|
||
|
||
def test_admin_kann_zwei_faktor_zuruecksetzen(admin):
|
||
username, password, secret = _mit_2fa(admin)
|
||
u = next(x for x in admin.get("/api/users").json() if x["username"] == username)
|
||
assert u["totp_enabled"] is True
|
||
|
||
r = admin.post(f"/api/users/{u['id']}/2fa/reset")
|
||
assert r.status_code == 200
|
||
assert r.json()["totp_enabled"] is False
|
||
|
||
# Danach genügt wieder das Passwort allein.
|
||
c = _client()
|
||
r = c.post("/api/auth/login", data={"username": username, "password": password})
|
||
assert r.json()["status"] == "ok"
|
||
assert secret # das alte Secret ist damit wertlos
|
||
|
||
|
||
def test_mfa_pflicht_sperrt_bis_zur_einrichtung(policy_admin):
|
||
username, password = new_user(policy_admin)
|
||
c, gueltig = login_ready(username, password)
|
||
|
||
assert policy_admin.put("/api/policy", json={
|
||
"min_length": 10, "require_upper": True, "require_lower": True,
|
||
"require_digit": True, "require_special": False, "mfa_required": True,
|
||
}).status_code == 200
|
||
|
||
zweiter = _client()
|
||
assert zweiter.post(
|
||
"/api/auth/login", data={"username": username, "password": gueltig}
|
||
).status_code == 200
|
||
me = zweiter.get("/api/auth/me").json()
|
||
assert me["mfa_setup_required"] is True
|
||
|
||
r = zweiter.get("/api/accounts")
|
||
assert r.status_code == 403
|
||
assert r.json()["detail"] == "mfa_setup_required"
|
||
|
||
# Einrichten ist erlaubt und hebt die Sperre auf.
|
||
setup = zweiter.post("/api/auth/2fa/setup").json()
|
||
assert zweiter.post(
|
||
"/api/auth/2fa/activate", json={"code": _code(setup["secret"])}
|
||
).status_code == 200
|
||
assert zweiter.get("/api/accounts").status_code == 200
|
||
|
||
# Abschalten bleibt verwehrt, solange die Pflicht gilt.
|
||
assert zweiter.post("/api/auth/2fa/disable", json={"password": gueltig}).status_code == 400
|