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>
541 lines
19 KiB
Python
541 lines
19 KiB
Python
"""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
|