10 KiB
PROJ-46: E-Mail als primärer Login-Identifier für Tenant-User
Status: In Review
Created: 2026-06-13 Last Updated: 2026-07-03
Implementation Notes (2026-07-03, Backend)
internal/userstore/userstore.go: Neue FunktionVerifyLogin(ctx, identifier, password) (*User, error). Ablauf: (1) Lookup peremail = $1(matcht alle User). (2) Falls kein Treffer, Lookup perusername = $1 AND tenant_id IS NULL— Tenant-User können sich damit NICHT mehr per Username anmelden. Danachactive-Check +bcrypt.CompareHashAndPassword. Neuer HelperscanUserWithHash(reichtpgx.ErrNoRowsunverfälscht durch, damit der Email→Username-Fallback greift).VerifyPasswordblieb unangetastet (IMAP-Pfad PROJ-26).internal/auth/auth.go:Manager.Login()ruft nunVerifyLogin(context.Background(), ...)stattVerifyPassword(...).extractDomain(identifier)musste NICHT angepasst werden: Tenant-User senden jetzt die E-Mail (user@domain) als Identifier, woraus die bestehendestrings.LastIndex(..., "@")-Logik die Domain korrekt extrahiert — für den per-Tenant-/ Global-LDAP-Fallback ist das sogar zuverlässiger als der frühere reine Username.internal/userstore/userstore_test.go:TestVerifyLogindeckt die volle Matrix ab (Tenant per E-Mail = Erfolg, Tenant per Username = Reject, Non-Tenant per Username = Erfolg, Non-Tenant per E-Mail = Erfolg, unbekannter Identifier = Reject, falsches Passwort = Reject). DB-backed (Skip ohneTEST_DATABASE_URL, wie die übrigen userstore-Tests).- Kein lokaler
go build/go testmöglich (kein Toolchain im Arbeitsverzeichnis) — Build-/Testverifikation erfolgt separat auf dem Testserver. - Frontend (
src/app/page.tsx,src/app/admin/login/page.tsx) wird separat vom Frontend-Agent umgesetzt.
Implementation Notes (2026-07-03, Frontend)
src/app/page.tsx(Tenant-User-Login): Label "Benutzername" → "E-Mail-Adresse", Inputtype="text"→type="email",autoComplete="username"→autoComplete="email", Placeholder +aria-labelentsprechend angepasst. API-Client (login()) unverändert — der eingegebene String geht weiterhin alsusername-Feld ins JSON-Body.src/app/admin/login/page.tsx(Admin/Superadmin-Login): Label + Placeholder → "Benutzername oder E-Mail-Adresse". Input bleibt bewussttype="text"(beide Formate möglich).npx tsc --noEmit: sauber durchgelaufen (Exit 0), keine Typfehler.- Kein API-Client-/Backend-Code angefasst. QA gegen Acceptance Criteria steht noch aus.
Problem Statement
Tenant-User melden sich aktuell mit username an, nicht mit ihrer E-Mail-Adresse. Das führt in der Praxis zu Verwechslungen: Ein User versucht sich mit seiner E-Mail-Adresse einzuloggen (die einzige Kennung, die er sich merkt), das Login schlägt mit "invalid_password" fehl, und nach 5 Fehlversuchen innerhalb von 15 Minuten greift das Rate-Limit (429 "too many failed login attempts") — selbst nachdem ein Admin das Passwort zurückgesetzt hat.
Konkreter Support-Fall (2026-06-13): patrick@perlbach24.de konnte sich trotz Passwort-Reset durch den Superadmin nicht einloggen, weil er patrick@perlbach24.de statt patrick (seinem tatsächlichen username) als Login-Identifier verwendete.
Dependencies
- Betrifft: PROJ-1 (Authentifizierung & Rollen), PROJ-21 (Multi-Tenancy)
- Berührt NICHT: PROJ-26 (IMAP-Server-Schnittstelle) — IMAP-Login bleibt unverändert per
username
User Stories
- Als Tenant-User möchte ich mich mit meiner E-Mail-Adresse anmelden, weil das die Kennung ist, die ich kenne und die mir in Einladungs-/Reset-Mails genannt wird.
- Als Superadmin/System-User (ohne Tenant-Zugehörigkeit) möchte ich mich weiterhin mit meinem Benutzernamen ODER meiner E-Mail-Adresse anmelden können.
- Als Tenant-Admin möchte ich, dass sich Tenant-User NICHT mehr per Benutzername einloggen können, um Verwechslungen wie im Support-Fall vom 2026-06-13 zukünftig zu vermeiden.
Acceptance Criteria
Login-Logik (Backend)
- Neue Lookup-Funktion
Store.VerifyLogin(ctx, identifier, password) (*User, error)ininternal/userstore/userstore.go - Login per E-Mail-Adresse (
email = $1) funktioniert für alle User (Tenant-User UND Nicht-Tenant-User) - Login per
usernamefunktioniert NUR für User mittenant_id IS NULL(Superadmin/System-User) - Tenant-User (
tenant_id IS NOT NULL), die ihrenusernameals Login-Identifier verwenden, erhalteninvalid_credentials(kein Login) - Bestehende
VerifyPassword(username, password)bleibt unverändert erhalten für den IMAP-Server-Login-Pfad (PROJ-26) internal/auth/auth.goManager.Login()ruftVerifyLogin()stattVerifyPassword()
Frontend
- Tenant-User-Login (
src/app/page.tsx): Label "Benutzername" → "E-Mail-Adresse", Input-Typeemail,autoComplete="email" - Admin/Superadmin-Login (
src/app/admin/login/page.tsx): Label → "Benutzername oder E-Mail-Adresse" - API-Request-Format bleibt unverändert (
{"username": "<identifier>", "password": "..."})
Rate-Limiting & Audit
login_attempts.usernameSpalte auf VARCHAR(255) erweitert (bereits erledigt, siehe Migration unten)- Rate-Limiting-Logik (
CountRecentFailures,RecordLoginAttempt) funktioniert unverändert mit E-Mail-Strings als Schlüssel - Audit-Log protokolliert bei Fehlversuchen weiterhin den eingegebenen Identifier (E-Mail oder Username)
Tests
- Unit-Tests für
VerifyLogindecken die vollständige Matrix ab:- Tenant-User per E-Mail → Erfolg
- Tenant-User per Username (≠ E-Mail) →
invalid_credentials - Nicht-Tenant-User per Username → Erfolg
- Nicht-Tenant-User per E-Mail → Erfolg
- Unbekannter Identifier →
invalid_credentials
QA Test Results (2026-07-04, QA Engineer)
Testumgebung: Testserver 192.168.1.132 (nicht Produktiv 131). Dedizierte Test-User angelegt und nach Test wieder entfernt:
qa46-tenant/qa46-tenant@perlbach24.de(tenant_id=1, role user) — username ≠ emailqa46-super/qa46-super@example.com(tenant_id NULL, role superadmin) PasswortTestPw123!(bcrypt cost 12). Beide User + zugehörigelogin_attemptsnach Testende gelöscht (verifiziert: 0 verbleibendeqa46%-User). Audit-Log-Einträge bleiben append-only erhalten (GoBD). Keine bestehenden Accounts verändert.
Gesamtergebnis: BESTANDEN (9/9 Punkte pass, 0 Bugs).
| # | Testpunkt | Erwartung | Ergebnis | Status |
|---|---|---|---|---|
| 1 | Tenant-User Login per E-Mail | 200 | 200, user.username=qa46-tenant korrekt aufgelöst |
PASS |
| 2 | Tenant-User Login per Username | 401 | 401 invalid_credentials | PASS |
| 3 | Non-Tenant (superadmin, tenant_id NULL) per Username | 200 | 200 | PASS |
| 4 | Non-Tenant per E-Mail | 200 | 200 | PASS |
| 5 | Unbekannter Identifier | 401 | 401 ({"error":"invalid credentials"}) |
PASS |
| 5b | Tenant-E-Mail + falsches Passwort | 401 | 401 | PASS |
| 6 | Rate-Limiting mit E-Mail-String als Schlüssel | 429 nach Fehlversuchen | 429 ausgelöst; login_attempts.username speichert vollständige E-Mail (VARCHAR(255)) ungekürzt |
PASS |
| 7 | Audit-Log protokolliert Fehlversuche mit eingegebenem Identifier | Identifier im Log | Fehlversuche geloggt mit exaktem Identifier (qa46-tenant@perlbach24.de, qa46-tenant), Detail invalid_password/rate limited; Erfolg loggt kanonischen user.Username |
PASS |
| 8 | IMAP-Login (PROJ-26, VerifyPassword-Pfad) per Username unverändert | LOGIN completed | a OK LOGIN completed per Username auf Port 993; VerifyPassword unangetastet, genutzt in internal/imapserver/server.go:380 |
PASS |
| 9 | Frontend: / E-Mail-Label+type=email, /admin/login "Benutzername oder E-Mail-Adresse" |
korrekt | Deployte Seiten: / rendert E-Mail-Adresse + type="email"; /admin/login rendert Benutzername oder E-Mail-Adresse (type=text) |
PASS |
Code-Review-Notizen:
VerifyLogin(userstore.go): Email-Lookup → Fallbackusername = $1 AND tenant_id IS NULL. Timing-Side-Channel geschlossen (Dummy-bcrypt bei No-Match).VerifyPasswordunverändert.Manager.Login(auth.go:79) ruftVerifyLogin. LDAP-extractDomainbleibt kompatibel (E-Mail-Format).- Kein Regressions-Fund gegen bestehende Auth-/IMAP-Features.
Hinweis (kein Bug, informativ): Alle produktiven Tenant-User auf 132 haben aktuell
username == email (z.B. patrick@perlbach24.de), sodass ihr Username-Login weiterhin über
den Email-Match greift. Der Reject-Pfad (Punkt 2) wurde deshalb bewusst mit einem Test-User
username != email verifiziert. homelocal-admin (id 120) wäre ein produktiver Fall mit
abweichendem Username — Passwort unbekannt, daher nicht live geprüft.
Migration (bereits durchgeführt am 2026-06-13)
- Datenqualitäts-Check auf 192.168.1.131: 0 Tenant-User mit fehlender/ungültiger E-Mail, 0 Username↔E-Mail-Kollisionen
login_attempts.usernamevon VARCHAR(100) → VARCHAR(255) erweitert (idempotenterinitSchema-Eintrag ininternal/userstore/userstore.goergänzt)- Hinweis:
superadmin@localhostundauditor@archivmail.local(beidetenant_id IS NULL) sind als E-Mail-Format ungewöhnlich, aber kein Blocker — diese User können weiterhin perusernameeinloggen
Edge Cases
- Kollision
username(User A) ==email(User B): Mitemail UNIQUEselten, aber falls vorhanden gewinnt deremail-Treffer (User B) immer — User A kann sich mit diesem String dann nicht mehr einloggen, auch wenntenant_id IS NULL. Aktuell 0 solcher Fälle (siehe Migration). - LDAP-User (PROJ-16/23):
extractDomain(identifier)ininternal/auth/auth.gomuss bei E-Mail-Eingabe weiterhin korrekt funktionieren (E-Mail-Formatuser@domainist kompatibel zum bisherigen Format).
Non-Goals
- IMAP-Server-Login (PROJ-26) bleibt unverändert per
username - Kein einheitlicher Login-Screen für alle Usertypen (bleibt bei zwei separaten Routen:
/und/admin/login) - Keine Änderung am API-Request-Wire-Format (
username-Feld bleibt im JSON-Body, nur die Bedeutung ändert sich)
Technical Requirements
- Breaking Change (bewusst): Tenant-User, die sich bisher per
usernameeinloggten, müssen künftig die E-Mail-Adresse verwenden. REST-API-Clients (PROJ-13), dieusernamefür Tenant-User senden, müssen aufemailumgestellt werden. - Betroffene Dateien:
internal/userstore/userstore.go(neueVerifyLogin, initSchema-Erweiterung — bereits erledigt)internal/auth/auth.go(Login()ruftVerifyLogin())src/app/page.tsx(Label/Input-Type)src/app/admin/login/page.tsx(Label)internal/auth/auth_test.go(neue Testfälle)