Files
Vorrania/backend/app/services/oidc.py
T
ScarriffleandClaude Opus 5 3d0ff54bf5 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>
2026-09-09 21:55:38 +02:00

467 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenID Connect: Discovery, Token-Pruefung und Zuordnung zu Vorrania-Konten.
Der Protokoll-Teil laeuft ueber Authlib – Signaturpruefung, PKCE und
Code-Tausch sind nichts, was man selbst schreiben sollte. Bewusst die
SYNCHRONE Variante von Authlib: alle Routen dieses Projekts sind normale
``def``-Funktionen im Threadpool, die Datenbank-Sitzung ist synchron, und
ausgehendes HTTP laeuft schon in ``off.py`` ueber ein synchrones ``httpx``.
:func:`benutzer_aus_claims` ist absichtlich frei von HTTP: die gesamte
Zuordnungs- und Anlage-Logik laesst sich damit mit einer Datenbank-Sitzung und
einem selbstgebauten Claims-Wörterbuch pruefen.
"""
from __future__ import annotations
import logging
import re
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from authlib.integrations.httpx_client import OAuth2Client
from authlib.jose import JsonWebKey, jwt
from authlib.jose.errors import JoseError
from authlib.oidc.core import CodeIDToken
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import Role, User, UserIdentity
from ..oidc_config import OidcProvider
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10.0
# Discovery-Dokument und Schluessel aendern sich selten; eine Stunde Vorrat spart
# bei jeder Anmeldung zwei Abfragen beim Anbieter.
CACHE_SEKUNDEN = 3600.0
# Etwas Spielraum fuer auseinanderlaufende Uhren zwischen Server und Anbieter.
UHREN_SPIELRAUM = 60
class OidcFehler(Exception):
"""Anmeldung ueber den Anbieter ist gescheitert.
``schluessel`` landet als Kennung in der Weiterleitung zur Web-Oberflaeche,
``args[0]`` ist der Klartext fuer Protokoll und API-Antwort.
"""
def __init__(self, schluessel: str, meldung: str) -> None:
super().__init__(meldung)
self.schluessel = schluessel
_metadaten_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_jwks_cache: dict[str, tuple[float, Any]] = {}
def cache_leeren() -> None:
"""Nur fuer Tests und zum erzwungenen Neuladen."""
_metadaten_cache.clear()
_jwks_cache.clear()
def _holen(url: str) -> dict[str, Any]:
try:
antwort = httpx.get(url, timeout=TIMEOUT_SECONDS, follow_redirects=True)
antwort.raise_for_status()
return antwort.json()
except (httpx.HTTPError, ValueError) as fehler:
# Anders als beim Produkt-Lookup darf das hier NICHT still zu None werden –
# ein nicht erreichbarer Anbieter muss als Fehler sichtbar sein.
raise OidcFehler(
"anbieter_nicht_erreichbar",
f"Anmeldeanbieter nicht erreichbar ({url}): {fehler}",
) from fehler
def metadaten(provider: OidcProvider, erzwingen: bool = False) -> dict[str, Any]:
"""Discovery-Dokument des Anbieters (gepuffert)."""
eintrag = _metadaten_cache.get(provider.slug)
if not erzwingen and eintrag and eintrag[0] > time.monotonic():
return eintrag[1]
daten = _holen(provider.discovery_url)
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
if not daten.get(pflicht):
raise OidcFehler(
"discovery_unvollstaendig",
f"Discovery-Dokument von '{provider.slug}' ohne '{pflicht}'.",
)
_metadaten_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, daten)
return daten
def _jwks(provider: OidcProvider, erzwingen: bool = False) -> Any:
eintrag = _jwks_cache.get(provider.slug)
if not erzwingen and eintrag and eintrag[0] > time.monotonic():
return eintrag[1]
schluessel = JsonWebKey.import_key_set(_holen(metadaten(provider)["jwks_uri"]))
_jwks_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, schluessel)
return schluessel
def _client(provider: OidcProvider, redirect_uri: str) -> OAuth2Client:
return OAuth2Client(
client_id=provider.client_id,
# Die App ist ein oeffentlicher Client und schickt nie ein Secret; nur der
# vertrauliche Web-Client hat eins.
client_secret=provider.client_secret or None,
scope=provider.scopes,
redirect_uri=redirect_uri,
code_challenge_method="S256",
timeout=TIMEOUT_SECONDS,
)
def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: str) -> str:
"""Adresse, zu der der Browser fuer die Anmeldung geschickt wird."""
meta = metadaten(provider)
with _client(provider, provider.redirect_uri) as client:
url, _ = client.create_authorization_url(
meta["authorization_endpoint"],
state=state,
nonce=nonce,
code_verifier=code_verifier,
)
return url
def code_einloesen(provider: OidcProvider, code: str, code_verifier: str) -> dict[str, Any]:
"""Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client)."""
meta = metadaten(provider)
try:
with _client(provider, provider.redirect_uri) as client:
return client.fetch_token(
meta["token_endpoint"],
grant_type="authorization_code",
code=code,
code_verifier=code_verifier,
redirect_uri=provider.redirect_uri,
)
except OidcFehler:
raise
except Exception as fehler: # Authlib wirft je nach Ursache sehr Verschiedenes.
raise OidcFehler(
"code_tausch_fehlgeschlagen", f"Code-Tausch gescheitert: {fehler}"
) from fehler
def pruefe_id_token(
provider: OidcProvider,
id_token: str,
nonce: str | None = None,
erlaubte_audiences: tuple[str, ...] | None = None,
) -> dict[str, Any]:
"""Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt).
``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der
Web-Rueckweg akzeptiert nur die vertrauliche Client-ID, der App-Weg nur die
der registrierten oeffentlichen Clients.
"""
meta = metadaten(provider)
audiences = erlaubte_audiences or provider.erlaubte_audiences
if not audiences:
raise OidcFehler(
"keine_audience", f"Fuer '{provider.slug}' ist keine Client-ID hinterlegt."
)
optionen = {
"iss": {"essential": True, "value": meta["issuer"]},
"aud": {"essential": True, "values": list(audiences)},
"exp": {"essential": True},
"sub": {"essential": True},
}
params = {"nonce": nonce} if nonce else None
def _entschluesseln(schluessel: Any) -> Any:
return jwt.decode(
id_token,
key=schluessel,
claims_cls=CodeIDToken,
claims_options=optionen,
claims_params=params,
)
try:
try:
claims = _entschluesseln(_jwks(provider))
except JoseError:
# Kann ein frisch gedrehter Signaturschluessel sein – einmal neu laden.
claims = _entschluesseln(_jwks(provider, erzwingen=True))
claims.validate(leeway=UHREN_SPIELRAUM)
except JoseError as fehler:
raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler
# Doppelt gehalten: aud steht schon in den claims_options, aber ein falsch
# adressiertes Token darf unter keinen Umstaenden durchrutschen.
roh_aud = claims.get("aud")
token_aud = set(roh_aud) if isinstance(roh_aud, list) else {roh_aud}
if not token_aud & set(audiences):
raise OidcFehler("id_token_ungueltig", "ID-Token ist fuer einen anderen Client bestimmt.")
return dict(claims)
# ---------------------------------------------------------------------------
# Zuordnung zu Vorrania-Konten (ohne HTTP – direkt testbar)
# ---------------------------------------------------------------------------
_ERLAUBT_IM_NAMEN = re.compile(r"[^A-Za-z0-9._-]")
def _jetzt() -> datetime:
return datetime.now(timezone.utc)
def _namensvorschlag(claims: dict[str, Any]) -> str:
roh = (
claims.get("preferred_username")
or claims.get("nickname")
or (claims.get("email") or "").split("@")[0]
or claims.get("name")
or ""
)
sauber = _ERLAUBT_IM_NAMEN.sub("", str(roh).strip().replace(" ", "."))[:64].strip("._-")
return sauber or "sso-nutzer"
def _freier_name(db: Session, vorschlag: str) -> str:
"""Macht den Namen eindeutig, ohne je ein bestehendes Konto zu uebernehmen."""
if db.query(User).filter(User.username == vorschlag).first() is None:
return vorschlag
stamm = vorschlag[:58].rstrip("._-") or "sso-nutzer"
for n in range(2, 1000):
kandidat = f"{stamm}-{n}"
if db.query(User).filter(User.username == kandidat).first() is None:
return kandidat
raise OidcFehler("kein_freier_name", "Kein freier Benutzername zu finden.")
def _email_aus_claims(claims: dict[str, Any]) -> str | None:
roh = claims.get("email")
if not roh or not isinstance(roh, str):
return None
return roh.strip().lower() or None
def identitaet_verknuepfen(
db: Session, nutzer: User, provider: OidcProvider, claims: dict[str, Any]
) -> UserIdentity:
"""Haengt eine Anmeldedienst-Kennung an ein bestehendes, angemeldetes Konto.
Der Weg fuer alle, deren Konto keine (oder eine andere) E-Mail-Adresse hat:
Die Berechtigung kommt hier aus der laufenden Vorrania-Sitzung, nicht aus
einem Claim – deshalb braucht es weder eine bestaetigte Adresse noch einen
Abgleich ueber den Benutzernamen.
"""
subject = str(claims.get("sub") or "").strip()
if not subject:
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
vorhanden = (
db.query(UserIdentity)
.filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject)
.first()
)
if vorhanden is not None:
if vorhanden.user_id == nutzer.id:
return vorhanden # schon verknuepft – nichts zu tun
# Diese Kennung gehoert einem ANDEREN Vorrania-Konto. Umhaengen waere
# eine Uebernahme, auch wenn hier gerade jemand angemeldet ist.
raise OidcFehler(
"kennung_fremd",
"Diese Anmeldung ist bereits mit einem anderen Vorrania-Konto verknuepft.",
)
schon_dabei = (
db.query(UserIdentity)
.filter(UserIdentity.provider == provider.slug, UserIdentity.user_id == nutzer.id)
.first()
)
if schon_dabei is not None:
raise OidcFehler(
"schon_verknuepft",
f"Dieses Konto ist bereits mit '{provider.display_name}' verknuepft.",
)
identitaet = UserIdentity(
user_id=nutzer.id,
provider=provider.slug,
subject=subject,
last_login_at=_jetzt(),
)
db.add(identitaet)
# Adresse nachtragen, solange sie frei ist – dann findet auch die
# Erstverknuepfung anderer Konten spaeter das Richtige.
email = _email_aus_claims(claims)
if email and not nutzer.email:
if db.query(User).filter(func.lower(User.email) == email).first() is None:
nutzer.email = email
db.commit()
db.refresh(identitaet)
logger.info("SSO '%s': mit Konto '%s' verknuepft.", provider.slug, nutzer.username)
return identitaet
def identitaet_loesen(db: Session, nutzer: User, provider_slug: str) -> None:
"""Entfernt eine Verknuepfung – aber nie die letzte Anmeldemoeglichkeit."""
identitaet = (
db.query(UserIdentity)
.filter(UserIdentity.provider == provider_slug, UserIdentity.user_id == nutzer.id)
.first()
)
if identitaet is None:
raise OidcFehler("nicht_verknuepft", "Diese Verknuepfung besteht nicht.")
uebrige = (
db.query(UserIdentity)
.filter(UserIdentity.user_id == nutzer.id, UserIdentity.id != identitaet.id)
.count()
)
if not nutzer.password_hash and uebrige == 0:
# Ohne Passwort und ohne weitere Kennung waere das Konto danach von
# niemandem mehr erreichbar – auch nicht vom Besitzer.
raise OidcFehler(
"letzte_anmeldung",
"Das ist die einzige Anmeldemoeglichkeit dieses Kontos. Vergib zuerst "
"ein Passwort oder verknuepfe einen weiteren Anmeldedienst.",
)
db.delete(identitaet)
db.commit()
logger.info("SSO '%s': Verknuepfung von '%s' geloest.", provider_slug, nutzer.username)
def benutzer_aus_claims(
db: Session, provider: OidcProvider, claims: dict[str, Any]
) -> User:
"""Findet oder erzeugt das Vorrania-Konto zu einem geprueften ID-Token.
Reihenfolge:
1. Bekannte Identitaet (Anbieter + ``sub``) – der Normalfall ab dem zweiten Mal.
2. Erstverknuepfung ueber die **bestaetigte** E-Mail-Adresse.
3. Neues Konto, falls fuer den Anbieter erlaubt.
"""
subject = str(claims.get("sub") or "").strip()
if not subject:
raise OidcFehler("kein_subject", "ID-Token ohne 'sub'.")
email = _email_aus_claims(claims)
# Authentik liefert das als echtes bool; andere Anbieter schicken schon mal
# den String "true".
roh_bestaetigt = claims.get("email_verified")
email_bestaetigt = roh_bestaetigt is True or str(roh_bestaetigt).lower() == "true"
# 1) Bereits verknuepft.
identitaet = (
db.query(UserIdentity)
.filter(UserIdentity.provider == provider.slug, UserIdentity.subject == subject)
.first()
)
if identitaet is not None:
nutzer = db.get(User, identitaet.user_id)
if nutzer is None:
# Verwaiste Verknuepfung (Konto geloescht) – aufraeumen und neu entscheiden.
db.delete(identitaet)
db.commit()
else:
identitaet.last_login_at = _jetzt()
# Adresse nachtragen, falls das Konto noch keine hat – aber nur, wenn
# sie nicht schon einem anderen Konto gehoert (eindeutiger Index).
if email and not nutzer.email:
if db.query(User).filter(func.lower(User.email) == email).first() is None:
nutzer.email = email
db.commit()
db.refresh(nutzer)
return nutzer
# 2) Erstverknuepfung ueber die E-Mail.
if email and (email_bestaetigt or not provider.require_verified_email):
treffer = db.query(User).filter(func.lower(User.email) == email).first()
if treffer is not None:
belegt = (
db.query(UserIdentity)
.filter(
UserIdentity.provider == provider.slug,
UserIdentity.user_id == treffer.id,
)
.first()
)
if belegt is not None:
# Dasselbe Konto haengt schon an einer ANDEREN Kennung dieses
# Anbieters. Stillschweigend umhaengen waere eine Uebernahme.
raise OidcFehler(
"konto_bereits_verknuepft",
f"Das Konto '{treffer.username}' ist bereits mit einer anderen "
f"Kennung bei '{provider.slug}' verknuepft.",
)
db.add(
UserIdentity(
user_id=treffer.id,
provider=provider.slug,
subject=subject,
last_login_at=_jetzt(),
)
)
db.commit()
db.refresh(treffer)
return treffer
elif email and not email_bestaetigt:
logger.info(
"SSO '%s': E-Mail '%s' ist beim Anbieter nicht bestaetigt – keine "
"Verknuepfung mit einem bestehenden Konto.",
provider.slug,
email,
)
# 3) Neu anlegen, falls erlaubt.
if not provider.auto_create:
raise OidcFehler(
"kein_konto",
"Zu dieser Anmeldung gibt es kein Vorrania-Konto. Ein Administrator muss "
"es zuerst anlegen und die E-Mail-Adresse hinterlegen.",
)
# Die Adresse ist eindeutig indiziert. Sie kann hier trotzdem schon vergeben
# sein – naemlich wenn Schritt 2 sie wegen fehlender Bestaetigung NICHT zum
# Verknuepfen benutzen durfte. Dann bleibt das neue Konto ohne Adresse, statt
# den Login an einem Datenbankfehler scheitern zu lassen.
email_frei = email is not None and (
db.query(User).filter(func.lower(User.email) == email).first() is None
)
if email and not email_frei:
logger.info(
"SSO '%s': Adresse '%s' ist schon vergeben – neues Konto bleibt ohne Adresse.",
provider.slug,
email,
)
# Rolle kommt NIE aus dem Anbieter: sonst genuegte ein passend gesetzter Claim,
# um sich Administratorrechte zu geben.
nutzer = User(
username=_freier_name(db, _namensvorschlag(claims)),
password_hash=None,
email=email if email_frei else None,
role=Role.user,
)
db.add(nutzer)
db.flush()
db.add(
UserIdentity(
user_id=nutzer.id,
provider=provider.slug,
subject=subject,
last_login_at=_jetzt(),
)
)
db.commit()
db.refresh(nutzer)
logger.info("SSO '%s': neues Konto '%s' angelegt.", provider.slug, nutzer.username)
return nutzer