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
|
Die Anmeldung läuft über eine server-seitige Sitzung im Cookie (siehe
|
||||||
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
``services/sessions``). ``get_current_user`` liest das Cookie, prüft die Sitzung
|
||||||
und liefert den Benutzer – oder 401.
|
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
|
``enforce_account_state`` hängt am zentralen ``/api``-Router und sperrt Benutzer
|
||||||
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
aus, die erst noch etwas erledigen müssen (Passwort wechseln, zweiten Faktor
|
||||||
einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
|
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
|
return request.state.session_user
|
||||||
token = request.cookies.get(get_settings().cookie_name, "")
|
token = request.cookies.get(get_settings().cookie_name, "")
|
||||||
user = sessions.validate(db, token)
|
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.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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -813,14 +813,24 @@ class CategorizationRule(Base):
|
|||||||
|
|
||||||
|
|
||||||
class TokenScope(str, enum.Enum):
|
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.
|
``quick_add`` ist die enge Form und die Voreinstellung. Eine solche Marke
|
||||||
Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist
|
liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt. Sie darf
|
||||||
kein Datenabfluss möglich.
|
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"
|
quick_add = "quick_add"
|
||||||
|
app = "app"
|
||||||
|
|
||||||
|
|
||||||
class ApiToken(Base):
|
class ApiToken(Base):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
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 (
|
from ..models import (
|
||||||
Account,
|
Account,
|
||||||
ApiToken,
|
ApiToken,
|
||||||
@@ -45,9 +45,14 @@ def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_
|
|||||||
def create_token(
|
def create_token(
|
||||||
payload: ApiTokenCreate,
|
payload: ApiTokenCreate,
|
||||||
db: Session = Depends(get_db),
|
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:
|
if payload.default_account_id is not None:
|
||||||
get_owned(db, Account, payload.default_account_id, user)
|
get_owned(db, Account, payload.default_account_id, user)
|
||||||
klartext = generate_session_token()
|
klartext = generate_session_token()
|
||||||
@@ -55,6 +60,7 @@ def create_token(
|
|||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
token_hash=hash_session_token(klartext),
|
token_hash=hash_session_token(klartext),
|
||||||
|
scope=payload.scope,
|
||||||
default_account_id=payload.default_account_id,
|
default_account_id=payload.default_account_id,
|
||||||
)
|
)
|
||||||
db.add(marke)
|
db.add(marke)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .models import (
|
|||||||
IdentifierKind,
|
IdentifierKind,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
Role,
|
Role,
|
||||||
|
TokenScope,
|
||||||
TxnType,
|
TxnType,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -687,11 +688,15 @@ class FlaggedKeywordOut(BaseModel):
|
|||||||
class ApiTokenCreate(BaseModel):
|
class ApiTokenCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=80)
|
name: str = Field(min_length=1, max_length=80)
|
||||||
default_account_id: int | None = None
|
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):
|
class ApiTokenOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
scope: TokenScope
|
||||||
default_account_id: int | None
|
default_account_id: int | None
|
||||||
last_used_at: datetime | None
|
last_used_at: datetime | None
|
||||||
created_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