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
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:
"""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)
if nutzer is None:
raise oidc_service.OidcFehler("Das verknuepfte Konto gibt es nicht mehr.")
_uebernimm_namen(db, nutzer, anmeldung)
return nutzer
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)
if user is None:
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:
policy_service.assert_ok(db, payload.password)
user.password_hash = hash_password(payload.password)