From 8a7545bf5bcc0ee065f9f88acf70cac6bd9e94da Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 20 Oct 2026 16:06:41 +0200 Subject: [PATCH] feat: Eine Geraetemarke kann jetzt auch als Anmeldung fuer eine App gelten Die API kannte zwei Zustaende: angemeldet per Sitzungs-Cookie, oder eine enge Kurzbefehl-Marke, die nur erfassen darf. Eine native App passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das Passwort nicht auf dem Geraet halten. Neu ist die Reichweite "app": Sie gilt im Header X-Certain-Token wie eine Anmeldung, laeuft nicht nach 30 Tagen ab und laesst sich einzeln widerrufen. Die enge Marke bleibt eng - das ist der Punkt, an dem die Tests am genauesten hinsehen. Und eine App-Marke darf keine weitere ausstellen: Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen liefe ins Leere. Co-Authored-By: Claude Opus 5 --- backend/app/deps.py | 58 +++++++++++- backend/app/models.py | 18 +++- backend/app/routers/inbox.py | 12 ++- backend/app/schemas.py | 5 ++ backend/tests/test_app_marke.py | 153 ++++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_app_marke.py diff --git a/backend/app/deps.py b/backend/app/deps.py index 7d6fd1c..332c735 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -1,9 +1,15 @@ -"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer (Cookie), Admin-Pflicht. +"""Gemeinsame FastAPI-Abhängigkeiten: aktueller Benutzer, Admin-Pflicht. Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe ``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung und liefert den Benutzer – oder 401. +Zweiter Weg für die native App: eine Gerätemarke der Reichweite ``app`` im +Header ``X-Certain-Token``. Eine App kann kein Cookie-Formular ausfüllen und +soll nicht das Passwort auf dem Gerät halten. Die enge ``quick_add``-Marke +bleibt davon unberührt – sie gilt weiterhin nur dort, wo +``require_quick_token`` sie ausdrücklich verlangt. + ``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt @@ -54,7 +60,57 @@ def _session_user(request: Request, db: Session) -> User | None: return request.state.session_user token = request.cookies.get(get_settings().cookie_name, "") user = sessions.validate(db, token) + herkunft = "cookie" + if user is None: + # Die native App meldet sich mit ihrer Marke statt mit einem Cookie. + # Hier und nicht erst in ``get_current_user``, damit auch + # ``enforce_account_state`` sie sieht – sonst käme eine App an der + # Pflicht zum Passwortwechsel vorbei. + user = _app_token_user(request, db) + herkunft = "marke" request.state.session_user = user + request.state.auth_via = herkunft if user is not None else None + return user + + +def _app_token_user(request: Request, db: Session) -> User | None: + """Benutzer hinter einer App-Marke im Header ``X-Certain-Token``. + + Nur die Reichweite ``app`` zählt hier. Eine ``quick_add``-Marke darf auf + diesem Weg NICHT zur Anmeldung werden – sonst wäre die bewusst enge Marke + still zu einem Vollzugang geworden. + """ + roh = (request.headers.get("X-Certain-Token") or "").strip() + if not roh: + return None + marke = ( + db.query(ApiToken) + .filter(ApiToken.token_hash == hash_session_token(roh), + ApiToken.scope == TokenScope.app) + .first() + ) + if marke is None: + return None + besitzer = db.get(User, marke.owner_id) + if besitzer is None or not besitzer.is_active: + return None + marke.last_used_at = datetime.now(timezone.utc) + db.commit() + return besitzer + + +def require_cookie_session(request: Request, db: Session = Depends(get_db)) -> User: + """Wie ``get_current_user``, aber NUR mit echter Anmeldung. + + Für Schritte, die eine Marke nicht auslösen darf – allen voran das + Ausstellen weiterer Marken. + """ + user = get_current_user(request, db) + if getattr(request.state, "auth_via", None) != "cookie": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Dafür ist eine richtige Anmeldung nötig, keine Gerätemarke", + ) return user diff --git a/backend/app/models.py b/backend/app/models.py index 464dbd1..f0aed89 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -813,14 +813,24 @@ class CategorizationRule(Base): class TokenScope(str, enum.Enum): - """Was eine Gerätemarke darf. Bewusst eng gehalten. + """Was eine Gerätemarke darf. - Eine Marke liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. - Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist - kein Datenabfluss möglich. + ``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke + liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf + deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist kein + Datenabfluss möglich. + + ``app`` ist die Marke der nativen App und gilt wie eine Anmeldung – eine + App muss alles können, was die Weboberfläche kann, und die API kennt + sonst nur das Sitzungs-Cookie. Der Klartext liegt allein im Schlüsselbund + des Geräts; widerrufen wird die Marke in den Einstellungen. Vergeben darf + sie nur eine echte Anmeldung, nie eine Marke selbst: Sonst hätte ein Dieb + sich beliebig viele Ersatzschlüssel ausgestellt und das Widerrufen wäre + wirkungslos. """ quick_add = "quick_add" + app = "app" class ApiToken(Base): diff --git a/backend/app/routers/inbox.py b/backend/app/routers/inbox.py index ee7c452..67781c8 100644 --- a/backend/app/routers/inbox.py +++ b/backend/app/routers/inbox.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from sqlalchemy.orm import Session from ..database import get_db -from ..deps import get_current_user, get_owned +from ..deps import get_current_user, get_owned, require_cookie_session from ..models import ( Account, ApiToken, @@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_ def create_token( payload: ApiTokenCreate, db: Session = Depends(get_db), - user: User = Depends(get_current_user), + user: User = Depends(require_cookie_session), ): - """Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.""" + """Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen. + + Bewusst ``require_cookie_session``: Eine App-Marke darf keine weitere + ausstellen. Sonst stellte ein Dieb sich Ersatzschlüssel aus und das + Widerrufen der gestohlenen Marke liefe ins Leere. + """ if payload.default_account_id is not None: get_owned(db, Account, payload.default_account_id, user) klartext = generate_session_token() @@ -55,6 +60,7 @@ def create_token( owner_id=user.id, name=payload.name.strip(), token_hash=hash_session_token(klartext), + scope=payload.scope, default_account_id=payload.default_account_id, ) db.add(marke) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 3f2b9fd..f0b51a3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -15,6 +15,7 @@ from .models import ( IdentifierKind, ImportStatus, Role, + TokenScope, TxnType, ) @@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel): class ApiTokenCreate(BaseModel): name: str = Field(min_length=1, max_length=80) default_account_id: int | None = None + # Voreinstellung bleibt die enge Marke: Wer nichts angibt, bekommt keinen + # Vollzugang. + scope: TokenScope = TokenScope.quick_add class ApiTokenOut(BaseModel): id: int name: str + scope: TokenScope default_account_id: int | None last_used_at: datetime | None created_at: datetime | None diff --git a/backend/tests/test_app_marke.py b/backend/tests/test_app_marke.py new file mode 100644 index 0000000..0c81c91 --- /dev/null +++ b/backend/tests/test_app_marke.py @@ -0,0 +1,153 @@ +"""Die Geraetemarke der nativen App. + +Die API kannte bisher nur zwei Zustaende: angemeldet per Sitzungs-Cookie, oder +eine enge Kurzbefehl-Marke, die ausschliesslich erfassen darf. Eine native App +passt in keinen davon - sie kann kein Cookie-Formular ausfuellen und soll das +Passwort nicht auf dem Geraet halten. + +Deshalb die Reichweite ``app``: gilt wie eine Anmeldung, liegt im +Schluesselbund, ist einzeln widerrufbar. Die enge Marke bleibt eng - das ist +der Punkt, an dem diese Datei am genauesten hinsieht. +""" + +import secrets + + +def _marke(client, name=None, scope=None): + koerper = {"name": name or f"Geraet {secrets.token_hex(3)}"} + if scope is not None: + koerper["scope"] = scope + r = client.post("/api/inbox/tokens", json=koerper) + assert r.status_code == 201, r.text + return r.json() + + +def _mit(token): + return {"X-Certain-Token": token} + + +def test_app_marke_darf_lesen_und_schreiben(admin): + """Der ganze Zweck: Mit der Marke allein, ohne Cookie, muss die App + arbeiten koennen.""" + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + marke = _marke(admin, scope="app") + assert marke["scope"] == "app" + + konto = admin.post("/api/accounts", json={ + "name": f"Marke {secrets.token_hex(3)}", "type": "girokonto", + "currency": "CHF", "opening_balance_cents": 0}).json() + + # Ein frischer Client OHNE Cookie - nur der Header. + roh = TestClient(fastapi_app) + + r = roh.get("/api/accounts", headers=_mit(marke["token"])) + assert r.status_code == 200, r.text + assert konto["id"] in {a["id"] for a in r.json()} + + # Lesen reicht nicht, schreiben muss auch gehen. + r = roh.post("/api/transactions", headers=_mit(marke["token"]), json={ + "type": "expense", "date": "2026-07-14", "account_id": konto["id"], + "amount_cents": 1234, "payee": "Mit Marke gebucht"}) + assert r.status_code == 201, r.text + + # Und die Budgetansicht, der eigentliche Grund fuer die App. + assert roh.get("/api/budget/current", headers=_mit(marke["token"])).status_code == 200 + + +def test_kurzbefehl_marke_bleibt_eng(admin): + """Der wichtigste Test hier. Die alte Marke darf durch die neue Pruefung + NICHT still zu einem Vollzugang geworden sein.""" + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + marke = _marke(admin) # ohne scope -> quick_add + assert marke["scope"] == "quick_add" + roh = TestClient(fastapi_app) + + # Ihr eigener Endpunkt geht weiterhin. + assert roh.get("/api/quick/accounts", headers=_mit(marke["token"])).status_code == 200 + + # Alles andere nicht. + for pfad in ("/api/accounts", "/api/transactions", "/api/budget/current", + "/api/subscriptions", "/api/categories"): + r = roh.get(pfad, headers=_mit(marke["token"])) + assert r.status_code == 401, f"{pfad} war mit einer quick_add-Marke erreichbar" + + +def test_ohne_marke_und_mit_falscher_marke_kommt_niemand_herein(admin): + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + roh = TestClient(fastapi_app) + assert roh.get("/api/accounts").status_code == 401 + assert roh.get("/api/accounts", headers=_mit("gibtesnicht")).status_code == 401 + assert roh.get("/api/accounts", headers=_mit("")).status_code == 401 + + +def test_fremde_marke_sieht_nur_ihre_eigenen_daten(admin): + """Mandanten-Trennung gilt auch auf dem Marken-Weg.""" + from conftest import login_ready, new_user + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + meins = admin.post("/api/accounts", json={ + "name": f"Admin-Konto {secrets.token_hex(3)}", "type": "girokonto", + "currency": "CHF", "opening_balance_cents": 0}).json() + + anderer, _ = login_ready(*new_user(admin)) + fremde_marke = _marke(anderer, scope="app") + + roh = TestClient(fastapi_app) + r = roh.get("/api/accounts", headers=_mit(fremde_marke["token"])) + assert r.status_code == 200 + assert meins["id"] not in {a["id"] for a in r.json()} + assert roh.get(f"/api/accounts/{meins['id']}", + headers=_mit(fremde_marke["token"])).status_code == 404 + + +def test_eine_app_marke_stellt_keine_weitere_aus(admin): + """Sonst stellte sich ein Dieb Ersatzschluessel aus und das Widerrufen der + gestohlenen Marke liefe ins Leere.""" + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + marke = _marke(admin, scope="app") + roh = TestClient(fastapi_app) + + r = roh.post("/api/inbox/tokens", headers=_mit(marke["token"]), + json={"name": "Ersatzschluessel", "scope": "app"}) + assert r.status_code == 403, r.text + # Auch keine enge Marke. + assert roh.post("/api/inbox/tokens", headers=_mit(marke["token"]), + json={"name": "Auch nicht"}).status_code == 403 + # Lesen der eigenen Markenliste bleibt erlaubt. + assert roh.get("/api/inbox/tokens", headers=_mit(marke["token"])).status_code == 200 + + +def test_widerrufen_sperrt_sofort(admin): + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + marke = _marke(admin, scope="app") + roh = TestClient(fastapi_app) + assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 200 + + assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204 + assert roh.get("/api/accounts", headers=_mit(marke["token"])).status_code == 401 + + +def test_marke_merkt_sich_wann_sie_zuletzt_benutzt_wurde(admin): + """Damit man im Nachhinein sieht, welches Geraet noch laeuft - und welches + man bedenkenlos widerrufen kann.""" + from app.main import app as fastapi_app + from fastapi.testclient import TestClient + + marke = _marke(admin, scope="app") + assert marke["last_used_at"] is None + + TestClient(fastapi_app).get("/api/accounts", headers=_mit(marke["token"])) + + danach = next(t for t in admin.get("/api/inbox/tokens").json() if t["id"] == marke["id"]) + assert danach["last_used_at"] is not None