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
|
||||
|
||||
Reference in New Issue
Block a user