feat(admin): Token-Reset-Endpunkt + Doku-Klarstellung zu Token-Verwaltung

- POST /api/admin/users/{user_id}/token: neues Bearer-Token ausstellen
  (alter Token sofort ungültig, Nutzerdaten bleiben erhalten)
- Store ABC + SQLiteStore: reset_token() implementiert
- BEDIENUNGSANLEITUNG §7.2: erklärt warum Tokens nicht abrufbar sind (nur
  SHA256-Hash gespeichert), wo ADMIN_API_KEY nachzuschauen ist (.env),
  wie Token-Reset genutzt wird; praktisches Tipp zu ~/.bashrc
- Anhang B.5: neuen Endpunkt in REST-Referenz eingetragen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 17:36:41 +02:00
commit b76ec76c45
3 changed files with 66 additions and 3 deletions

View file

@ -958,16 +958,49 @@ Beispiel-Antwort:
``` ```
> ⚠️ Das Token erscheint **nur einmal** — sofort sicher aufbewahren (z. B. in einem > ⚠️ Das Token erscheint **nur einmal** — sofort sicher aufbewahren (z. B. in einem
> Passwort-Manager). Es kann danach nicht mehr abgerufen werden. Bei Verlust muss > Passwort-Manager oder `~/.bashrc`). Es wird nur als SHA256-Hash in der Datenbank
> der Nutzer gelöscht und neu angelegt werden. > gespeichert — der Klartext ist danach **nicht mehr abrufbar**.
>
> Token verloren? → Neues Token ausstellen (s. u.) oder Nutzer löschen und neu anlegen.
Das Token dem Nutzer mitteilen. Er gibt es bei jedem Aufruf im `Authorization`-Header an: Das Token dem Nutzer mitteilen. Er gibt es bei jedem Aufruf im `Authorization`-Header an:
```bash ```bash
TOKEN=va-tok-AbCdEfGh12345... # einmal setzen TOKEN=va-tok-AbCdEfGh12345... # einmal setzen, z.B. in ~/.bashrc:
# export TOKEN=va-tok-AbCdEfGh12345...
curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq
# → {"user_id":"a3f8c1d2e4b7…","display_name":"Oma Anna","prefs":{}} # → {"user_id":"a3f8c1d2e4b7…","display_name":"Oma Anna","prefs":{}}
``` ```
#### Token neu ausstellen (bei Verlust oder Rotation)
Falls das Token verloren gegangen ist oder aus Sicherheitsgründen gewechselt werden soll:
```bash
curl -s -X POST $URL/api/admin/users/$USER_ID/token \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
Beispiel-Antwort:
```json
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"token": "va-tok-NeuErKlArTeXt..."
}
```
Der **alte Token wird sofort ungültig**. Der neue Token erscheint ebenfalls nur einmal —
alle bisherigen Daten (Erinnerungen, Gesprächsverlauf) bleiben erhalten.
> **Woher kommt `$ADMIN_API_KEY`?** Dieser Key ist in der Datei `.env` auf dem Server
> hinterlegt. Nachschauen mit:
> ```bash
> grep ADMIN_API_KEY .env
> # ADMIN_API_KEY=mein-geheimes-admin-passwort
> ```
> Du hast ihn beim Setup selbst gewählt. Er ist **kein** auto-generierter Hash —
> du kannst ihn jederzeit in `.env` lesen und bei Bedarf ändern (Gateway neu starten).
#### Alle Nutzer anzeigen #### Alle Nutzer anzeigen
```bash ```bash
@ -1524,6 +1557,7 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`
| `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig | | `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig |
| `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten | | `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten |
| `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen | | `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen |
| `POST` | `/api/admin/users/{user_id}/token` | `X-Admin-Key` | Neues Token ausstellen (alter Token sofort ungültig) |
## B.6 WebSocket ## B.6 WebSocket

View file

@ -59,6 +59,17 @@ async def delete_user(user_id: str):
return {"deleted": user_id} return {"deleted": user_id}
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def reset_token(user_id: str):
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.
Der neue Token wird EINMALIG zurueckgegeben und danach nicht mehr angezeigt."""
result = get_store().reset_token(user_id)
if result is None:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
user, token = result
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)]) @router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
async def list_users(): async def list_users():
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY.""" """Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""

View file

@ -124,6 +124,11 @@ class Store(ABC):
Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden. Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden.
Liefert True, wenn der Nutzer existierte und geloescht wurde.""" Liefert True, wenn der Nutzer existierte und geloescht wurde."""
@abstractmethod
def reset_token(self, user_id: str) -> tuple[User, str] | None:
"""Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig.
Liefert (User, Klartext-Token) oder None, wenn der Nutzer nicht existiert."""
@abstractmethod @abstractmethod
def log_emergency(self, user_id: str, category: str, snippet: str) -> None: def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" """Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
@ -445,6 +450,19 @@ class SQLiteStore(Store):
conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
return True return True
def reset_token(self, user_id: str) -> tuple[User, str] | None:
with self._connect() as conn:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
if not row:
return None
raw_token = secrets.token_urlsafe(32)
conn.execute(
"UPDATE users SET token_hash = ? WHERE id = ?",
(hash_token(raw_token), user_id),
)
user = self._row_to_user(conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone())
return user, raw_token
# ----- Notfall-Protokoll ------------------------------------------------ # ----- Notfall-Protokoll ------------------------------------------------
def log_emergency(self, user_id: str, category: str, snippet: str) -> None: def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
with self._connect() as conn: with self._connect() as conn: