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:
co-authored by
Claude Sonnet 5
parent
804cd62201
commit
767373b206
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user