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:
parent
1b1e1bb1d5
commit
b4111c887e
3 changed files with 169 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
73
tests/test_admin_app_login.py
Normal file
73
tests/test_admin_app_login.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue