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

@ -33,6 +33,8 @@ class User:
display_name: str
prefs: dict = field(default_factory=dict)
created_at: str = ""
external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth)
is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet
@dataclass
@ -70,6 +72,17 @@ class Store(ABC):
@abstractmethod
def ensure_anonymous_user(self) -> User: ...
@abstractmethod
def list_users(self) -> list[User]: ...
@abstractmethod
def get_user_by_external_id(self, external_id: str) -> User | None: ...
@abstractmethod
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User: ...
@abstractmethod
def get_session(self, session_id: str) -> Session | None: ...
@ -132,7 +145,8 @@ class SQLiteStore(Store):
display_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefs_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
external_id TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
@ -174,14 +188,25 @@ class SQLiteStore(Store):
);
"""
)
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
if "external_id" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT")
# NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external"
" ON users(external_id)"
)
# ----- Nutzer -----------------------------------------------------------
def _row_to_user(self, row: sqlite3.Row) -> User:
keys = row.keys()
return User(
id=row["id"],
display_name=row["display_name"],
prefs=json.loads(row["prefs_json"] or "{}"),
created_at=row["created_at"],
external_id=row["external_id"] if "external_id" in keys else None,
)
def create_user(self, display_name: str) -> tuple[User, str]:
@ -230,6 +255,46 @@ class SQLiteStore(Store):
)
return self.get_user(ANONYMOUS_USER_ID)
def list_users(self) -> list[User]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM users WHERE id != ? ORDER BY created_at",
(ANONYMOUS_USER_ID,),
).fetchall()
return [self._row_to_user(row) for row in rows]
def get_user_by_external_id(self, external_id: str) -> User | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM users WHERE external_id = ?", (external_id,)
).fetchone()
return self._row_to_user(row) if row else None
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User:
"""Findet den Nutzer zur SSO-/Proxy-Identitaet oder legt ihn an (Forward-Auth)."""
existing = self.get_user_by_external_id(external_id)
if existing:
return existing
user = User(
id=uuid.uuid4().hex,
display_name=display_name or external_id,
prefs={},
created_at=_now(),
external_id=external_id,
)
with self._connect() as conn:
conn.execute(
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at,"
" external_id) VALUES (?, ?, ?, ?, ?, ?)",
# token_hash ist NOT NULL UNIQUE -> synthetischer, kollisionsfreier Platzhalter
# (SSO-Nutzer authentifizieren sich nicht ueber ein Token).
(user.id, user.display_name, f"ext:{external_id}", "{}",
user.created_at, external_id),
)
return user
# ----- Sessions ---------------------------------------------------------
def get_session(self, session_id: str) -> Session | None:
with self._connect() as conn: