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