From b4111c887e548e482ea20a8b8a96dc59d15e8575 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 26 Jun 2026 12:37:14 +0200 Subject: [PATCH] feat(auth): Admin-Selbstanmeldung in die App (Login statt verlorenem Zugangslink) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die App-Oberflaeche (/) ist bewusst nur per persoenlichem Zugangslink erreichbar (kein SSO, damit Senioren keine Login-Maske sehen). Ein Admin, der seinen Link verliert, kam bisher nicht mehr in die App-UI. Neuer Endpunkt GET /api/admin/login: liegt hinter Authelia (nginx-Location /api/admin/ mit auth_request + error_page-Redirect, Remote-User durchgereicht), erkennt den SSO-Admin, mintet ein frisches Capability-Token, setzt es als va_token-Cookie und leitet in die App (/). Damit meldet sich der Admin allein mit seinem Authelia-Passwort an. Jeder Aufruf rotiert den Token (alte Links dieses Admins werden ungueltig) - fuer eine Recovery-Funktion korrekt. Die huebsche URL https://voice.jamulix.de/admin-login wird per nginx-Alias auf diesen Endpunkt gelegt (nginx-Config liegt ausserhalb des Repos). Doku: BEDIENUNGSANLEITUNG.md § 7.6 - Selbstanmeldung, Recovery per ADMIN_API_KEY (SSO-unabhaengig) und Haertung (zweiter Admin in ADMIN_USERS, Key im Passwortmanager). Tests: Cookie wird gesetzt + 303 -> /, gesetzter Token authentifiziert, Token-Rotation, Nicht-Admin/ohne Identitaet -> 403. Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 67 +++++++++++++++++++++++++++++++- app/api/admin.py | 32 ++++++++++++++- tests/test_admin_app_login.py | 73 +++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/test_admin_app_login.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index a703ed3..a74b4da 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration) **Administration** -7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) +7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) · [7.6 Admin-Login & Recovery](#76-admin-zugang-ohne-persönlichen-link-selbstanmeldung--recovery) 8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen) 9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken) 10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation) @@ -1865,6 +1865,71 @@ Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen > Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb > (`make run`) erscheinen die Audit-Zeilen im Terminal. +### 7.6 Admin-Zugang ohne persönlichen Link (Selbstanmeldung & Recovery) + +> 🔧 Admin + +Die App-Oberfläche (`/`) ist **nur per persönlichem Zugangslink** (`?k=` → Cookie +`va_token`) erreichbar — bewusst ohne SSO, damit Senioren keine Login-Maske sehen. Wer +seinen Link verliert, kommt zunächst nicht in die App. Als **Admin** bist Du dadurch +**nicht ausgesperrt**: Du hast einen Selbst-Login und zwei unabhängige Recovery-Wege. + +#### 7.6.1 Selbstanmeldung mit dem Admin-Passwort (empfohlen) + +Rufe im Browser auf: + +``` +https://voice.jamulix.de/admin-login +``` + +Ablauf: Der Reverse-Proxy schickt Dich zu **Authelia** (`auth.jamulix.de`) → Du meldest +Dich mit Deinem **Admin-Passwort/2FA** an → das Gateway erkennt Dich als SSO-Admin +(`ADMIN_USERS`), setzt Dir ein frisches `va_token`-Cookie und leitet in die App (`/`). +**Kein Link nötig.** Bookmarke diese URL als „Admin-Login". + +- Technisch dahinter steht der Endpunkt `GET /api/admin/login` (dieselbe Wirkung; die + hübsche URL `/admin-login` ist nur ein nginx-Alias darauf). +- **Jeder Aufruf rotiert Deinen Token** — alte persönliche Links *dieses Admin-Nutzers* + werden ungültig. Für eine Wiederherstellung korrekt und ein Sicherheitsplus. + +#### 7.6.2 Recovery per `ADMIN_API_KEY` (SSO-unabhängig) + +Funktioniert auch, wenn Authelia gerade nicht erreichbar ist — vom Server/localhost aus. +Den Key liest Du als root aus `/etc/voice-assistant/voice-assistant.env`. + +```bash +# 1) Deine user_id finden: +curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" \ + | jq -r '.[] | select(.external_id=="dschlueter") | .user_id' + +USER_ID= + +# 2) Neuen Token ausstellen (wird EINMALIG zurückgegeben): +TOKEN=$(curl -s -X POST $URL/api/admin/users/$USER_ID/token \ + -H "X-Admin-Key: $ADMIN_API_KEY" | jq -r '.token') + +# 3) Daraus den Zugangslink bauen: +echo "https://voice.jamulix.de/?k=$TOKEN" +``` + +> Der `ADMIN_API_KEY` ist sehr mächtig (voller Admin-Zugriff). Nicht in Proxy-Logs +> geraten lassen (kein `?key=` in URLs verwenden, außer beim einmaligen Bootstrap über +> `GET /api/admin/request-headers?key=…`), und nach solchem Gebrauch rotieren. + +#### 7.6.3 Härtung gegen Total-Aussperrung + +Eine echte Vollsperre bräuchte: Link weg **und** Authelia-Konto weg **und** +`ADMIN_API_KEY` weg **und** kein SSH. Um auch das abzusichern: + +- **Zweiter Admin:** einen weiteren Authelia-Namen in `ADMIN_USERS` aufnehmen (CSV), + damit ein einzelnes verlorenes Konto nicht alles blockiert: + ```bash + # in /etc/voice-assistant/voice-assistant.env, dann Gateway neu starten: + ADMIN_USERS=dschlueter, + ``` +- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in + `/etc/voice-assistant/voice-assistant.env`). + --- ## 8. Gedächtnis und Erinnerungen diff --git a/app/api/admin.py b/app/api/admin.py index 22db3eb..72fec12 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,7 +6,7 @@ import httpx import yaml from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel from app.admin_llm import ( @@ -16,7 +16,7 @@ from app.admin_llm import ( switch_backend, ) from app.audit import log_admin_action -from app.auth import is_admin_user, require_admin, require_admin_or_user +from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings @@ -66,6 +66,34 @@ def _current_user_or_none(request: Request): return authenticate(request.headers, client_host, token) +@router.get("/admin/login") +async def admin_app_login(request: Request): + """Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink. + + Liegt hinter Authelia (nginx: Location /api/admin/ bzw. /admin-login mit + auth_request + error_page-Redirect). Erkennt den SSO-Admin via Remote-User, + mintet ein frisches Capability-Token, setzt es als va_token-Cookie und leitet + in die App (/). Damit meldet sich der Admin allein mit seinem Authelia-Passwort + an, ohne persoenlichen Link. + + Jeder Aufruf rotiert das Token (alte Links DIESES Admin-Nutzers werden + ungueltig) — fuer eine Recovery-Funktion korrekt und ein Sicherheitsplus: + jede SSO-Anmeldung erzeugt eine frische Capability. + """ + user = _current_user_or_none(request) + if not is_admin_user(user): + raise HTTPException(status_code=403, detail="Admin privileges required") + result = get_store().reset_token(user.id) + if result is None: + raise HTTPException(status_code=404, detail="Admin-Nutzer nicht gefunden.") + _, raw_token = result + log_admin_action(request, "admin_app_login", user_id=user.id) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie(CAPABILITY_COOKIE, raw_token, httponly=True, secure=True, + samesite="lax", max_age=31536000) + return resp + + @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.""" diff --git a/tests/test_admin_app_login.py b/tests/test_admin_app_login.py new file mode 100644 index 0000000..15d7d85 --- /dev/null +++ b/tests/test_admin_app_login.py @@ -0,0 +1,73 @@ +"""Tests fuer die Admin-Selbstanmeldung in die App (GET /api/admin/login). + +Der Endpunkt liegt in Produktion hinter Authelia (nginx setzt Remote-User). +Hier wird die Forward-Auth ueber den vertrauten Proxy simuliert. +""" +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.auth import CAPABILITY_COOKIE +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", "dschlueter") + monkeypatch.setattr(settings, "auth_enabled", True) + + +def test_admin_login_sets_cookie_and_redirects(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + set_cookie = r.headers.get("set-cookie", "") + assert f"{CAPABILITY_COOKIE}=" in set_cookie + assert "HttpOnly" in set_cookie + + +def test_admin_login_cookie_token_authenticates(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + # Den frisch gesetzten Token aus dem Set-Cookie ziehen ... + raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + # ... und pruefen, dass er den Admin-Nutzer authentifiziert. + user = deps.get_store().get_user_by_token(raw) + assert user is not None + assert user.external_id == "dschlueter" + + +def test_admin_login_rotates_token(forward_auth): + r1 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw1 = r1.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + r2 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw2 = r2.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + assert raw1 != raw2 + # Der alte Token ist nach der Rotation ungueltig. + assert deps.get_store().get_user_by_token(raw1) is None + assert deps.get_store().get_user_by_token(raw2) is not None + + +def test_admin_login_rejects_non_admin(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "lieschen"}, + follow_redirects=False) + assert r.status_code == 403 + + +def test_admin_login_rejects_without_identity(forward_auth): + # Vertrauter Proxy, aber kein Identitaets-Header -> keine Admin-Identitaet -> 403. + # (In Produktion faengt nginx das vorher mit dem Authelia-Redirect ab.) + r = client.get("/api/admin/login", follow_redirects=False) + assert r.status_code == 403