feat(auth): Admin-Selbstanmeldung in die App (Login statt verlorenem Zugangslink)

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 12:37:14 +02:00
commit b4111c887e
3 changed files with 169 additions and 3 deletions

View file

@ -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=<token>` → 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=<deine 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,<zweiter-admin>
```
- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in
`/etc/voice-assistant/voice-assistant.env`).
---
## 8. Gedächtnis und Erinnerungen

View file

@ -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."""

View file

@ -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