feat(auth): Passwort-Login (1FA) für die App; Admin ebenfalls 1FA

Variante 1: Senioren behalten ihren persönlichen Link; zusätzlich ein
Passwort-Einstieg ohne 2FA über Authelia.

- Neuer Endpunkt GET /api/login (app_login): hinter Authelia (nginx-Location
  /login, one_factor), erkennt den per Remote-User weitergereichten Nutzer
  (Admin ODER normaler Authelia-Nutzer), mintet das va_token-Cookie und leitet
  in die App (/). Cookie-Mint in _issue_login_cookie() gemeinsam mit
  /api/admin/login refaktoriert.
- "Persönlicher Zugang nötig"-Seite (main.py) bekommt einen großen Knopf
  „Mit Benutzername & Passwort anmelden" -> /login. Bare / leitet bewusst NICHT
  automatisch um (Senioren ohne Authelia-Konto würden sonst feststecken).

Begleitende Infra (außerhalb des Repos):
- nginx: location = /login (auth_request one_factor -> /api/login).
- Authelia: access_control-Regel voice.jamulix.de -> one_factor (über der
  *.jamulix.de-Wildcard); damit sind App-Login UND Admin nur noch 1FA, andere
  Subdomains bleiben 2FA.

Tests: /api/login für Nicht-Admin/Admin/ohne-Identität. 257 Offline-Tests grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 19:04:37 +02:00
commit 6792e1be86
3 changed files with 63 additions and 21 deletions

View file

@ -66,34 +66,50 @@ 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")
def _issue_login_cookie(request: Request, user, action: str):
"""Mintet ein frisches Capability-Token fuer `user`, setzt es als va_token-Cookie
und leitet in die App (/). Gemeinsame Basis fuer /api/login und /api/admin/login.
Jeder Aufruf rotiert das Token (ein Token pro Nutzer)."""
result = get_store().reset_token(user.id)
if result is None:
raise HTTPException(status_code=404, detail="Admin-Nutzer nicht gefunden.")
raise HTTPException(status_code=404, detail="Nutzer nicht gefunden.")
_, raw_token = result
log_admin_action(request, "admin_app_login", user_id=user.id)
log_admin_action(request, action, 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.get("/login")
async def app_login(request: Request):
"""Anmeldung in die App mit Benutzername/Passwort (1FA) statt persoenlichem Link.
Liegt hinter Authelia (nginx: Location /login, one_factor). Erkennt den per
Remote-User weitergereichten Authelia-Nutzer (Admin ODER normaler Nutzer),
mintet das va_token-Cookie und leitet in die App. Senioren nutzen weiter ihren
Link; dieser Weg ist der Passwort-Einstieg fuer Betreuer/Admins.
"""
user = _current_user_or_none(request)
if user is None:
raise HTTPException(status_code=403, detail="Authentication required")
return _issue_login_cookie(request, user, "app_login")
@router.get("/admin/login")
async def admin_app_login(request: Request):
"""Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink.
Wie /api/login, aber zusaetzlich auf Admins beschraenkt (ADMIN_USERS). Liegt
hinter Authelia (nginx: Location /admin-login). Mintet das va_token-Cookie und
leitet in die App (/).
"""
user = _current_user_or_none(request)
if not is_admin_user(user):
raise HTTPException(status_code=403, detail="Admin privileges required")
return _issue_login_cookie(request, user, "admin_app_login")
@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."""