fix: Login-userId war nicht stabil gegenüber Groß-/Kleinschreibung

/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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:51:31 +02:00
co-authored by Claude Sonnet 5
parent e7e5faeba8
commit ba8786906a
4 changed files with 78 additions and 13 deletions
+27 -7
View File
@@ -12,7 +12,7 @@ public sealed class UserStoreTests
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort"); var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.True(created); Assert.True(created);
Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort")); Assert.NotNull(temp.Store.Authenticate("sebastian", "einSicheresPasswort"));
} }
[Fact] [Fact]
@@ -27,20 +27,40 @@ public sealed class UserStoreTests
} }
[Fact] [Fact]
public void VerifyPassword_FalschesPasswort_GibtFalseZurueck() public void Authenticate_FalschesPasswort_LiefertNull()
{ {
using var temp = new TempUserStore(); using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort"); temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort")); Assert.Null(temp.Store.Authenticate("sebastian", "falschesPasswort"));
} }
[Fact] [Fact]
public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck() public void Authenticate_UnbekannterNutzer_LiefertNull()
{ {
using var temp = new TempUserStore(); 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] [Fact]
@@ -52,8 +72,8 @@ public sealed class UserStoreTests
var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123"); var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123");
Assert.True(updated); Assert.True(updated);
Assert.True(temp.Store.VerifyPassword("sebastian", "neuesPasswort123")); Assert.NotNull(temp.Store.Authenticate("sebastian", "neuesPasswort123"));
Assert.False(temp.Store.VerifyPassword("sebastian", "altesPasswort123")); Assert.Null(temp.Store.Authenticate("sebastian", "altesPasswort123"));
} }
[Fact] [Fact]
+9 -3
View File
@@ -19,9 +19,15 @@ public static class Endpoints
{ {
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized(); return Results.Unauthorized();
if (!store.VerifyPassword(req.Username, req.Password)) var user = store.Authenticate(req.Username, req.Password);
return Results.Unauthorized(); // Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!)
return Results.Ok(new { token = Jwt(req.Username, secret), userId = 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"); }).RequireRateLimiting("login");
} }
+15 -3
View File
@@ -32,16 +32,28 @@ public class UserStore(string dataPath) : IDisposable
return true; return true;
} }
public bool VerifyPassword(string username, string password) /// <summary>
/// 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.
/// </summary>
public UserEntry? Authenticate(string username, string password)
{ {
var user = Col.FindOne(x => x.Username == username); 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(); public void Dispose() => _db.Dispose();
} }
internal class UserEntry public class UserEntry
{ {
public ObjectId Id { get; set; } = ObjectId.NewObjectId(); public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Username { get; set; } = ""; public string Username { get; set; } = "";
+27
View File
@@ -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 **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 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. 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 ### 10.3 Verschlüsselung
- [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase). - [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).