Files
ScarriffleandClaude Opus 5 eb29143f97 Erstanmeldung fragt nach, statt still ein zweites Konto anzulegen
Wer sich zum ersten Mal ueber den Anmeldedienst anmeldet und hier noch kein
Konto hat, bekam bisher wortlos ein neues - mit Rolle "Benutzer" und ohne
Verlauf. Wer schon ein Konto hatte, wollte aber fast immer genau das verknuepfen
und stand dann mit zwei Konten da.

Jetzt zeigt der Rueckweg eine Entscheidungsseite: bestehendes Konto verknuepfen
oder ein neues anlegen. Das Verknuepfen verlangt einmalig Benutzername und
Passwort des bestehenden Kontos. Genau dieser Nachweis ist der Grund, warum der
Anmeldeweg selbst nie fragen darf, welches Konto gemeint ist - ohne ihn koennte
sich jeder mit einem beliebigen Konto beim Anmeldedienst an ein fremdes
Vorrania-Konto haengen.

Die geprueften Angaben liegen dafuer kurzlebig und einmalig in
oidc_pending_links; vom Code steht dort nur der SHA-256-Hash, wie bei den
API-Tokens. Ein falsch getipptes Passwort verbraucht den Vorgang nicht, und
unbekannter Benutzername und falsches Passwort antworten gleich - sonst liessen
sich vorhandene Konten abzaehlen.

AUTO_CREATE=false blendet den Knopf zum Anlegen aus und laesst sich auch ueber
den Dialog nicht umgehen. Der App-Weg bleibt unveraendert, weil er keinen
Zwischenschritt anzeigen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:28:38 +02:00

541 lines
19 KiB
Python
Raw Permalink 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.
"""Single Sign-on: Konfiguration lesen und Konten zuordnen.
Beides laeuft ohne HTTP – die Anbieter-Konfiguration ist eine reine Funktion
ueber einem Umgebungs-Woerterbuch, und die Zuordnung bekommt ein fertiges
Claims-Woerterbuch, wie es nach der Token-Pruefung vorliegt.
"""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.models import OidcPendingLink, Role, User, UserIdentity
from app.oidc_config import OidcProvider, provider_aus_umgebung
from app.routers.auth import login
from app.services import oidc_state
from app.security import hash_password, verify_password
from app.services.oidc import (
OFFEN_GUELTIG,
OidcFehler,
benutzer_aus_claims,
identitaet_loesen,
identitaet_verknuepfen,
konto_suchen,
offen_anlegen,
offen_lesen,
offen_neues_konto,
offen_verknuepfen,
)
BASIS = "https://vorrania.example.com"
VOLLE_UMGEBUNG = {
"OIDC_PROVIDERS": "authentik",
"OIDC_AUTHENTIK_DISPLAY_NAME": "Authentik",
"OIDC_AUTHENTIK_DISCOVERY_URL": "https://idp.example.com/application/o/v/.well-known/openid-configuration",
"OIDC_AUTHENTIK_CLIENT_ID": "web-client",
"OIDC_AUTHENTIK_CLIENT_SECRET": "geheim",
"OIDC_AUTHENTIK_NATIVE_CLIENT_IDS": "ios-client, ipad-client",
}
@pytest.fixture
def provider() -> OidcProvider:
return OidcProvider(
slug="authentik",
display_name="Authentik",
discovery_url="https://idp.example.com/.well-known/openid-configuration",
client_id="web-client",
client_secret="geheim",
native_client_ids=("ios-client",),
redirect_uri=f"{BASIS}/api/auth/oidc/authentik/callback",
)
def claims(**felder) -> dict:
grund = {"sub": "abc-123", "email": "eva@example.com", "email_verified": True}
grund.update(felder)
return grund
# ---- Konfiguration --------------------------------------------------------
def test_ohne_konfiguration_kein_anbieter():
"""Ohne OIDC_PROVIDERS bleibt alles beim Passwort-Login.
Wichtig, weil update.sh bestehende .env-Dateien nicht nachtraegt – ein alter
Bestand darf durch das Update nicht ploetzlich beim Start scheitern.
"""
assert provider_aus_umgebung({}, BASIS) == []
def test_anbieter_wird_vollstaendig_gelesen():
(p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS)
assert p.slug == "authentik"
assert p.display_name == "Authentik"
assert p.client_id == "web-client"
assert p.client_secret == "geheim"
assert p.native_client_ids == ("ios-client", "ipad-client")
# Das /api-Praefix entsteht erst in nginx und muss deshalb hier drinstehen.
assert p.redirect_uri == f"{BASIS}/api/auth/oidc/authentik/callback"
# Der Browser-Weg braucht kein Refresh-Token – offline_access dort anzufragen
# loeste nur einen ueberfluessigen Zustimmungsdialog aus.
assert p.scopes == "openid profile email"
# Die App dagegen erneuert selbst und braucht es zwingend: ohne diesen Scope
# liefert Authentik seit 2024.2 gar kein Refresh-Token.
assert "offline_access" in p.native_scopes
assert p.erlaubte_audiences == ("web-client", "ios-client", "ipad-client")
def test_zweiter_anbieter_ist_reine_konfiguration():
"""Ein weiterer Anbieter darf keine Codeaenderung brauchen."""
umgebung = dict(VOLLE_UMGEBUNG)
umgebung["OIDC_PROVIDERS"] = "authentik,keycloak"
umgebung["OIDC_KEYCLOAK_DISCOVERY_URL"] = "https://kc.example.com/.well-known/openid-configuration"
umgebung["OIDC_KEYCLOAK_CLIENT_ID"] = "kc-web"
anbieter = provider_aus_umgebung(umgebung, BASIS)
assert [p.slug for p in anbieter] == ["authentik", "keycloak"]
assert anbieter[1].display_name == "Keycloak" # Vorgabe aus dem Kuerzel
def test_unvollstaendiger_anbieter_wird_uebersprungen():
"""Ein Tippfehler in der .env darf den Serverstart nicht verhindern."""
umgebung = {"OIDC_PROVIDERS": "authentik"} # weder Discovery noch Client-ID
assert provider_aus_umgebung(umgebung, BASIS) == []
def test_ohne_basisadresse_kein_anbieter():
"""Ohne PUBLIC_BASE_URL laesst sich die Rueckleit-Adresse nicht bilden."""
assert provider_aus_umgebung(VOLLE_UMGEBUNG, "") == []
def test_ausdrueckliche_rueckleitadresse_ersetzt_die_basisadresse():
umgebung = dict(VOLLE_UMGEBUNG)
umgebung["OIDC_AUTHENTIK_REDIRECT_URI"] = "https://anders.example.com/api/auth/oidc/authentik/callback"
(p,) = provider_aus_umgebung(umgebung, "")
assert p.redirect_uri == "https://anders.example.com/api/auth/oidc/authentik/callback"
# ---- Zuordnung ------------------------------------------------------------
def test_bekannte_identitaet_meldet_denselben_nutzer_an(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.flush()
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
db.commit()
gefunden = benutzer_aus_claims(db, provider, claims())
assert gefunden.id == eva.id
assert gefunden.role == Role.admin # Rolle bleibt, was der Admin gesetzt hat
assert db.query(User).count() == 1
def test_erstverknuepfung_ueber_bestaetigte_email(db, provider):
"""Der Regelfall beim Umstieg: Admin traegt seine Adresse ein, meldet sich an."""
eva = User(
username="eva",
password_hash=hash_password("geheim123"),
email="eva@example.com",
role=Role.admin,
)
db.add(eva)
db.commit()
gefunden = benutzer_aus_claims(db, provider, claims())
assert gefunden.id == eva.id
assert gefunden.role == Role.admin
assert db.query(User).count() == 1
# Ab jetzt zaehlt die sub, nicht mehr die Adresse.
identitaet = db.query(UserIdentity).one()
assert (identitaet.provider, identitaet.subject) == ("authentik", "abc-123")
def test_unbestaetigte_email_verknuepft_nicht(db, provider):
"""Sonst genuegt eine frei gewaehlte Adresse im IdP zur Kontouebernahme."""
db.add(
User(
username="eva",
password_hash=hash_password("geheim123"),
email="eva@example.com",
role=Role.admin,
)
)
db.commit()
gefunden = benutzer_aus_claims(db, provider, claims(email_verified=False))
assert gefunden.username != "eva"
assert gefunden.role == Role.user
assert db.query(User).count() == 2
# Die Adresse gehoert weiterhin allein dem Bestandskonto.
assert gefunden.email is None
def test_ohne_auto_anlage_wird_abgewiesen(db, provider):
from dataclasses import replace
ohne = replace(provider, auto_create=False)
with pytest.raises(OidcFehler) as fehler:
benutzer_aus_claims(db, ohne, claims())
assert fehler.value.schluessel == "kein_konto"
assert db.query(User).count() == 0
def test_auto_anlage_erzeugt_konto_ohne_passwort(db, provider):
neu = benutzer_aus_claims(db, provider, claims(preferred_username="eva.mueller"))
assert neu.username == "eva.mueller"
assert neu.email == "eva@example.com"
# Rolle kommt NIE aus dem Anbieter.
assert neu.role == Role.user
# Reines SSO-Konto: kein Passwort, also auch kein Passwort-Login moeglich.
assert neu.password_hash is None
def test_namenskollision_uebernimmt_kein_fremdes_konto(db, provider):
"""Ein passend gewaehlter preferred_username darf kein Konto kapern."""
fremd = User(username="admin", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(fremd)
db.commit()
neu = benutzer_aus_claims(db, provider, claims(preferred_username="admin"))
assert neu.id != fremd.id
assert neu.username == "admin-2"
assert neu.role == Role.user
# Das bestehende Admin-Konto bleibt unangetastet.
db.refresh(fremd)
assert fremd.role == Role.admin
assert fremd.password_hash is not None
def test_zweite_kennung_auf_verknuepftes_konto_wird_abgelehnt(db, provider):
eva = User(username="eva", password_hash=None, email="eva@example.com", role=Role.user)
db.add(eva)
db.flush()
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999"))
db.commit()
with pytest.raises(OidcFehler) as fehler:
benutzer_aus_claims(db, provider, claims(sub="neu-111"))
assert fehler.value.schluessel == "konto_bereits_verknuepft"
def test_sso_konto_ist_per_passwort_nicht_anmeldbar():
"""Ohne Hash muss die Pruefung schlicht False sagen – kein AttributeError.
Sonst antwortet POST /auth/login mit 500 statt mit einem ehrlichen 401.
"""
assert verify_password("irgendwas", None) is False
assert verify_password("irgendwas", "") is False
# ---- Merkzettel fuer den Browser-Weg ---------------------------------------
def test_zettel_gilt_genau_einmal(db):
"""Ein zweiter Rueckweg mit demselben state muss ins Leere laufen."""
zettel = oidc_state.anlegen(db, "authentik")
erster = oidc_state.einloesen(db, zettel.state)
assert erster is not None
assert erster.provider == "authentik"
assert len(erster.code_verifier) >= 43 # RFC 7636
assert oidc_state.einloesen(db, zettel.state) is None
def test_abgelaufener_zettel_wird_abgelehnt(db):
zettel = oidc_state.anlegen(db, "authentik")
spaeter = datetime.now(timezone.utc) + oidc_state.GUELTIG + timedelta(seconds=1)
assert oidc_state.einloesen(db, zettel.state, jetzt=spaeter) is None
def test_unbekannter_zettel_wird_abgelehnt(db):
assert oidc_state.einloesen(db, "nie-vergeben") is None
def test_aufraeumen_entfernt_nur_alte_zettel(db):
alt = oidc_state.anlegen(db, "authentik")
neu = oidc_state.anlegen(db, "authentik")
# Den einen kuenstlich altern lassen.
alt.created_at = datetime.now(timezone.utc) - oidc_state.GUELTIG - timedelta(minutes=1)
db.commit()
assert oidc_state.aufraeumen(db) == 1
assert oidc_state.einloesen(db, neu.state) is not None
def test_zettel_tragen_unterschiedliche_werte(db):
a = oidc_state.anlegen(db, "authentik")
b = oidc_state.anlegen(db, "authentik")
assert a.state != b.state
assert a.nonce != b.nonce
assert a.code_verifier != b.code_verifier
# ---- Passwort-Login gegen ein reines SSO-Konto -----------------------------
def test_passwort_login_gegen_sso_konto_gibt_401(db):
"""Kein 500: das Konto hat keinen Hash, das ist ein Anmeldefehler."""
db.add(User(username="eva", password_hash=None, role=Role.user))
db.commit()
with pytest.raises(HTTPException) as fehler:
login(form=SimpleNamespace(username="eva", password="beliebig"), db=db)
assert fehler.value.status_code == 401
def test_sehr_langer_name_passt_in_die_spalte(db, provider):
"""username ist String(64) – ein langer preferred_username darf nicht sprengen."""
neu = benutzer_aus_claims(db, provider, claims(preferred_username="e" * 200))
assert len(neu.username) <= 64
# ---- Verknuepfung eines bestehenden Kontos ---------------------------------
def test_verknuepfung_haengt_kennung_an_das_angemeldete_konto(db, provider):
"""Der Weg fuer Konten ohne (passende) E-Mail: Berechtigung aus der Sitzung."""
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
identitaet = identitaet_verknuepfen(db, eva, provider, claims(email=None))
assert identitaet.user_id == eva.id
assert identitaet.subject == "abc-123"
# Ab jetzt meldet dieselbe Kennung genau dieses Konto an – mit seiner Rolle.
gefunden = benutzer_aus_claims(db, provider, claims())
assert gefunden.id == eva.id
assert gefunden.role == Role.admin
def test_verknuepfung_braucht_keine_bestaetigte_email(db, provider):
"""Anders als die Erstverknuepfung: hier buergt die laufende Sitzung."""
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
identitaet_verknuepfen(db, eva, provider, claims(email_verified=False))
assert db.query(UserIdentity).count() == 1
def test_verknuepfung_zweimal_ist_folgenlos(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
db.add(eva)
db.commit()
erste = identitaet_verknuepfen(db, eva, provider, claims())
zweite = identitaet_verknuepfen(db, eva, provider, claims())
assert erste.id == zweite.id
assert db.query(UserIdentity).count() == 1
def test_fremde_kennung_wird_nicht_umgehaengt(db, provider):
"""Auch eine gueltige Sitzung darf keine fremde Kennung an sich ziehen."""
otto = User(username="otto", password_hash=hash_password("geheim123"), role=Role.user)
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add_all([otto, eva])
db.flush()
db.add(UserIdentity(user_id=otto.id, provider="authentik", subject="abc-123"))
db.commit()
with pytest.raises(OidcFehler) as fehler:
identitaet_verknuepfen(db, eva, provider, claims())
assert fehler.value.schluessel == "kennung_fremd"
assert db.query(UserIdentity).one().user_id == otto.id
def test_zweite_kennung_desselben_dienstes_wird_abgelehnt(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
db.add(eva)
db.flush()
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="alt-999"))
db.commit()
with pytest.raises(OidcFehler) as fehler:
identitaet_verknuepfen(db, eva, provider, claims(sub="neu-111"))
assert fehler.value.schluessel == "schon_verknuepft"
def test_loesen_entfernt_die_verknuepfung(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
db.add(eva)
db.flush()
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
db.commit()
identitaet_loesen(db, eva, "authentik")
assert db.query(UserIdentity).count() == 0
def test_letzte_anmeldemoeglichkeit_bleibt_bestehen(db, provider):
"""Ein reines SSO-Konto darf sich nicht selbst aussperren."""
eva = User(username="eva", password_hash=None, role=Role.user)
db.add(eva)
db.flush()
db.add(UserIdentity(user_id=eva.id, provider="authentik", subject="abc-123"))
db.commit()
with pytest.raises(OidcFehler) as fehler:
identitaet_loesen(db, eva, "authentik")
assert fehler.value.schluessel == "letzte_anmeldung"
assert db.query(UserIdentity).count() == 1
def test_zettel_merkt_sich_das_zu_verknuepfende_konto(db):
"""Nur so weiss der Rueckweg, dass er verknuepfen und nicht anmelden soll."""
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.user)
db.add(eva)
db.commit()
zettel = oidc_state.anlegen(db, "authentik", link_user_id=eva.id)
eingeloest = oidc_state.einloesen(db, zettel.state)
assert eingeloest.link_user_id == eva.id
# Eine normale Anmeldung traegt das Feld nicht.
assert oidc_state.einloesen(db, oidc_state.anlegen(db, "authentik").state).link_user_id is None
def test_app_client_darf_eigene_discovery_adresse_haben():
"""In Authentik hat jede Anwendung standardmaessig einen eigenen Issuer.
Web- und App-Client brauchen dort zwei getrennte Anwendungen – ohne eigene
Discovery-Adresse wuerde die iss-Pruefung des App-Tokens fehlschlagen.
"""
umgebung = dict(VOLLE_UMGEBUNG)
umgebung["OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL"] = (
"https://idp.example.com/application/o/vorrania-ios/.well-known/openid-configuration"
)
(p,) = provider_aus_umgebung(umgebung, BASIS)
assert p.app_discovery_url == umgebung["OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL"]
assert p.discovery_url != p.app_discovery_url
def test_ohne_eigene_adresse_teilen_sich_beide_wege_die_discovery():
(p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS)
assert p.app_discovery_url == p.discovery_url
# ---- Erstanmeldung ohne passendes Konto ------------------------------------
def test_unbekannte_kennung_legt_nichts_an(db, provider):
"""konto_suchen darf NIE ein Konto erzeugen – sonst gibt es nichts zu fragen."""
assert konto_suchen(db, provider, claims()) is None
assert db.query(User).count() == 0
assert db.query(UserIdentity).count() == 0
def test_offener_merkzettel_haelt_die_geprueften_angaben(db, provider):
code = offen_anlegen(db, provider, claims(preferred_username="eva.mueller"))
eintrag = offen_lesen(db, code)
assert eintrag.subject == "abc-123"
assert eintrag.email == "eva@example.com"
assert eintrag.email_verified is True
assert eintrag.username_vorschlag == "eva.mueller"
# Der Klartext-Code darf nirgends liegen, nur sein Hash.
assert code not in (eintrag.code_hash, eintrag.subject)
def test_offener_merkzettel_laeuft_ab(db, provider):
code = offen_anlegen(db, provider, claims())
spaeter = datetime.now(timezone.utc) + OFFEN_GUELTIG + timedelta(seconds=1)
assert offen_lesen(db, code, jetzt=spaeter) is None
def test_falscher_code_findet_nichts(db, provider):
offen_anlegen(db, provider, claims())
assert offen_lesen(db, "ausgedacht") is None
def test_verknuepfen_braucht_das_richtige_passwort(db, provider):
"""Das Passwort ist der Eigentumsnachweis – ohne ihn waere der Dialog eine Luecke."""
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_verknuepfen(db, provider, eintrag, "eva", "falsch")
assert fehler.value.schluessel == "anmeldung_falsch"
assert db.query(UserIdentity).count() == 0
# Der Merkzettel bleibt: ein Tippfehler soll nicht den Vorgang wegwerfen.
assert db.query(OidcPendingLink).count() == 1
def test_unbekannter_benutzername_verraet_sich_nicht(db, provider):
"""Gleiche Meldung wie bei falschem Passwort – sonst zaehlt man Konten ab."""
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_verknuepfen(db, provider, eintrag, "gibtsnicht", "beliebig")
assert fehler.value.schluessel == "anmeldung_falsch"
def test_verknuepfen_haengt_kennung_an_das_bestaetigte_konto(db, provider):
eva = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(eva)
db.commit()
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims()))
nutzer = offen_verknuepfen(db, provider, eintrag, "eva", "geheim123")
assert nutzer.id == eva.id
assert nutzer.role == Role.admin # Rolle bleibt erhalten
assert db.query(User).count() == 1 # kein Zweitkonto
assert db.query(OidcPendingLink).count() == 0 # verbraucht
# Ab jetzt meldet dieselbe Kennung direkt an.
assert konto_suchen(db, provider, claims()).id == eva.id
def test_neues_konto_auf_wunsch(db, provider):
eintrag = offen_lesen(db, offen_anlegen(db, provider, claims(preferred_username="eva")))
neu = offen_neues_konto(db, provider, eintrag)
assert neu.username == "eva"
assert neu.role == Role.user
assert neu.password_hash is None
assert db.query(OidcPendingLink).count() == 0
def test_ohne_auto_anlage_bleibt_nur_verknuepfen(db, provider):
"""AUTO_CREATE=false darf sich nicht ueber den Dialog umgehen lassen."""
from dataclasses import replace
ohne = replace(provider, auto_create=False)
eintrag = offen_lesen(db, offen_anlegen(db, ohne, claims()))
with pytest.raises(OidcFehler) as fehler:
offen_neues_konto(db, ohne, eintrag)
assert fehler.value.schluessel == "anlegen_nicht_erlaubt"
assert db.query(User).count() == 0