fix(auth): 协议登录(IMAP/SMTP/POP3)成功时清零失败计数 + 支持裸用户名
问题:ResetFail 只在 Web 登录调用,协议层失败计数只增不减。 合法用户的客户端(手机 APP 用裸用户名重试、配置向导探测、输错 密码等)失败次数持续累积,每达到阈值就触发一次封禁档位,从第 4 次触发起真实封禁 30 分钟+——用户被反复误封,手机端表现为一直 卡在"正在接收邮件"。 修复: - IMAP/SMTP/POP3 认证成功路径调用 Bans.ResetFail(与 Web 一致) - 新增 UserStore.AuthenticateLogin:支持裸用户名(如 "kevin"), 唯一归属时自动解析到其域名;跨域名同名歧义时要求完整邮箱 - 新增 TestAuthenticateLoginBareUsername 单元测试
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user