diff --git a/app/api/ws.py b/app/api/ws.py index 4e8cc7c..9b3df72 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -33,7 +33,7 @@ from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError -from app.auth import authenticate +from app.auth import authenticate, _cookie_value, CAPABILITY_COOKIE router = APIRouter() @@ -47,11 +47,20 @@ _OVERRIDE_KEYS = ( ) +def _capability_token(websocket: WebSocket, token: str | None) -> str | None: + """Token aus ?token=, ?k= oder dem va_token-Cookie (Senioren-Login).""" + return ( + token + or websocket.query_params.get("k") + or _cookie_value(websocket.headers.get("cookie"), CAPABILITY_COOKIE) + ) + + def _authenticate(websocket: WebSocket, token: str | None): # Forward-Auth (SSO) greift auch beim WS-Handshake: SSOwat injiziert den # Identitaets-Header in den Upgrade-Request -> aus websocket.headers lesbar. client_host = websocket.client.host if websocket.client else "" - return authenticate(websocket.headers, client_host, token) + return authenticate(websocket.headers, client_host, _capability_token(websocket, token)) async def _resolve(user, session_id, options): diff --git a/app/auth.py b/app/auth.py index 8b7cad7..d18767c 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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") diff --git a/app/main.py b/app/main.py index 02da98b..53d7617 100644 --- a/app/main.py +++ b/app/main.py @@ -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 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: diff --git a/tests/test_capability_login.py b/tests/test_capability_login.py new file mode 100644 index 0000000..2f03308 --- /dev/null +++ b/tests/test_capability_login.py @@ -0,0 +1,48 @@ +"""Schritt 1: additiver Ein-Klick-Login per Capability-Token (Cookie / ?k=).""" + +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.main import app +from app.config import settings + +client = TestClient(app) + + +def test_capability_link_sets_cookie_and_strips_url(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + user, token = deps.get_store().create_user("Senior") + r = client.get(f"/?k={token}", follow_redirects=False) + assert r.status_code == 303 + # Cookie wird gesetzt + sc = r.headers.get("set-cookie", "") + assert "va_token=" in sc and "HttpOnly" in sc + # URL ist um ?k= bereinigt (Redirect-Ziel ohne k) + assert "k=" not in r.headers.get("location", "") + client.cookies.clear() + + +def test_cookie_authenticates_api(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + user, token = deps.get_store().create_user("Senior") + client.cookies.set("va_token", token) + me = client.get("/api/me") + assert me.status_code == 200 + assert me.json()["user_id"] == user.id + client.cookies.clear() + + +def test_query_token_authenticates_api(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + user, token = deps.get_store().create_user("Senior") + me = client.get(f"/api/me?k={token}") + assert me.status_code == 200 + assert me.json()["user_id"] == user.id + + +def test_invalid_capability_link_does_not_set_cookie(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + r = client.get("/?k=not-a-real-token", follow_redirects=False) + # ungültiges Token -> kein Cookie, kein 303-Redirect auf bereinigte URL + assert "va_token=" not in r.headers.get("set-cookie", "") + client.cookies.clear()