feat: Ein Benutzername laesst sich aendern – er ist ein Etikett, kein Schluessel

Aendert der Anmeldedienst den Namen eines Nutzers, muss Certain folgen
koennen. Bisher ging das gar nicht: Umbenennen war nirgends vorgesehen.

Die gute Nachricht vorweg – am Fundament war nichts zu richten. Buchungen,
Konten, Vertraege, Sitzungen und Geraeteschluessel haengen alle an der NUMMER
des Nutzers; der Name ist nirgends ein Fremdschluessel, und auch das
Wiedereinspielen einer Sicherung ordnet nicht ueber ihn zu. Umbenennen zieht
also nichts mit. Ein Test haelt das jetzt fest: Nach dem Umbenennen sind es
dieselbe Nummer, dasselbe Konto und derselbe Saldo.

Zwei Wege gibt es jetzt.

Der Admin kann umbenennen (PATCH /api/users/{id}). Eindeutig bleiben muss der
Name trotzdem – er ist das, was beim Passwort-Login eingetippt wird.

Und der Anmeldedienst zieht bei jeder Anmeldung nach. Dabei zaehlt, WESSEN
Name das jeweils ist: Der Anzeigename gehoert dem Anbieter – heiratet jemand,
soll der neue Name auch hier stehen. Der Benutzername gehoert bei einem Konto
MIT Passwort dem Nutzer; ihn hinter dessen Ruecken zu aendern hiesse, ihm
seine Anmeldung wegzunehmen. Nur bei einem reinen SSO-Konto gibt es nichts
einzutippen – dort darf der Anbieter bestimmen.

Ist der neue Name schon vergeben, bleibt der alte stehen. Eine Anmeldung
daran scheitern zu lassen waere die schlechtere Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-10 06:00:52 +02:00
co-authored by Claude Opus 5
parent 62cb6a017c
commit 41db67f53e
4 changed files with 203 additions and 0 deletions
+142
View File
@@ -483,3 +483,145 @@ def test_unerreichbarer_anbieter_zeigt_keine_json_seite(client, anbieter, monkey
assert ziel.startswith("/login"), ziel
assert "oidc_error" in ziel
assert "Name+or+service" in ziel or "Name%20or%20service" in ziel
# --- Der Benutzername ist ein Etikett, kein Schluessel ------------------------
def test_umbenennen_laesst_alle_daten_stehen(admin):
"""Der Kern: Buchungen, Konten und Vertraege haengen an der NUMMER des
Nutzers. Aendert sich sein Name – etwa weil der Anmeldedienst einen neuen
schickt –, darf davon nichts mitwandern."""
username, temp = new_user(admin)
# login_ready erledigt den Pflicht-Wechsel und gibt das NEUE Passwort
# zurueck – das temporaere gilt danach nicht mehr.
nutzer, passwort = login_ready(username, temp)
konto = nutzer.post("/api/accounts", json={
"name": "Giro " + secrets.token_hex(3), "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 12345}).json()
nutzer.post("/api/transactions", json={
"type": "expense", "date": "2026-05-05", "account_id": konto["id"],
"amount_cents": 999})
ich = nutzer.get("/api/auth/me").json()
neuer_name = "umbenannt" + secrets.token_hex(4)
r = admin.patch(f"/api/users/{ich['id']}", json={"username": neuer_name})
assert r.status_code == 200, r.text
assert r.json()["username"] == neuer_name
assert r.json()["id"] == ich["id"], "Umbenennen hat einen NEUEN Nutzer erzeugt"
# Dieselbe Sitzung, dieselben Daten – nichts ist umgezogen.
assert nutzer.get("/api/auth/me").json()["username"] == neuer_name
konten = nutzer.get("/api/accounts").json()
assert [k["id"] for k in konten] == [konto["id"]]
assert konten[0]["balance_cents"] == 12345 - 999
# Und der neue Name funktioniert beim Anmelden.
nutzer.post("/api/auth/logout")
r = nutzer.post("/api/auth/login",
data={"username": neuer_name, "password": passwort})
assert r.status_code == 200, r.text
def test_umbenennen_auf_einen_vergebenen_namen_wird_abgewiesen(admin):
"""Eindeutig muss er bleiben – er ist das, was eingetippt wird."""
username, passwort = new_user(admin)
nutzer, _ = login_ready(username, passwort)
ich = nutzer.get("/api/auth/me").json()
r = admin.patch(f"/api/users/{ich['id']}", json={"username": "admin"})
assert r.status_code == 409, r.text
assert nutzer.get("/api/auth/me").json()["username"] == username
def test_anbieter_darf_ein_reines_sso_konto_umbenennen(client, anbieter, monkeypatch):
"""Dort gibt es nichts einzutippen – der Name ist ein Etikett, und der
Anbieter bestimmt es."""
from app import oidc_config
from app.database import SessionLocal
from app.models import User, UserIdentity
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
sub = "rename-" + secrets.token_hex(4)
erst = "vorher" + secrets.token_hex(3)
anbieter["claims"] = {"sub": sub, "preferred_username": erst, "name": "Vorher Nachname"}
state, nonce, _ = _beginne(client)
assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"]
ich = client.get("/api/auth/me").json()
assert ich["username"] == erst
assert ich["display_name"] == "Vorher Nachname"
client.post("/api/auth/logout")
# Beim Anbieter umbenannt – naechste Anmeldung zieht nach.
danach = "nachher" + secrets.token_hex(3)
anbieter["claims"] = {"sub": sub, "preferred_username": danach,
"name": "Nachher Nachname"}
state, nonce, _ = _beginne(client)
assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"]
jetzt = client.get("/api/auth/me").json()
assert jetzt["id"] == ich["id"], "es ist ein ZWEITES Konto entstanden"
assert jetzt["username"] == danach, "Namensaenderung nicht uebernommen"
assert jetzt["display_name"] == "Nachher Nachname"
client.post("/api/auth/logout")
with SessionLocal() as s:
eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first()
s.delete(s.get(User, eintrag.user_id))
s.commit()
def test_anbieter_ruehrt_den_namen_eines_passwort_kontos_nicht_an(admin, anbieter):
"""Dort gehoert der Name dem Nutzer. Ihn hinter dessen Ruecken zu aendern
hiesse, ihm seine Anmeldung wegzunehmen."""
sub = "eigen-" + secrets.token_hex(4)
anbieter["claims"] = {"sub": sub, "preferred_username": "ganzandersername",
"name": "Anzeigename vom Anbieter"}
state, nonce, _ = _beginne(admin, "/api/auth/oidc/authentik/link")
assert "oidc_linked" in _zurueck(admin, anbieter, state, nonce).headers["location"]
admin.post("/api/auth/logout")
state, nonce, _ = _beginne(admin)
assert "oidc_error" not in _zurueck(admin, anbieter, state, nonce).headers["location"]
ich = admin.get("/api/auth/me").json()
assert ich["username"] == "admin", "der Anbieter hat den Namen ueberschrieben"
# Der Anzeigename darf er dagegen setzen – der ist reine Anzeige.
assert ich["display_name"] == "Anzeigename vom Anbieter"
ident = admin.get("/api/auth/oidc/identities").json()["identities"][0]
admin.delete(f"/api/auth/oidc/identities/{ident['id']}")
admin.patch(f"/api/users/{ich['id']}", json={"display_name": None})
admin.post("/api/auth/login",
data={"username": "admin", "password": ADMIN_PASSWORD})
def test_vergebener_name_vom_anbieter_laesst_den_alten_stehen(client, anbieter,
monkeypatch):
"""Eine Anmeldung daran scheitern zu lassen waere die schlechtere Antwort."""
from app import oidc_config
from app.database import SessionLocal
from app.models import User, UserIdentity
monkeypatch.setenv("APP_OIDC_AUTHENTIK_AUTO_CREATE", "true")
oidc_config.zuruecksetzen()
sub = "kollision-" + secrets.token_hex(4)
eigen = "eigener" + secrets.token_hex(3)
anbieter["claims"] = {"sub": sub, "preferred_username": eigen}
state, nonce, _ = _beginne(client)
_zurueck(client, anbieter, state, nonce)
client.post("/api/auth/logout")
# Der Anbieter schickt jetzt einen Namen, den es hier schon gibt.
anbieter["claims"] = {"sub": sub, "preferred_username": "admin"}
state, nonce, _ = _beginne(client)
assert "oidc_error" not in _zurueck(client, anbieter, state, nonce).headers["location"]
assert client.get("/api/auth/me").json()["username"] == eigen, \
"fremder Name uebernommen"
client.post("/api/auth/logout")
with SessionLocal() as s:
eintrag = s.query(UserIdentity).filter(UserIdentity.subject == sub).first()
s.delete(s.get(User, eintrag.user_id))
s.commit()