feat(PROJ-46): E-Mail als primärer Login-Identifier für Tenant-User

Tenant-User (tenant_id IS NOT NULL) melden sich künftig per E-Mail an statt
per Username — behebt Verwechslungen wie im Support-Fall vom 2026-06-13
(Login schlug trotz Passwort-Reset fehl, weil E-Mail statt Username
verwendet wurde). Nicht-Tenant-User (Superadmin/System) können weiterhin
Username ODER E-Mail nutzen.

Neue Store.VerifyLogin() prüft erst per E-Mail (alle User), fällt dann auf
Username zurück (nur tenant_id IS NULL). VerifyPassword() bleibt für den
IMAP-Server-Login-Pfad (PROJ-26) unverändert. Bewusster Breaking Change für
Tenant-User, Datenqualität vorab geprüft (0 Kollisionen).

Security-Nachtrag: bcrypt-Dummy-Compare im "user not found"-Pfad ergänzt,
um Timing-basierte Identifier-Enumeration zu verhindern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-03 23:37:20 +02:00
co-authored by Claude Sonnet 5
parent 804cd62201
commit 767373b206
18 changed files with 1710 additions and 14 deletions
+68
View File
@@ -116,6 +116,74 @@ func TestVerifyPassword(t *testing.T) {
}
}
// TestVerifyLogin covers the full PROJ-46 login-identifier matrix.
func TestVerifyLogin(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
tenantID := int64(1)
// Tenant user: username "patrick" != email
if _, err := s.Create(userstore.CreateUserRequest{
Username: "patrick", Email: "patrick@perlbach24.de",
Password: "pw-tenant", Role: userstore.RoleUser, TenantID: &tenantID,
}); err != nil {
t.Fatal(err)
}
// Non-tenant user (superadmin/system), tenant_id IS NULL
if _, err := s.Create(userstore.CreateUserRequest{
Username: "superadmin", Email: "superadmin@localhost",
Password: "pw-super", Role: userstore.RoleSuperAdmin, TenantID: nil,
}); err != nil {
t.Fatal(err)
}
// 1. Tenant user via email → success
u, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "pw-tenant")
if err != nil {
t.Fatalf("tenant user via email should succeed: %v", err)
}
if u.Username != "patrick" {
t.Errorf("Username = %q, want patrick", u.Username)
}
// 2. Tenant user via username (≠ email) → invalid_credentials
if _, err := s.VerifyLogin(ctx, "patrick", "pw-tenant"); err == nil {
t.Error("tenant user via username should be rejected")
}
// 3. Non-tenant user via username → success
u, err = s.VerifyLogin(ctx, "superadmin", "pw-super")
if err != nil {
t.Fatalf("non-tenant user via username should succeed: %v", err)
}
if u.Username != "superadmin" {
t.Errorf("Username = %q, want superadmin", u.Username)
}
// 4. Non-tenant user via email → success
u, err = s.VerifyLogin(ctx, "superadmin@localhost", "pw-super")
if err != nil {
t.Fatalf("non-tenant user via email should succeed: %v", err)
}
if u.Username != "superadmin" {
t.Errorf("Username = %q, want superadmin", u.Username)
}
// 5. Unknown identifier → invalid_credentials
if _, err := s.VerifyLogin(ctx, "ghost@nowhere.tld", "x"); err == nil {
t.Error("unknown identifier should be rejected")
}
if _, err := s.VerifyLogin(ctx, "ghost", "x"); err == nil {
t.Error("unknown username should be rejected")
}
// Wrong password for valid identifier → rejected
if _, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "wrong"); err == nil {
t.Error("wrong password should be rejected")
}
}
func TestUpdateUser(t *testing.T) {
s := newTestStore(t)
u, _ := s.Create(userstore.CreateUserRequest{