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:
co-authored by
Claude Opus 5
parent
8d4954610b
commit
3d0ff54bf5
@@ -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
|
||||
Reference in New Issue
Block a user