feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt + Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen), Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue - Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration) - /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated - StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL - WS-Auth liest Identitaet aus dem Handshake-Header - Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery) - Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
855fc71a1e
commit
76df695111
16 changed files with 703 additions and 29 deletions
|
|
@ -1,14 +1,41 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.auth import require_admin
|
||||
from app.auth import require_admin, require_admin_or_user
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserCreate, UserCreated
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/request-headers", dependencies=[Depends(require_admin_or_user)])
|
||||
async def request_headers(request: Request):
|
||||
"""Discovery: zeigt die eingehenden HTTP-Header + Quell-IP.
|
||||
|
||||
Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header
|
||||
(TRUSTED_AUTH_HEADER) festzustellen. Nur fuer Admins.
|
||||
"""
|
||||
return {
|
||||
"client": request.client.host if request.client else None,
|
||||
"headers": dict(request.headers),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||
async def create_user(payload: UserCreate):
|
||||
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||
user, token = get_store().create_user(payload.display_name)
|
||||
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||
|
||||
|
||||
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
|
||||
async def list_users():
|
||||
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""
|
||||
return [
|
||||
{
|
||||
"user_id": u.id,
|
||||
"display_name": u.display_name,
|
||||
"external_id": u.external_id,
|
||||
"created_at": u.created_at,
|
||||
}
|
||||
for u in get_store().list_users()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.auth import require_user
|
||||
from app.auth import require_user, is_admin_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
|
||||
from app.store import User
|
||||
|
|
@ -10,7 +11,14 @@ router = APIRouter()
|
|||
|
||||
@router.get("/me")
|
||||
async def get_me(user: User = Depends(require_user)):
|
||||
return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs}
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"display_name": user.display_name,
|
||||
"external_id": user.external_id,
|
||||
"is_admin": user.is_admin or is_admin_user(user),
|
||||
"prefs": user.prefs,
|
||||
"sso_logout_url": settings.sso_logout_url,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/me/prefs")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from app.store import 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.safety.emergency import handle_emergency, schedule_llm_emergency_check
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -45,13 +46,11 @@ _OVERRIDE_KEYS = (
|
|||
)
|
||||
|
||||
|
||||
def _authenticate(token: str | None):
|
||||
store = get_store()
|
||||
if not settings.auth_enabled:
|
||||
return store.ensure_anonymous_user()
|
||||
if not token:
|
||||
return None
|
||||
return store.get_user_by_token(token)
|
||||
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)
|
||||
|
||||
|
||||
async def _resolve(user, session_id, options):
|
||||
|
|
@ -213,7 +212,7 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
|
|||
|
||||
@router.websocket("/ws/chat")
|
||||
async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
|
||||
user = _authenticate(token)
|
||||
user = _authenticate(websocket, token)
|
||||
if user is None:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
|
@ -244,7 +243,7 @@ async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: st
|
|||
|
||||
@router.websocket("/ws/voice")
|
||||
async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
|
||||
user = _authenticate(token)
|
||||
user = _authenticate(websocket, token)
|
||||
if user is None:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue