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
67
app/store.py
67
app/store.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue