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:
parent
cc354938df
commit
47daf918ae
4 changed files with 88 additions and 10 deletions
|
|
@ -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):
|
||||
|
|
|
|||
20
app/auth.py
20
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")
|
||||
|
|
|
|||
17
app/main.py
17
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> -> 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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue