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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-20 16:06:41 +02:00
co-authored by Claude Opus 5
parent 15b863ff5e
commit 8a7545bf5b
5 changed files with 238 additions and 8 deletions
+57 -1
View File
@@ -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
+14 -4
View File
@@ -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):
+9 -3
View File
@@ -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)
+5
View File
@@ -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
+153
View File
@@ -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