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

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