Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a394bbd9e | ||
|
|
d9bb3cbf3f | ||
|
|
f48776e89b | ||
|
|
6d5a7a8ed4 | ||
|
|
7ede29a18a |
+1
-1
@@ -56,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate all models
|
// Auto-migrate all models
|
||||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil {
|
||||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,3 +170,21 @@ type Attachment struct {
|
|||||||
func (Attachment) TableName() string {
|
func (Attachment) TableName() string {
|
||||||
return "attachments"
|
return "attachments"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。
|
||||||
|
// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间
|
||||||
|
// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存
|
||||||
|
// 并全量重新同步。此前硬编码为 1,数据库重建后客户端缓存永不失效,
|
||||||
|
// 导致只显示/下载少量"缺失"邮件。
|
||||||
|
type MailboxState struct {
|
||||||
|
UserID uint `gorm:"primaryKey" json:"user_id"`
|
||||||
|
Folder string `gorm:"primaryKey;size:64" json:"folder"`
|
||||||
|
UidValidity uint32 `gorm:"not null" json:"uid_validity"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for MailboxState.
|
||||||
|
func (MailboxState) TableName() string {
|
||||||
|
return "mailbox_states"
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -354,7 +359,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
status.Messages = uint32(len(messages))
|
status.Messages = uint32(len(messages))
|
||||||
|
|
||||||
var unseenCount uint32
|
var unseenCount uint32
|
||||||
@@ -370,7 +374,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
status.UidNext = uint32(maxID + 1)
|
status.UidNext = uint32(maxID + 1)
|
||||||
status.UidValidity = 1
|
// UIDVALIDITY 持久化随机值(RFC 3501):数据库重建导致消息 ID 空间
|
||||||
|
// 变化时该值随之改变,客户端才会丢弃旧缓存全量重同步。此前硬编码 1,
|
||||||
|
// 数据库重建后 Thunderbird 等客户端缓存永不失效(只下载"新增"的
|
||||||
|
// UID),表现为列表只剩少量邮件。
|
||||||
|
uidValidity, err := m.stores.MailboxState.UidValidity(m.user.id, m.name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", m.name, err)
|
||||||
|
uidValidity = 1
|
||||||
|
}
|
||||||
|
status.UidValidity = uidValidity
|
||||||
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
@@ -394,7 +407,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(dbMessages) == 0 {
|
if len(dbMessages) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -567,7 +579,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ func newMailStore(database *gorm.DB) MailStore {
|
|||||||
|
|
||||||
// Create inserts a new message record.
|
// Create inserts a new message record.
|
||||||
func (s *mailStoreGorm) Create(msg *db.Message) error {
|
func (s *mailStoreGorm) Create(msg *db.Message) error {
|
||||||
|
// 日期统一为 UTC 存储:date 列在 SQLite 中是文本,混合时区偏移
|
||||||
|
// (+08:00/-04:00 等)会让 ORDER BY date 变成错误的字典序(Web 列表
|
||||||
|
// 排序错乱、IMAP 序号与客户端日期视图不一致)。统一 UTC 后字典序
|
||||||
|
// 即时间序,所有排序路径(Web/IMAP/seqOf)全链路一致。
|
||||||
|
if msg != nil {
|
||||||
|
msg.Date = msg.Date.UTC()
|
||||||
|
}
|
||||||
return s.db.Create(msg).Error
|
return s.db.Create(msg).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
|
||||||
|
"mail_go/internal/db"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailboxStateStore 提供 IMAP 邮箱持久化状态(UIDVALIDITY)的存取。
|
||||||
|
type MailboxStateStore interface {
|
||||||
|
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
|
||||||
|
UidValidity(userID uint, folder string) (uint32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailboxStateStoreGorm struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMailboxStateStore(database *gorm.DB) *mailboxStateStoreGorm {
|
||||||
|
return &mailboxStateStoreGorm{db: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UidValidity 返回邮箱的持久化 UIDVALIDITY;首次访问时随机生成并落库。
|
||||||
|
// 随机值保证:邮箱内容身份变化(如数据库重建导致消息 ID 空间变化)时,
|
||||||
|
// 新库生成的新值会让客户端丢弃旧缓存全量重同步(RFC 3501 UIDVALIDITY
|
||||||
|
// 语义)。绝不返回 0(0 不是合法 UIDVALIDITY)。
|
||||||
|
func (s *mailboxStateStoreGorm) UidValidity(userID uint, folder string) (uint32, error) {
|
||||||
|
var st db.MailboxState
|
||||||
|
err := s.db.Where("user_id = ? AND folder = ?", userID, folder).First(&st).Error
|
||||||
|
if err == nil {
|
||||||
|
if st.UidValidity != 0 {
|
||||||
|
return st.UidValidity, nil
|
||||||
|
}
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次访问(或旧数据为 0):随机生成并持久化
|
||||||
|
var buf [4]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
v := binary.BigEndian.Uint32(buf[:])
|
||||||
|
if v == 0 {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
st = db.MailboxState{UserID: userID, Folder: folder, UidValidity: v}
|
||||||
|
if err := s.db.Save(&st).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mail_go/internal/db"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMailboxStateUidValidity 验证 UIDVALIDITY:首次访问随机生成、重复访问
|
||||||
|
// 稳定返回、0 值被修正、不同邮箱互不影响。
|
||||||
|
func TestMailboxStateUidValidity(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.MailboxState{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := newMailboxStateStore(gdb)
|
||||||
|
|
||||||
|
// 首次访问:随机非 0
|
||||||
|
v1, err := s.UidValidity(1, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v1 == 0 {
|
||||||
|
t.Fatal("UIDVALIDITY 不应为 0")
|
||||||
|
}
|
||||||
|
// 重复访问:稳定
|
||||||
|
v2, err := s.UidValidity(1, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v1 != v2 {
|
||||||
|
t.Fatalf("UIDVALIDITY 不稳定: %d != %d", v1, v2)
|
||||||
|
}
|
||||||
|
// 不同邮箱:独立
|
||||||
|
v3, err := s.UidValidity(1, "Sent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v3 == v1 {
|
||||||
|
t.Fatal("不同邮箱的 UIDVALIDITY 不应相同")
|
||||||
|
}
|
||||||
|
// 不同用户:独立
|
||||||
|
v4, err := s.UidValidity(2, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v4 == v1 {
|
||||||
|
t.Fatal("不同用户的 UIDVALIDITY 不应相同")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type Stores struct {
|
|||||||
Bans BanStore
|
Bans BanStore
|
||||||
Outbound OutboundStore
|
Outbound OutboundStore
|
||||||
ProtocolLogs ProtocolLogStore
|
ProtocolLogs ProtocolLogStore
|
||||||
|
MailboxState MailboxStateStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||||
@@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores {
|
|||||||
Bans: newBanStore(database),
|
Bans: newBanStore(database),
|
||||||
Outbound: newOutboundStore(database),
|
Outbound: newOutboundStore(database),
|
||||||
ProtocolLogs: newProtocolLogStore(database),
|
ProtocolLogs: newProtocolLogStore(database),
|
||||||
|
MailboxState: newMailboxStateStore(database),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,3 +39,4 @@ var _ = db.Message{}
|
|||||||
var _ = db.Attachment{}
|
var _ = db.Attachment{}
|
||||||
var _ = db.BanEntry{}
|
var _ = db.BanEntry{}
|
||||||
var _ = db.ProtocolLog{}
|
var _ = db.ProtocolLog{}
|
||||||
|
var _ = db.MailboxState{}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user