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
+39
View File
@@ -178,6 +178,44 @@ def _darf_anlegen(p, anmeldung: oidc_service.Anmeldung) -> bool:
return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains return anmeldung.email.rsplit("@", 1)[-1] in p.allowed_domains
def _uebernimm_namen(db: Session, nutzer: User, anmeldung: oidc_service.Anmeldung) -> None:
"""Namensaenderungen beim Anbieter nachziehen.
Wichtig ist, WESSEN Name das jeweils ist:
``display_name`` ist reine Anzeige und gehoert dem Anbieter – heiratet
jemand, soll der neue Name auch hier stehen.
``username`` ist das, was beim Passwort-Login eingetippt wird. Bei einem
Konto MIT Passwort gehoert er dem Nutzer; ihn hinter dessen Ruecken zu
aendern hiesse, ihm seine Anmeldung wegzunehmen. Bei einem reinen
SSO-Konto gibt es nichts einzutippen – dort ist er ein Etikett, und der
Anbieter darf es bestimmen.
Dass das ueberhaupt geht, liegt daran, dass der Name NIRGENDS ein
Schluessel ist: Buchungen, Konten und Sitzungen haengen an der Nummer des
Nutzers. Umbenennen zieht nichts mit.
Ist der neue Name schon vergeben, bleibt der alte stehen. Eine Anmeldung
daran scheitern zu lassen waere die schlechtere Antwort.
"""
if anmeldung.name and anmeldung.name[:120] != nutzer.display_name:
nutzer.display_name = anmeldung.name[:120]
gewuenscht = (anmeldung.preferred_username or "").strip()[:64]
if not gewuenscht or gewuenscht == nutzer.username:
return
if nutzer.password_hash is not None:
return # gehoert dem Nutzer, nicht dem Anbieter
belegt = (
db.query(User.id)
.filter(User.username == gewuenscht, User.id != nutzer.id)
.first()
)
if belegt is None:
nutzer.username = gewuenscht
def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User: def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> User:
"""Zu wem gehoert dieser Anmeldende? """Zu wem gehoert dieser Anmeldende?
@@ -205,6 +243,7 @@ def _finde_oder_lege_an(db: Session, p, anmeldung: oidc_service.Anmeldung) -> Us
nutzer = db.get(User, vorhanden.user_id) nutzer = db.get(User, vorhanden.user_id)
if nutzer is None: if nutzer is None:
raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.") raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.")
_uebernimm_namen(db, nutzer, anmeldung)
return nutzer return nutzer
if not _darf_anlegen(p, anmeldung): if not _darf_anlegen(p, anmeldung):
+17
View File
@@ -49,6 +49,23 @@ def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None: if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
if payload.username is not None:
neu = payload.username.strip()
if not neu:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
"Benutzername darf nicht leer sein")
# Der Name ist ein Etikett, kein Schluessel – umbenennen zieht nichts
# mit. Eindeutig bleiben muss er trotzdem: Er ist das, was beim
# Passwort-Login eingetippt wird.
belegt = (
db.query(User.id)
.filter(User.username == neu, User.id != user.id)
.first()
)
if belegt is not None:
raise HTTPException(status.HTTP_409_CONFLICT,
"Benutzername existiert bereits")
user.username = neu
if payload.password is not None: if payload.password is not None:
policy_service.assert_ok(db, payload.password) policy_service.assert_ok(db, payload.password)
user.password_hash = hash_password(payload.password) user.password_hash = hash_password(payload.password)
+5
View File
@@ -57,6 +57,11 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
# Umbenennen ist erlaubt: Der Benutzername ist ein ETIKETT, kein Schluessel.
# Alles Fachliche haengt an der Nummer des Nutzers – aendert sich der Name
# beim Anmeldedienst, muss Certain folgen koennen, ohne dass Daten
# umziehen.
username: str | None = Field(default=None, min_length=1, max_length=64)
password: str | None = Field(default=None, min_length=1, max_length=1024) password: str | None = Field(default=None, min_length=1, max_length=1024)
role: Role | None = None role: Role | None = None
display_name: str | None = Field(default=None, max_length=120) display_name: str | None = Field(default=None, max_length=120)
+142
View File
@@ -483,3 +483,145 @@ def test_unerreichbarer_anbieter_zeigt_keine_json_seite(client, anbieter, monkey
assert ziel.startswith("/login"), ziel assert ziel.startswith("/login"), ziel
assert "oidc_error" in ziel assert "oidc_error" in ziel
assert "Name+or+service" in ziel or "Name%20or%20service" 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()