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