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>
215 lines
7.9 KiB
Python
215 lines
7.9 KiB
Python
"""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
|
||
ansprach, kam daran vorbei.
|
||
"""
|
||
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import Depends, HTTPException, Request, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .config import get_settings
|
||
from .database import get_db
|
||
from .models import ApiToken, Role, TokenScope, User
|
||
from .schemas import UserOut
|
||
from .services import policy as policy_service
|
||
from .services import sessions
|
||
from .security import hash_session_token
|
||
|
||
_credentials_exc = HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Nicht angemeldet oder Sitzung abgelaufen",
|
||
)
|
||
|
||
# Pfade, die trotz offener Pflicht erreichbar bleiben müssen – sonst könnte
|
||
# niemand die Pflicht je erfüllen. Präfix-Vergleich, ohne führendes /api.
|
||
_ALWAYS_ALLOWED = (
|
||
"/health",
|
||
"/version",
|
||
"/auth/me",
|
||
"/auth/logout",
|
||
"/auth/login",
|
||
"/auth/change-password",
|
||
"/auth/2fa",
|
||
)
|
||
# Die Richtlinie darf gelesen werden (die Checkliste braucht sie), aber ein
|
||
# Admin mit offener Pflicht soll sie nicht erst einmal entschärfen können.
|
||
_READ_ONLY_ALLOWED = ("/policy",)
|
||
|
||
|
||
def _session_user(request: Request, db: Session) -> User | None:
|
||
"""Validiert die Sitzung genau einmal pro Anfrage und merkt sie sich.
|
||
|
||
``enforce_account_state`` läuft vor den Endpunkt-Abhängigkeiten; ohne diesen
|
||
Zwischenspeicher würde ``sessions.validate`` (und damit der Schreibzugriff
|
||
auf ``last_seen_at``) pro Anfrage zweimal laufen."""
|
||
if hasattr(request.state, "session_user"):
|
||
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
|
||
|
||
|
||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||
user = _session_user(request, db)
|
||
if user is None:
|
||
raise _credentials_exc
|
||
return user
|
||
|
||
|
||
def pending_reason(db: Session, user: User) -> str | None:
|
||
"""Was der Benutzer erledigen muss, bevor er die App benutzen darf."""
|
||
if user.must_change_password:
|
||
return "password_change_required"
|
||
if policy_service.load(db).mfa_required and not user.totp_enabled:
|
||
return "mfa_setup_required"
|
||
return None
|
||
|
||
|
||
def enforce_account_state(request: Request, db: Session = Depends(get_db)) -> None:
|
||
"""Router-weite Sperre für Konten mit offener Pflicht (403, sonst Durchlass).
|
||
|
||
Nicht angemeldete Anfragen laufen hier ungehindert durch – um die kümmert
|
||
sich ``get_current_user`` am jeweiligen Endpunkt."""
|
||
pfad = request.url.path
|
||
rest = pfad[4:] if pfad.startswith("/api") else pfad
|
||
if rest.startswith(_ALWAYS_ALLOWED):
|
||
return
|
||
if request.method == "GET" and rest.startswith(_READ_ONLY_ALLOWED):
|
||
return
|
||
user = _session_user(request, db)
|
||
if user is None:
|
||
return
|
||
grund = pending_reason(db, user)
|
||
if grund is not None:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
||
|
||
|
||
_token_exc = HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Ungültige oder fehlende Gerätemarke",
|
||
)
|
||
|
||
|
||
def require_quick_token(request: Request, db: Session = Depends(get_db)) -> ApiToken:
|
||
"""Prüft die Gerätemarke im Header ``X-Certain-Token``.
|
||
|
||
Bewusst eine EIGENE Abhängigkeit statt eines Sonderfalls in
|
||
``get_current_user``: So gilt eine Marke ausschliesslich an den Endpunkten,
|
||
die sie ausdrücklich verlangen. Auf allen übrigen Wegen ist sie schlicht
|
||
keine Anmeldung – wer sie erbeutet, kommt an keine Daten.
|
||
"""
|
||
roh = (request.headers.get("X-Certain-Token") or "").strip()
|
||
if not roh:
|
||
raise _token_exc
|
||
marke = (
|
||
db.query(ApiToken)
|
||
.filter(ApiToken.token_hash == hash_session_token(roh),
|
||
ApiToken.scope == TokenScope.quick_add)
|
||
.first()
|
||
)
|
||
if marke is None:
|
||
raise _token_exc
|
||
besitzer = db.get(User, marke.owner_id)
|
||
if besitzer is None or not besitzer.is_active:
|
||
raise _token_exc
|
||
# Wer sein Passwort wechseln muss, soll auch nicht per Kurzbefehl buchen.
|
||
grund = pending_reason(db, besitzer)
|
||
if grund is not None:
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
|
||
marke.last_used_at = datetime.now(timezone.utc)
|
||
db.commit()
|
||
request.state.token_user = besitzer
|
||
return marke
|
||
|
||
|
||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||
if current_user.role != Role.admin:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="Nur Administratoren dürfen diese Aktion ausführen",
|
||
)
|
||
return current_user
|
||
|
||
|
||
def user_out(db: Session, user: User) -> UserOut:
|
||
"""``UserOut`` inklusive des berechneten ``mfa_setup_required``."""
|
||
out = UserOut.model_validate(user)
|
||
out.mfa_setup_required = policy_service.load(db).mfa_required and not user.totp_enabled
|
||
return out
|
||
|
||
|
||
def get_owned(db: Session, model, obj_id: int, user: User):
|
||
"""Holt eine Zeile und stellt sicher, dass sie dem Nutzer gehört – sonst 404.
|
||
|
||
Bewusst 404 (nicht 403): fremde IDs sollen nicht einmal als existent gelten.
|
||
Zentrale Stelle für die Mandanten-Isolation."""
|
||
obj = db.get(model, obj_id)
|
||
if obj is None or getattr(obj, "owner_id", None) != user.id:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nicht gefunden")
|
||
return obj
|