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
77
tests/test_forward_auth.py
Normal file
77
tests/test_forward_auth.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue