From ba8786906acbba921a2396283d8e50a1a893aa4e Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 18 Aug 2026 23:51:31 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Login-userId=20war=20nicht=20stabil=20ge?= =?UTF-8?q?gen=C3=BCber=20Gro=C3=9F-/Kleinschreibung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/auth/login mintete die JWT-userId bisher aus dem roh eingegebenen Benutzernamen. LiteDBs Standard-Collation vergleicht den eindeutigen Index auf Username aber case-insensitive - ein Login mit nur einmal abweichender Schreibweise auf einem zweiten Gerät authentifiziert erfolgreich, mintet aber eine andere userId. Da EventStore.GetCol(userId) diese direkt als Dateiname für den Server-seitigen Event-Speicher nutzt, entstanden zwei komplett getrennte Datenbestände für ein und dasselbe, aus Nutzersicht einzige Konto - Ursache dafür, dass ein zweites Gerät trotz "since=0" durchgängig 0 Ereignisse erhielt. UserStore.VerifyPassword(bool) durch Authenticate(UserEntry?) ersetzt, das bei Erfolg den kanonisch gespeicherten Nutzereintrag liefert; /api/auth/login mintet Token und userId daraus statt aus der Roheingabe. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Api.Tests/UserStoreTests.cs | 34 +++++++++++++++++++++------ LehrerApp.Api/Endpoints/Endpoints.cs | 12 +++++++--- LehrerApp.Api/UserStore.cs | 18 +++++++++++--- TODO.md | 27 +++++++++++++++++++++ 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/LehrerApp.Api.Tests/UserStoreTests.cs b/LehrerApp.Api.Tests/UserStoreTests.cs index 073d015..0928eea 100644 --- a/LehrerApp.Api.Tests/UserStoreTests.cs +++ b/LehrerApp.Api.Tests/UserStoreTests.cs @@ -12,7 +12,7 @@ public sealed class UserStoreTests var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort"); Assert.True(created); - Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort")); + Assert.NotNull(temp.Store.Authenticate("sebastian", "einSicheresPasswort")); } [Fact] @@ -27,20 +27,40 @@ public sealed class UserStoreTests } [Fact] - public void VerifyPassword_FalschesPasswort_GibtFalseZurueck() + public void Authenticate_FalschesPasswort_LiefertNull() { using var temp = new TempUserStore(); temp.Store.CreateUser("sebastian", "einSicheresPasswort"); - Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort")); + Assert.Null(temp.Store.Authenticate("sebastian", "falschesPasswort")); } [Fact] - public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck() + public void Authenticate_UnbekannterNutzer_LiefertNull() { using var temp = new TempUserStore(); - Assert.False(temp.Store.VerifyPassword("unbekannt", "irgendwas")); + Assert.Null(temp.Store.Authenticate("unbekannt", "irgendwas")); + } + + /// Regression: LiteDBs Standard-Collation vergleicht den eindeutigen Index auf Username + /// case-insensitive - ein Login mit abweichender Groß-/Kleinschreibung authentifiziert also + /// erfolgreich. Würde der Aufrufer (Endpoints./api/auth/login) daraufhin den roh eingegebenen + /// Namen statt des kanonisch gespeicherten als userId für den Server-seitigen Event-Speicher + /// verwenden, würde ein einziger andersartig getippter Login auf einem zweiten Gerät einen + /// komplett getrennten, nicht überlappenden Datenbestand erzeugen (siehe TODO 10.2.5 - genau + /// das vom Nutzer beobachtete Symptom: nur ein Konto im System, aber Client 2 erhielt trotz + /// since=0 keine Ereignisse). + [Fact] + public void Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen() + { + using var temp = new TempUserStore(); + temp.Store.CreateUser("sebastian", "einSicheresPasswort"); + + var user = temp.Store.Authenticate("Sebastian", "einSicheresPasswort"); + + Assert.NotNull(user); + Assert.Equal("sebastian", user!.Username); } [Fact] @@ -52,8 +72,8 @@ public sealed class UserStoreTests var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123"); Assert.True(updated); - Assert.True(temp.Store.VerifyPassword("sebastian", "neuesPasswort123")); - Assert.False(temp.Store.VerifyPassword("sebastian", "altesPasswort123")); + Assert.NotNull(temp.Store.Authenticate("sebastian", "neuesPasswort123")); + Assert.Null(temp.Store.Authenticate("sebastian", "altesPasswort123")); } [Fact] diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index 51e5e48..f4f8303 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -19,9 +19,15 @@ public static class Endpoints { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) return Results.Unauthorized(); - if (!store.VerifyPassword(req.Username, req.Password)) - return Results.Unauthorized(); - return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); + var user = store.Authenticate(req.Username, req.Password); + // Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!) + // wird als JWT-userId verwendet - LiteDBs case-insensitive Standard-Collation lässt + // einen Login mit abweichender Groß-/Kleinschreibung erfolgreich durch; würde man + // stattdessen req.Username übernehmen, würde jede andersartig getippte Anmeldung einen + // eigenen, komplett getrennten Server-seitigen Event-Speicher erzeugen (siehe + // UserStore.Authenticate). + if (user is null) return Results.Unauthorized(); + return Results.Ok(new { token = Jwt(user.Username, secret), userId = user.Username }); }).RequireRateLimiting("login"); } diff --git a/LehrerApp.Api/UserStore.cs b/LehrerApp.Api/UserStore.cs index fa45920..8acc3ff 100644 --- a/LehrerApp.Api/UserStore.cs +++ b/LehrerApp.Api/UserStore.cs @@ -32,16 +32,28 @@ public class UserStore(string dataPath) : IDisposable return true; } - public bool VerifyPassword(string username, string password) + /// + /// Prüft die Anmeldedaten und liefert bei Erfolg den GESPEICHERTEN Nutzereintrag zurück - + /// nicht bloß true/false. Wichtig: LiteDBs Standard-Collation vergleicht Strings (und damit + /// auch den eindeutigen Index auf Username) standardmäßig case-insensitive, d.h. ein Login mit + /// abweichender Groß-/Kleinschreibung authentifiziert erfolgreich. Würde der Aufrufer daraufhin + /// den roh eingegebenen Benutzernamen als userId für den Server-seitigen Event-Speicher + /// verwenden (siehe EventStore.GetCol), würde ein einziger andersartig getippter Login auf + /// einem zweiten Gerät einen komplett getrennten, nicht überlappenden Datenbestand erzeugen, + /// obwohl es sich aus Nutzersicht um dasselbe Konto handelt. Der kanonisch gespeicherte Name + /// aus diesem Rückgabewert stellt sicher, dass die userId unabhängig von der beim Login + /// eingegebenen Schreibweise stets identisch ist. + /// + public UserEntry? Authenticate(string username, string password) { var user = Col.FindOne(x => x.Username == username); - return user is not null && PasswordHasher.Verify(password, user.PasswordHash); + return user is not null && PasswordHasher.Verify(password, user.PasswordHash) ? user : null; } public void Dispose() => _db.Dispose(); } -internal class UserEntry +public class UserEntry { public ObjectId Id { get; set; } = ObjectId.NewObjectId(); public string Username { get; set; } = ""; diff --git a/TODO.md b/TODO.md index c9fb8c7..65caaa3 100644 --- a/TODO.md +++ b/TODO.md @@ -1744,6 +1744,33 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba **Merke:** bei Git-basierten Deploy-Plattformen (Dokploy & vergleichbare) niemals Bind-Mounts relativ zum Checkout-Verzeichnis für persistente Daten verwenden — nur Named Volumes oder ein Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher. +- [x] **10.2.5** Login-`userId` war nicht stabil gegenüber Groß-/Kleinschreibung + (Nutzer-Bug-Report nach 10.1.10: "nur ein Nutzer im System", trotzdem bekam Client 2 auch + mit `since=0` durchgängig 0 Ereignisse zurück). + + **Ursache:** `/api/auth/login` mintete den JWT-`userId` bisher aus dem roh eingegebenen + `req.Username`. LiteDBs Standard-Collation vergleicht Strings (und damit auch den + eindeutigen Index auf `Username` in `UserStore`) aber standardmäßig case-insensitive — durch + einen Test bestätigt: `VerifyPassword("Sebastian", ...)` authentifiziert erfolgreich gegen + ein als `"sebastian"` angelegtes Konto. Loggt sich ein zweites Gerät mit nur EINMAL anders + getippter Groß-/Kleinschreibung ein, meldet der Login trotzdem Erfolg, mintet aber eine + ANDERE `userId` — und `EventStore.GetCol(userId)` verwendet diese direkt als Dateiname für + den Server-seitigen Event-Speicher. Ergebnis: zwei komplett getrennte, nie überlappende + Datenbestände für ein und dasselbe, aus Nutzersicht einzige Konto — Push von Gerät 1 landete + unter `"Sebastian"`, Pull von Gerät 2 fragte unter `"sebastian"` nach und fand nichts, exakt + das beobachtete Symptom. + + **Fix:** `UserStore.VerifyPassword(username, password): bool` ersetzt durch + `Authenticate(username, password): UserEntry?`, das bei Erfolg den KANONISCH gespeicherten + Nutzereintrag zurückgibt statt nur `true`. `/api/auth/login` mintet Token und `userId` jetzt + aus `user.Username` (dem gespeicherten Namen), nicht mehr aus `req.Username` — die + Groß-/Kleinschreibung beim Login hat damit keinen Einfluss mehr auf die serverseitige + Datenablage. Neuer Regressionstest + `UserStoreTests.Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen`. + **Betroffene Geräte:** einmal neu anmelden (Logout/Login in den Sync-Einstellungen) reicht, + damit beide Geräte fortan dieselbe `userId` verwenden — zusätzlich auf dem zuvor + "abgehängten" Gerät einmal "Vollständigen Sync erzwingen" (10.1.10) nutzen, damit es den + unter der korrekten `userId` bereits vorhandenen Bestand nachlädt. ### 10.3 Verschlüsselung - [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).