Anmeldung wahlweise ueber OpenID Connect (Authentik)

Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+192
View File
@@ -0,0 +1,192 @@
"""OIDC-Anmeldeanbieter aus Umgebungsvariablen lesen.
Ein Anbieter wird NICHT im Code hinterlegt, sondern über die Umgebung beschrieben.
``OIDC_PROVIDERS`` zaehlt die Kuerzel auf, jedes Kuerzel bekommt einen eigenen
Block::
OIDC_PROVIDERS=authentik
OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration
OIDC_AUTHENTIK_CLIENT_ID=...
OIDC_AUTHENTIK_CLIENT_SECRET=...
Ein zweiter Anbieter ist damit reine Konfiguration (``OIDC_PROVIDERS=authentik,keycloak``
plus zweiter Block) – ohne Codeaenderung. ``pydantic-settings`` kann solche
dynamischen Praefixe nicht, deshalb dieser eigene kleine Leser statt eines Feldes
in ``Settings``.
Fehlt die Konfiguration ganz, ist die Liste leer und die Anwendung laeuft wie
bisher rein mit Passwort-Login weiter. Das ist wichtig, weil ``deploy/update.sh``
bestehende ``.env``-Dateien nicht nachtraegt.
"""
from __future__ import annotations
import logging
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from functools import lru_cache
logger = logging.getLogger(__name__)
# Der Browser-Weg braucht KEIN Refresh-Token: das Backend loest den Code ein,
# stellt sein eigenes Vorrania-Token aus und wirft den Rest weg. offline_access
# hier anzufordern wuerde nur einen zusaetzlichen Zustimmungsdialog ausloesen.
STANDARD_SCOPES = "openid profile email"
# Die App dagegen haelt die Anmeldung selbst und erneuert sie im Hintergrund.
# offline_access ist dafuer Pflicht und kein Beiwerk: Authentik liefert seit
# 2024.2 ohne diesen Scope KEIN Refresh-Token.
STANDARD_NATIVE_SCOPES = "openid profile email offline_access"
# Custom-URL-Scheme der iOS-App. Der Scheme "vorrania" ist in der Info.plist
# bereits registriert (dort fuer vorrania://checkin und vorrania://i/<UID>).
STANDARD_NATIVE_REDIRECT = "vorrania://oauth/callback"
_ERLAUBTES_KUERZEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$")
@dataclass(frozen=True)
class OidcProvider:
"""Ein konfigurierter Anmeldeanbieter."""
slug: str
display_name: str
discovery_url: str
client_id: str
# Nur der vertrauliche Web-Client hat ein Secret. Die App ist ein oeffentlicher
# Client und sendet nie eins – deshalb steht es hier auch nur fuer den Web-Weg.
client_secret: str = ""
scopes: str = STANDARD_SCOPES
native_scopes: str = STANDARD_NATIVE_SCOPES
# Client-IDs der nativen (oeffentlichen) Clients. Ein ID-Token aus der App
# traegt eine ANDERE aud als der Web-Client, deshalb eine eigene Liste.
native_client_ids: tuple[str, ...] = ()
native_redirect_uri: str = STANDARD_NATIVE_REDIRECT
redirect_uri: str = ""
auto_create: bool = True
# Verknuepfung mit einem bestehenden Konto nur bei bestaetigter E-Mail. Wer das
# abschaltet, erlaubt einem IdP-Konto mit frei gewaehlter Adresse die Uebernahme
# eines fremden Vorrania-Kontos.
require_verified_email: bool = True
@property
def erlaubte_audiences(self) -> tuple[str, ...]:
"""Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf."""
return (self.client_id, *self.native_client_ids)
def _wahrheitswert(rohwert: str | None, standard: bool) -> bool:
if rohwert is None or not rohwert.strip():
return standard
return rohwert.strip().lower() in {"1", "true", "yes", "on", "ja"}
def _liste(rohwert: str | None) -> tuple[str, ...]:
if not rohwert:
return ()
return tuple(t.strip() for t in rohwert.replace(";", ",").split(",") if t.strip())
def _praefix(slug: str) -> str:
""""authentik" -> "OIDC_AUTHENTIK_", "my-idp" -> "OIDC_MY_IDP_"."""
return "OIDC_" + slug.upper().replace("-", "_") + "_"
def provider_aus_umgebung(
env: Mapping[str, str], public_base_url: str = ""
) -> list[OidcProvider]:
"""Liest alle konfigurierten Anbieter.
Unvollstaendig konfigurierte Anbieter werden uebersprungen und protokolliert –
ein Tippfehler in der ``.env`` darf den Serverstart nicht verhindern.
"""
anbieter: list[OidcProvider] = []
basis = public_base_url.rstrip("/")
for roh in _liste(env.get("OIDC_PROVIDERS")):
slug = roh.strip().lower()
if not _ERLAUBTES_KUERZEL.match(slug):
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: Kuerzel darf nur Kleinbuchstaben, "
"Ziffern und Bindestriche enthalten.",
roh,
)
continue
if any(a.slug == slug for a in anbieter):
logger.warning("OIDC-Anbieter '%s' doppelt genannt – zweiter ignoriert.", slug)
continue
p = _praefix(slug)
discovery = (env.get(p + "DISCOVERY_URL") or "").strip()
client_id = (env.get(p + "CLIENT_ID") or "").strip()
if not discovery or not client_id:
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: %sDISCOVERY_URL und %sCLIENT_ID "
"muessen gesetzt sein.",
slug,
p,
p,
)
continue
redirect = (env.get(p + "REDIRECT_URI") or "").strip()
if not redirect:
if not basis:
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: ohne PUBLIC_BASE_URL laesst sich "
"die Rueckleit-Adresse nicht bilden (oder %sREDIRECT_URI setzen).",
slug,
p,
)
continue
# Das /api-Praefix entsteht erst in nginx, nicht in FastAPI – deshalb
# gehoert es hier fest hinein und darf nicht aus dem Request kommen.
redirect = f"{basis}/api/auth/oidc/{slug}/callback"
def _scopes(schluessel: str, standard: str) -> str:
wert = (env.get(p + schluessel) or "").strip() or standard
# Ohne openid ist es kein OpenID Connect, sondern blosses OAuth –
# dann kaeme gar kein ID-Token zurueck.
return wert if "openid" in wert.split() else "openid " + wert
scopes = _scopes("SCOPES", STANDARD_SCOPES)
native_scopes = _scopes("NATIVE_SCOPES", STANDARD_NATIVE_SCOPES)
anbieter.append(
OidcProvider(
slug=slug,
display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(),
discovery_url=discovery,
client_id=client_id,
client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(),
scopes=scopes,
native_scopes=native_scopes,
native_client_ids=_liste(env.get(p + "NATIVE_CLIENT_IDS")),
native_redirect_uri=(env.get(p + "NATIVE_REDIRECT_URI") or "").strip()
or STANDARD_NATIVE_REDIRECT,
redirect_uri=redirect,
auto_create=_wahrheitswert(env.get(p + "AUTO_CREATE"), True),
require_verified_email=_wahrheitswert(
env.get(p + "REQUIRE_VERIFIED_EMAIL"), True
),
)
)
return anbieter
@lru_cache
def registrierte_provider() -> tuple[OidcProvider, ...]:
"""Die Anbieter dieses Prozesses (einmal beim ersten Zugriff gelesen)."""
from .config import get_settings
return tuple(provider_aus_umgebung(os.environ, get_settings().public_base_url))
def provider_finden(slug: str) -> OidcProvider | None:
for p in registrierte_provider():
if p.slug == slug:
return p
return None