feat: Anmelden ueber Authentik – zusaetzlich zu Benutzername und Passwort

Ein zweiter Weg herein, kein Ersatz. Wer weiter Passwort und Zwei-Faktor
benutzen will, merkt nichts: Ohne eingerichteten Anbieter sieht die
Login-Seite aus wie bisher.

DER WEG FUER BESTEHENDE KONTEN ist das Verknuepfen, nicht das Anmelden: einmal
wie gewohnt hereinkommen, unter "Mein Konto" auf "Mit Authentik verknuepfen",
fertig. Danach geht beides.

Was es ausdruecklich NICHT gibt, ist das automatische Zusammenfuehren ueber
die E-Mail. Es waere der bequeme Weg und der gefaehrliche: Wer beim Anbieter
eine fremde Adresse eintraegt, uebernaehme damit ein fremdes Konto – und die
E-Mail ist dort die Angabe, die sich am leichtesten aendern laesst. Deshalb
sagt der Nutzer selbst, wer er beim Anbieter ist, im angemeldeten Zustand.

Aus demselben Grund legt eine unbekannte Anmeldung standardmaessig KEIN Konto
an, sondern wird mit dem Hinweis abgewiesen, sich anzumelden und zu
verknuepfen. Sonst bekaeme genau der Mensch, der schon ein Konto hat, bei
seiner ersten SSO-Anmeldung ein zweites, leeres – und seine Daten laegen
weiter unter dem alten. Wer will, schaltet AUTO_CREATE ein.

Geprueft wird mit Authlib, nicht von Hand: Signatur gegen JWKS, Aussteller,
Empfaenger, Laufzeit und nonce. Dazu state gegen untergeschobene Rueckkehr und
PKCE, obwohl der Client vertraulich ist – es kostet nichts und der Code taugt
damit auch fuer einen oeffentlichen.

Ein zweiter Anbieter ist reine Konfiguration:
    APP_OIDC_PROVIDERS=authentik,keycloak

Nebenbei zwei Stellen abgesichert, die es vorher nicht geben konnte: Ein Konto
ohne Passwort faellt beim Passwort-Login nicht mehr in argon2 (das haette
verraten, dass es diesen Benutzernamen gibt), und die letzte Verknuepfung
laesst sich nicht loesen, solange es kein Passwort gibt – sonst schliesst sich
der Nutzer aus seinem eigenen Konto aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 17:28:24 +02:00
co-authored by Claude Opus 5
parent 01ae12ffea
commit 7690718731
18 changed files with 1633 additions and 16 deletions
+268
View File
@@ -0,0 +1,268 @@
"""OpenID Connect: Anmeldung ueber einen fremden Anbieter (Authorization Code Flow).
Geprueft wird mit Authlib, nicht von Hand. Ein ID-Token selbst zu zerlegen und
seine Signatur nachzurechnen ist die Art Code, die in neunzig Prozent der
Faelle funktioniert und in den uebrigen zehn jemanden hereinlaesst, der nicht
hereingehoert.
Was hier passiert und warum:
``state`` Verknuepft die Rueckkehr mit dem Aufbruch. Ohne ihn koennte jemand
eine fremde Rueckleitung in den Browser des Nutzers schieben.
``nonce`` Steht im ID-Token und wird gegen den gemerkten Wert geprueft. Ohne
ihn liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
``PKCE`` Bindet den Code an den, der ihn angefordert hat. Bei einem
vertraulichen Client (Secret) nicht zwingend, aber kostenlos – und
der Code bleibt derselbe, falls einmal ein oeffentlicher Client
dazukommt.
Discovery und Schluessel werden kurz zwischengespeichert. Nicht laenger: Ein
Anbieter wechselt seine Signaturschluessel, und ein zu lange gehaltener
Schluesselsatz weist danach jeden ab.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from dataclasses import dataclass
import httpx
from authlib.jose import JsonWebKey, JsonWebToken
from authlib.jose.errors import JoseError
from ..oidc_config import Provider
log = logging.getLogger(__name__)
ZEITLIMIT = 15.0
# So lange gelten Discovery-Dokument und Schluesselsatz als frisch.
CACHE_SEKUNDEN = 600
# Wie viel Uhrzeit-Unterschied zwischen uns und dem Anbieter geduldet wird.
TOLERANZ_SEKUNDEN = 60
# Nur asymmetrische Verfahren. "none" waere keine Signatur, und HS256 pruefte
# gegen das Client-Secret – beides gehoert hier nicht hin.
ERLAUBTE_ALGORITHMEN = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"]
class OidcFehler(RuntimeError):
"""Fehler mit einem Grund, den man dem Nutzer zeigen kann."""
@dataclass
class Anmeldung:
"""Was nach erfolgreicher Pruefung ueber den Anmeldenden feststeht."""
subject: str
email: str | None
email_verified: bool
name: str | None
preferred_username: str | None
_cache: dict[str, tuple[float, dict]] = {}
_schluessel_cache: dict[str, tuple[float, object]] = {}
def _hole(url: str) -> dict:
try:
with httpx.Client(timeout=ZEITLIMIT, follow_redirects=True) as c:
antwort = c.get(url)
antwort.raise_for_status()
return antwort.json()
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def discovery(p: Provider) -> dict:
"""Das Discovery-Dokument – gecacht."""
eintrag = _cache.get(p.name)
if eintrag and eintrag[0] > time.time():
return eintrag[1]
daten = _hole(p.discovery_url)
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
if not daten.get(pflicht):
raise OidcFehler(
f"Das Discovery-Dokument von '{p.name}' hat kein '{pflicht}'. "
f"Zeigt die Adresse wirklich auf eine OpenID-Konfiguration?"
)
_cache[p.name] = (time.time() + CACHE_SEKUNDEN, daten)
return daten
def _schluesselsatz(p: Provider, jwks_uri: str, erzwinge_neu: bool = False):
eintrag = _schluessel_cache.get(p.name)
if eintrag and eintrag[0] > time.time() and not erzwinge_neu:
return eintrag[1]
satz = JsonWebKey.import_key_set(_hole(jwks_uri))
_schluessel_cache[p.name] = (time.time() + CACHE_SEKUNDEN, satz)
return satz
def cache_leeren() -> None:
_cache.clear()
_schluessel_cache.clear()
# --- Schritt 1: hinschicken --------------------------------------------------
def _pkce() -> tuple[str, str]:
"""(verifier, challenge) nach S256."""
verifier = secrets.token_urlsafe(64)
rumpf = hashlib.sha256(verifier.encode("ascii")).digest()
return verifier, base64.urlsafe_b64encode(rumpf).rstrip(b"=").decode("ascii")
def start(p: Provider, redirect_uri: str) -> dict:
"""Adresse, zu der der Nutzer geschickt wird – plus was wir uns merken muessen."""
from urllib.parse import urlencode
daten = discovery(p)
verifier, challenge = _pkce()
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
frage = {
"response_type": "code",
"client_id": p.client_id,
"redirect_uri": redirect_uri,
"scope": p.scopes,
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return {
"url": f"{daten['authorization_endpoint']}?{urlencode(frage)}",
"state": state,
"nonce": nonce,
"code_verifier": verifier,
}
# --- Schritt 2: zurueckkommen ------------------------------------------------
def _tausche_code(p: Provider, daten: dict, code: str, redirect_uri: str,
code_verifier: str) -> dict:
formular = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": p.client_id,
"code_verifier": code_verifier,
}
kopfzeilen = {"Accept": "application/json"}
# Ein oeffentlicher Client hat kein Secret und darf auch keines schicken.
auth = (p.client_id, p.client_secret) if p.client_secret else None
if auth is None:
formular.pop("client_id", None)
formular["client_id"] = p.client_id
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.post(daten["token_endpoint"], data=formular,
headers=kopfzeilen, auth=auth)
except httpx.HTTPError as e:
raise OidcFehler(f"Der Anmeldedienst ist nicht erreichbar: {e}") from e
if antwort.status_code >= 400:
grund = ""
try:
inhalt = antwort.json()
grund = inhalt.get("error_description") or inhalt.get("error") or ""
except ValueError:
pass
raise OidcFehler(
f"Der Anmeldedienst hat den Code abgelehnt{': ' + grund if grund else '.'}"
)
try:
return antwort.json()
except ValueError as e:
raise OidcFehler("Der Anmeldedienst antwortet unverstaendlich.") from e
def _pruefe_id_token(p: Provider, daten: dict, id_token: str, nonce: str) -> dict:
"""Signatur, Aussteller, Empfaenger, Laufzeit und nonce.
Bei einem unbekannten Schluessel wird der Satz EINMAL neu geholt: Anbieter
tauschen ihre Signaturschluessel aus, und ein gecachter Satz wiese danach
jeden ab, bis der Zwischenspeicher von selbst ablaeuft.
"""
anspruch = {
"iss": {"essential": True, "value": daten["issuer"]},
"aud": {"essential": True, "value": p.client_id},
"exp": {"essential": True},
}
def _lauf(erzwinge_neu: bool) -> dict:
satz = _schluesselsatz(p, daten["jwks_uri"], erzwinge_neu)
jwt = JsonWebToken(ERLAUBTE_ALGORITHMEN)
gelesen = jwt.decode(id_token, satz, claims_options=anspruch)
gelesen.validate(leeway=TOLERANZ_SEKUNDEN)
return gelesen
try:
gelesen = _lauf(False)
except (JoseError, ValueError):
try:
gelesen = _lauf(True)
except JoseError as e:
raise OidcFehler(f"Das Token des Anmeldedienstes ist ungueltig: {e}") from e
except ValueError as e:
raise OidcFehler("Das Token des Anmeldedienstes ist unlesbar.") from e
# Der nonce wird von Authlib nicht mitgeprueft – hier von Hand, sonst
# liesse sich ein anderswo erbeutetes, gueltiges Token einreichen.
if nonce and gelesen.get("nonce") != nonce:
raise OidcFehler("Die Antwort gehoert nicht zu dieser Anmeldung (nonce).")
if not gelesen.get("sub"):
raise OidcFehler("Das Token nennt keinen Nutzer (sub fehlt).")
return dict(gelesen)
def schliesse_ab(p: Provider, code: str, redirect_uri: str, code_verifier: str,
nonce: str) -> Anmeldung:
"""Code einloesen, ID-Token pruefen, und sagen wer da hereinkommt."""
daten = discovery(p)
tokens = _tausche_code(p, daten, code, redirect_uri, code_verifier)
id_token = tokens.get("id_token")
if not id_token:
raise OidcFehler(
"Der Anmeldedienst hat kein ID-Token geschickt. Fehlt der Scope "
"'openid' in der Konfiguration?"
)
anspruch = _pruefe_id_token(p, daten, id_token, nonce)
email = anspruch.get("email")
verifiziert = bool(anspruch.get("email_verified"))
# Manche Anbieter liefern die E-Mail erst am userinfo-Endpunkt.
if not email and daten.get("userinfo_endpoint") and tokens.get("access_token"):
try:
with httpx.Client(timeout=ZEITLIMIT) as c:
antwort = c.get(
daten["userinfo_endpoint"],
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
if antwort.status_code < 400:
weiteres = antwort.json()
# Nur vom selben Nutzer – sonst mischten wir zwei Identitaeten.
if weiteres.get("sub") == anspruch["sub"]:
email = weiteres.get("email") or email
verifiziert = bool(weiteres.get("email_verified")) or verifiziert
anspruch.setdefault("name", weiteres.get("name"))
anspruch.setdefault("preferred_username",
weiteres.get("preferred_username"))
except (httpx.HTTPError, ValueError):
log.info("userinfo von '%s' nicht abrufbar – ID-Token genuegt", p.name)
return Anmeldung(
subject=str(anspruch["sub"]),
email=(email or "").strip().lower() or None,
email_verified=verifiziert,
name=anspruch.get("name"),
preferred_username=anspruch.get("preferred_username"),
)