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:
Dieter Schlüter 2026-06-18 04:37:16 +02:00
commit 76df695111
16 changed files with 703 additions and 29 deletions

View file

@ -0,0 +1,77 @@
import pytest
from fastapi.testclient import TestClient
import app.dependencies as deps
from app.config import settings
from app.main import app
client = TestClient(app)
HEADER = "X-Remote-User"
# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen.
TRUSTED = "testclient"
@pytest.fixture
def forward_auth(monkeypatch):
monkeypatch.setattr(settings, "trusted_auth_header", HEADER)
monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED)
monkeypatch.setattr(settings, "admin_users", "atoor,dieterschlueter,dschlueter")
monkeypatch.setattr(settings, "auth_enabled", True)
def test_forward_auth_provisions_user(forward_auth):
r = client.get("/api/me", headers={HEADER: "lieschen"})
assert r.status_code == 200
body = r.json()
assert body["external_id"] == "lieschen"
assert body["is_admin"] is False
# Nutzer wurde im Store angelegt.
assert deps.get_store().get_user_by_external_id("lieschen") is not None
def test_admin_users_flagged(forward_auth):
for name in ("atoor", "dieterschlueter", "dschlueter"):
r = client.get("/api/me", headers={HEADER: name})
assert r.status_code == 200
assert r.json()["is_admin"] is True, name
def test_untrusted_ip_ignores_header(monkeypatch):
# Proxy-IP passt NICHT zur TestClient-Quelle -> Header wird ignoriert.
monkeypatch.setattr(settings, "trusted_auth_header", HEADER)
monkeypatch.setattr(settings, "trusted_proxy_ips", "10.9.9.9")
monkeypatch.setattr(settings, "auth_enabled", False) # Fallback -> anonym
r = client.get("/api/me", headers={HEADER: "angreifer"})
assert r.status_code == 200
assert r.json()["external_id"] is None # nicht als 'angreifer' uebernommen
def test_missing_header_from_trusted_proxy_401(forward_auth):
r = client.get("/api/me") # vertrauter Proxy, aber kein Identitaets-Header
assert r.status_code == 401
def test_get_or_create_idempotent(forward_auth):
r1 = client.get("/api/me", headers={HEADER: "wiederkehr"})
r2 = client.get("/api/me", headers={HEADER: "wiederkehr"})
assert r1.json()["user_id"] == r2.json()["user_id"]
users = [u for u in deps.get_store().list_users() if u.external_id == "wiederkehr"]
assert len(users) == 1
def test_admin_users_endpoint_gated(forward_auth):
# Admin darf die Liste sehen.
client.get("/api/me", headers={HEADER: "lieschen"}) # einen Nutzer anlegen
r_admin = client.get("/api/admin/users", headers={HEADER: "dschlueter"})
assert r_admin.status_code == 200
assert any(u["external_id"] == "lieschen" for u in r_admin.json())
# Nicht-Admin wird abgewiesen.
r_user = client.get("/api/admin/users", headers={HEADER: "lieschen"})
assert r_user.status_code == 403
def test_static_index_served():
r = client.get("/")
assert r.status_code == 200
assert "Voice Assistant" in r.text