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>
This commit is contained in:
Scarriffle
2026-09-09 22:28:38 +02:00
co-authored by Claude Opus 5
parent a134b2aad5
commit eb29143f97
9 changed files with 609 additions and 31 deletions
+108 -1
View File
@@ -11,16 +11,22 @@ from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.models import Role, User, UserIdentity
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"
@@ -431,3 +437,104 @@ def test_app_client_darf_eigene_discovery_adresse_haben():
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