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,466 @@
|
||||
"""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
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Kurzlebige Merkzettel fuer den Browser-Anmeldevorgang.
|
||||
|
||||
``state``, ``nonce`` und der PKCE-Verifier muessen den Ausflug zum Anmeldedienst
|
||||
ueberdauern. Sie liegen dafuer in der Datenbank statt in einem Cookie – warum,
|
||||
steht bei :class:`~app.models.OidcLoginState`.
|
||||
|
||||
Reine Datenbank-Funktionen, damit sie sich mit der vorhandenen ``db``-Fixture
|
||||
pruefen lassen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import OidcLoginState
|
||||
|
||||
# Genug fuer eine Anmeldung samt Zwei-Faktor-Eingabe, kurz genug, dass ein
|
||||
# liegengebliebener Zettel nicht ewig gilt.
|
||||
GUELTIG = timedelta(minutes=15)
|
||||
|
||||
|
||||
def _jetzt() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _mit_zeitzone(wert: datetime) -> datetime:
|
||||
# SQLite gibt Zeitstempel ohne Zonenangabe zurueck; ein Vergleich mit einem
|
||||
# bewussten Zeitpunkt wuerfe sonst einen TypeError.
|
||||
return wert if wert.tzinfo is not None else wert.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def anlegen(
|
||||
db: Session, provider: str, link_user_id: int | None = None
|
||||
) -> OidcLoginState:
|
||||
"""Erzeugt state, nonce und PKCE-Verifier und merkt sie vor.
|
||||
|
||||
``link_user_id`` macht daraus einen Verknuepfungs- statt Anmeldevorgang.
|
||||
"""
|
||||
zettel = OidcLoginState(
|
||||
state=secrets.token_urlsafe(32),
|
||||
provider=provider,
|
||||
nonce=secrets.token_urlsafe(24),
|
||||
# RFC 7636 erlaubt 43-128 Zeichen; token_urlsafe(64) liefert 86.
|
||||
code_verifier=secrets.token_urlsafe(64),
|
||||
link_user_id=link_user_id,
|
||||
created_at=_jetzt(),
|
||||
)
|
||||
db.add(zettel)
|
||||
db.commit()
|
||||
return zettel
|
||||
|
||||
|
||||
def einloesen(db: Session, state: str, jetzt: datetime | None = None) -> OidcLoginState | None:
|
||||
"""Holt den Zettel und entfernt ihn dabei – er gilt genau einmal.
|
||||
|
||||
Damit ist ein zweiter Rueckweg mit demselben ``state`` wirkungslos.
|
||||
"""
|
||||
zettel = db.get(OidcLoginState, state)
|
||||
if zettel is None:
|
||||
return None
|
||||
|
||||
# Vor dem Ablauf-Vergleich loeschen: auch ein abgelaufener Zettel ist
|
||||
# verbraucht und soll nicht liegenbleiben.
|
||||
db.delete(zettel)
|
||||
db.commit()
|
||||
|
||||
if _mit_zeitzone(zettel.created_at) + GUELTIG < (jetzt or _jetzt()):
|
||||
return None
|
||||
return zettel
|
||||
|
||||
|
||||
def aufraeumen(db: Session, jetzt: datetime | None = None) -> int:
|
||||
"""Entfernt abgelaufene Zettel (abgebrochene Anmeldungen)."""
|
||||
grenze = (jetzt or _jetzt()) - GUELTIG
|
||||
alte = [z for z in db.query(OidcLoginState).all() if _mit_zeitzone(z.created_at) < grenze]
|
||||
for zettel in alte:
|
||||
db.delete(zettel)
|
||||
if alte:
|
||||
db.commit()
|
||||
return len(alte)
|
||||
Reference in New Issue
Block a user