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

@ -59,6 +59,17 @@ async def delete_user(user_id: str):
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)])
async def list_users():
"""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.
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
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
@ -445,6 +450,19 @@ class SQLiteStore(Store):
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
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 ------------------------------------------------
def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
with self._connect() as conn: