feat(2-Schritt1): Additiver Ein-Klick-Login per Capability-Token

Zweiter Auth-Weg NEBEN Authelia (nichts schaltet um, kein Aussperr-Risiko):
- Link https://voice.jamulix.de/?k=<token> -> Token landet im va_token-Cookie
  (HttpOnly, Secure, SameSite=Lax, 1 Jahr), Token wird aus der URL entfernt.
- Cookie/?k= authentifiziert App-Routen (require_user) und WS (/ws/voice,
  /ws/chat). Token = vorhandenes Nutzer-Token (get_user_by_token).
- authenticate(): fehlt der SSO-Header, wird auf Token-Auth durchgefallen
  (statt hartem 401) — Voraussetzung für die spätere nginx-Trennung.

Authelia/Forward-Auth bleibt voll funktionsfähig (Live verifiziert: beide
Wege gehen). Tests: Cookie-, ?k=- und Negativ-Pfad. 217 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 04:24:52 +02:00
commit 47daf918ae
4 changed files with 88 additions and 10 deletions

View file

@ -9,6 +9,9 @@ from app.config import settings, Settings
from app.dependencies import get_store
from app.store import User
# Cookie mit dem Capability-Token (Ein-Klick-Login der Senioren-App).
CAPABILITY_COOKIE = "va_token"
def _csv_set(value: str) -> set[str]:
return {item.strip() for item in (value or "").split(",") if item.strip()}
@ -89,11 +92,12 @@ def authenticate(headers, client_host: str, token: str | None,
external = (headers.get(cfg.trusted_auth_header) or "").strip() or None
if not external and cfg.trusted_auth_cookie:
external = _username_from_cookie(headers, cfg)
if not external:
return None # SSO sollte die Identitaet immer liefern -> 401
user = store.get_or_create_user_by_external_id(external, display_name=external)
user.is_admin = is_admin_user(user, cfg)
return user
if external:
user = store.get_or_create_user_by_external_id(external, display_name=external)
user.is_admin = is_admin_user(user, cfg)
return user
# Kein SSO-Identitaetsheader (z. B. App-Route ohne Authelia) -> unten
# auf Capability-/Bearer-Token-Auth durchfallen.
# Nicht von der Proxy-IP -> ignorieren, normale Auth unten.
# 2. Auth abgeschaltet (dev/Test).
@ -115,7 +119,11 @@ def _bearer_token(authorization: str | None) -> str | None:
def require_user(request: Request) -> User:
"""FastAPI-Dependency: liefert den authentifizierten Nutzer (sonst 401)."""
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
token = (
_bearer_token(request.headers.get("authorization"))
or request.query_params.get("k")
or request.cookies.get(CAPABILITY_COOKIE)
)
user = authenticate(request.headers, client_host, token)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")