diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index ec5eec4..cd5f393 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -145,7 +145,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) return nil, backend.ErrInvalidCredentials } - user, err := b.stores.Users.Authenticate(username, password) + user, err := b.stores.Users.AuthenticateLogin(username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多") @@ -153,6 +153,11 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) return nil, fmt.Errorf("invalid credentials: %w", err) } + // 登录成功清零失败计数(与 Web 登录一致):否则协议客户端的失败计数 + // 只增不减(如配置探测、输错密码、APP 用裸用户名重试等),累计触发 + // 档位封禁,合法用户 IP 被反复误封。 + b.stores.Bans.ResetFail(clientIP) + email := user.Username + "@" domain, err := b.stores.Domains.GetByID(user.DomainID) if err == nil { diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index 5ea1091..dea5dd0 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -395,7 +395,7 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( clientIP := store.ClientIPFromAddr(conn.RemoteAddr()) - authUser, err := s.stores.Users.Authenticate(user.Username, password) + authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") @@ -403,6 +403,10 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( return nil, nil, nil } + // 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP + // 因失败计数只增不减被反复误封。 + s.stores.Bans.ResetFail(clientIP) + // 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用 authUser.Username = user.Username diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 179e450..7591e6e 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -219,7 +219,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { return smtp.ErrAuthFailed } - user, err := s.backend.server.stores.Users.Authenticate(username, password) + user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password) if err != nil { // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) s.backend.server.stores.RecordAuthFailure( @@ -232,6 +232,10 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { return smtp.ErrAuthFailed } + // 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP + // 因失败计数只增不减被反复误封。 + s.backend.server.stores.Bans.ResetFail(s.clientIP) + domainName := user.Domain.Name if domainName == "" { domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID) diff --git a/internal/store/user_store.go b/internal/store/user_store.go index 01e55b2..13011ca 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -16,6 +16,10 @@ type UserStore interface { GetByUsername(username string, domainID uint) (*db.User, error) GetByEmail(email string) (*db.User, error) Authenticate(email, password string) (*db.User, error) + // AuthenticateLogin 协议层登录(IMAP/SMTP/POP3):与 Authenticate 相同, + // 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下 + // 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。 + AuthenticateLogin(login, password string) (*db.User, error) Update(user *db.User) error Delete(id uint) error List(domainID uint, page, size int) ([]db.User, int64, error) @@ -94,6 +98,35 @@ func (s *userStoreGorm) Authenticate(email, password string) (*db.User, error) { return user, nil } +// AuthenticateLogin 协议层登录:优先按完整邮箱认证;裸用户名(无 @)时 +// 按用户名全局查找,仅在唯一归属时接受(多域名同名视为歧义,返回失败, +// 客户端应改用完整邮箱)。密码校验与 IsActive 逻辑与 Authenticate 一致。 +func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, error) { + if strings.Contains(login, "@") { + return s.Authenticate(login, password) + } + + var users []db.User + if err := s.db.Joins("JOIN domains ON domains.id = users.domain_id"). + Where("users.username = ?", login). + Preload("Domain"). + Find(&users).Error; err != nil { + return nil, ErrInvalidCredentials + } + if len(users) != 1 { + // 0 个:用户不存在;多个:跨域名同名歧义,要求完整邮箱 + return nil, ErrInvalidCredentials + } + user := users[0] + if !user.IsActive { + return nil, ErrUserInactive + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + return &user, nil +} + // Update saves changes to an existing user record. func (s *userStoreGorm) Update(user *db.User) error { return s.db.Save(user).Error diff --git a/internal/store/user_store_authlogin_test.go b/internal/store/user_store_authlogin_test.go new file mode 100644 index 0000000..06b07f0 --- /dev/null +++ b/internal/store/user_store_authlogin_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "testing" + + "mail_go/internal/db" + + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestAuthenticateLoginBareUsername 验证协议层登录支持裸用户名: +// 唯一归属时可用,密码错误/用户不存在/跨域名同名歧义时拒绝。 +func TestAuthenticateLoginBareUsername(t *testing.T) { + gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}); err != nil { + t.Fatal(err) + } + us := newUserStore(gdb) + ds := newDomainStore(gdb) + + dom1 := &db.Domain{Name: "example.com"} + if err := ds.Create(dom1); err != nil { + t.Fatal(err) + } + hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) + u1 := &db.User{Username: "alice", DomainID: dom1.ID, PasswordHash: string(hashed), IsActive: true} + if err := us.Create(u1); err != nil { + t.Fatal(err) + } + + // 裸用户名 + 正确密码 → 成功 + u, err := us.AuthenticateLogin("alice", "secret123") + if err != nil { + t.Fatalf("bare username should succeed: %v", err) + } + if u.ID != u1.ID { + t.Fatalf("wrong user: %d != %d", u.ID, u1.ID) + } + // 裸用户名 + 错误密码 → 失败 + if _, err := us.AuthenticateLogin("alice", "wrong"); err == nil { + t.Fatal("wrong password should fail") + } + // 不存在 → 失败 + if _, err := us.AuthenticateLogin("nobody", "secret123"); err == nil { + t.Fatal("unknown user should fail") + } + // 完整邮箱仍然可用 + if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil { + t.Fatalf("full email should succeed: %v", err) + } + + // 跨域名同名 → 歧义拒绝 + dom2 := &db.Domain{Name: "other.com"} + if err := ds.Create(dom2); err != nil { + t.Fatal(err) + } + u2 := &db.User{Username: "alice", DomainID: dom2.ID, PasswordHash: string(hashed), IsActive: true} + if err := us.Create(u2); err != nil { + t.Fatal(err) + } + if _, err := us.AuthenticateLogin("alice", "secret123"); err == nil { + t.Fatal("ambiguous bare username should fail") + } + // 歧义时完整邮箱仍可用 + if _, err := us.AuthenticateLogin("alice@example.com", "secret123"); err != nil { + t.Fatalf("full email should still work: %v", err) + } +}