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

@ -8,7 +8,10 @@ from fastapi.staticfiles import StaticFiles
from starlette.responses import RedirectResponse, JSONResponse
from app.config import settings
from app.auth import authenticate, _bearer_token
from urllib.parse import urlencode
from app.auth import authenticate, _bearer_token, CAPABILITY_COOKIE
from app.dependencies import get_store
from app.core.warmup import warmup_local_models
from app.metrics import metrics
from app.api.health import router as health_router
@ -67,8 +70,18 @@ async def gate_web_ui(request: Request, call_next):
"""
path = request.url.path
if not path.startswith(_PUBLIC_PREFIXES):
# Ein-Klick-Link: ?k=<token> -> Token im Cookie speichern, aus der URL entfernen.
k = request.query_params.get("k")
if k and get_store().get_user_by_token(k):
params = {kk: vv for kk, vv in request.query_params.items() if kk != "k"}
clean = path + ("?" + urlencode(params) if params else "")
resp = RedirectResponse(clean, status_code=303)
resp.set_cookie(CAPABILITY_COOKIE, k, httponly=True, secure=True,
samesite="lax", max_age=31536000)
return resp
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
token = (_bearer_token(request.headers.get("authorization"))
or k or request.cookies.get(CAPABILITY_COOKIE))
if authenticate(request.headers, client_host, token) is None:
login = settings.sso_login_url.strip()
if login: