"""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