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>
193 lines
7.4 KiB
Python
193 lines
7.4 KiB
Python
"""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
|