fix(imap): UIDVALIDITY 改为持久化随机值,修复数据库重建后客户端缓存永不失效(TB 只显示 1-3 封) #11

Merged
kevin merged 4 commits from dsh/mailgo:fix/imap-uidvalidity into main 2026-08-19 23:48:51 +08:00
5 changed files with 122 additions and 3 deletions
Showing only changes of commit f48776e89b - Show all commits
+6 -1
View File
@@ -145,7 +145,7 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string)
return nil, backend.ErrInvalidCredentials return nil, backend.ErrInvalidCredentials
} }
user, err := b.stores.Users.Authenticate(username, password) user, err := b.stores.Users.AuthenticateLogin(username, password)
if err != nil { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多") 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) return nil, fmt.Errorf("invalid credentials: %w", err)
} }
// 登录成功清零失败计数(与 Web 登录一致):否则协议客户端的失败计数
// 只增不减(如配置探测、输错密码、APP 用裸用户名重试等),累计触发
// 档位封禁,合法用户 IP 被反复误封。
b.stores.Bans.ResetFail(clientIP)
email := user.Username + "@" email := user.Username + "@"
domain, err := b.stores.Domains.GetByID(user.DomainID) domain, err := b.stores.Domains.GetByID(user.DomainID)
if err == nil { if err == nil {
+5 -1
View File
@@ -395,7 +395,7 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
clientIP := store.ClientIPFromAddr(conn.RemoteAddr()) 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 { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") 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 return nil, nil, nil
} }
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.stores.Bans.ResetFail(clientIP)
// 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用 // 保留完整邮箱作为登录标识(与 handleUSER 一致),便于推送/日志使用
authUser.Username = user.Username authUser.Username = user.Username
+5 -1
View File
@@ -219,7 +219,7 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed 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 { if err != nil {
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries
s.backend.server.stores.RecordAuthFailure( s.backend.server.stores.RecordAuthFailure(
@@ -232,6 +232,10 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
return smtp.ErrAuthFailed return smtp.ErrAuthFailed
} }
// 登录成功清零失败计数(与 Web 登录一致):防止合法用户 IP
// 因失败计数只增不减被反复误封。
s.backend.server.stores.Bans.ResetFail(s.clientIP)
domainName := user.Domain.Name domainName := user.Domain.Name
if domainName == "" { if domainName == "" {
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID) domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
+33
View File
@@ -16,6 +16,10 @@ type UserStore interface {
GetByUsername(username string, domainID uint) (*db.User, error) GetByUsername(username string, domainID uint) (*db.User, error)
GetByEmail(email string) (*db.User, error) GetByEmail(email string) (*db.User, error)
Authenticate(email, password 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 Update(user *db.User) error
Delete(id uint) error Delete(id uint) error
List(domainID uint, page, size int) ([]db.User, int64, 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 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. // Update saves changes to an existing user record.
func (s *userStoreGorm) Update(user *db.User) error { func (s *userStoreGorm) Update(user *db.User) error {
return s.db.Save(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)
}
}