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:
co-authored by
Claude Opus 5
parent
15b863ff5e
commit
8a7545bf5b
+57
-1
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user