Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bb3cbf3f | ||
|
|
f48776e89b | ||
|
|
6d5a7a8ed4 | ||
|
|
7ede29a18a | ||
|
|
3a4636c2c5 |
+1
-1
@@ -56,7 +56,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -170,3 +170,21 @@ type Attachment struct {
|
||||
func (Attachment) TableName() string {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -354,7 +359,6 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status.Messages = uint32(len(messages))
|
||||
|
||||
var unseenCount uint32
|
||||
@@ -370,7 +374,16 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -394,7 +407,6 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(dbMessages) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -461,6 +473,15 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
|
||||
if err == nil {
|
||||
imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure)
|
||||
}
|
||||
// 防御:FetchBodyStructure 对部分合法/畸形 MIME 会失败并返回
|
||||
// nil(典型:message/rfc822 附件为 base64 编码时库内不解码
|
||||
// 直接按嵌套消息解析头;或 multipart 边界截断)。BodyStructure
|
||||
// 为 nil 时 go-imap 格式化 FETCH 响应会在 send() 协程 panic
|
||||
// (nil 指针解引用),连接中断导致客户端只收到部分邮件甚至
|
||||
// 一直卡在同步。解析失败时降级为 text/plain 单段结构。
|
||||
if imapMsg.BodyStructure == nil {
|
||||
imapMsg.BodyStructure = fallbackBodyStructure(rawMsg)
|
||||
}
|
||||
default:
|
||||
section, err := imap.ParseBodySectionName(item)
|
||||
if err != nil {
|
||||
@@ -471,13 +492,33 @@ func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items [
|
||||
return nil, err
|
||||
}
|
||||
literal, _ := backendutil.FetchBodySection(hdr, body, section)
|
||||
imapMsg.Body[section] = literal
|
||||
if literal != nil {
|
||||
imapMsg.Body[section] = literal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imapMsg, nil
|
||||
}
|
||||
|
||||
// fallbackBodyStructure 构造一个 text/plain 单段 BodyStructure,用于
|
||||
// MIME 解析失败的消息(保证 FETCH BODY/BODYSTRUCTURE 不因 nil 崩溃)。
|
||||
func fallbackBodyStructure(raw []byte) *imap.BodyStructure {
|
||||
size := uint32(len(raw))
|
||||
lines := uint32(bytes.Count(raw, []byte{'\n'}))
|
||||
if len(raw) > 0 && raw[len(raw)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
return &imap.BodyStructure{
|
||||
MIMEType: "text",
|
||||
MIMESubType: "plain",
|
||||
Params: map[string]string{"charset": "utf-8"},
|
||||
Encoding: "8bit",
|
||||
Size: size,
|
||||
Lines: lines,
|
||||
}
|
||||
}
|
||||
|
||||
func messageRawData(msg *db.Message) []byte {
|
||||
if msg.RawData != "" {
|
||||
return []byte(msg.RawData)
|
||||
@@ -538,7 +579,6 @@ func (m *imapMailbox) SearchMessages(uid bool, criteria *imap.SearchCriteria) ([
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -188,3 +188,95 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
||||
// 客户端意图是标记最新一封(ids[2])为已读
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
}
|
||||
|
||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
|
||||
// 不得因 nil BodyStructure 触发服务器 panic(否则连接中断,客户端只收到
|
||||
// 部分邮件或一直卡在同步)。修复前 go-imap send() 协程会 nil 指针崩溃。
|
||||
func TestFetchBodyMalformedMIME(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
|
||||
// 1) base64 编码的 message/rfc822 附件(转发邮件场景):
|
||||
// backendutil.FetchBodyStructure 不解码 base64,直接把编码文本
|
||||
// 当嵌套消息头解析 → "malformed MIME header line" 错误。
|
||||
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
|
||||
msgWithRFC822 := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "alice@example.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "fwd",
|
||||
Date: time.Now().Add(-2 * time.Hour),
|
||||
RawData: "From: alice@example.com\r\n" +
|
||||
"To: alice@example.com\r\n" +
|
||||
"Subject: fwd\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=\"==fwd==\"\r\n\r\n" +
|
||||
"--==fwd==\r\n" +
|
||||
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
|
||||
"正文\r\n\r\n" +
|
||||
"--==fwd==\r\n" +
|
||||
"Content-Type: message/rfc822\r\n" +
|
||||
"Content-Transfer-Encoding: base64\r\n" +
|
||||
"Content-Disposition: attachment; filename=\"original.eml\"\r\n" +
|
||||
"MIME-Version: 1.0\r\n\r\n" +
|
||||
rfc822Body + "\r\n" +
|
||||
"--==fwd==--\r\n",
|
||||
}
|
||||
// 2) 截断的 multipart(缺少结束边界):BODYSTRUCTURE(extended) 解析报错
|
||||
msgTruncated := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "alice@example.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "truncated",
|
||||
Date: time.Now().Add(-1 * time.Hour),
|
||||
RawData: "From: alice@example.com\r\n" +
|
||||
"To: alice@example.com\r\n" +
|
||||
"Subject: truncated\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/alternative; boundary=\"==trunc==\"\r\n\r\n" +
|
||||
"--==trunc==\r\n" +
|
||||
"Content-Type: text/plain\r\n\r\n" +
|
||||
"hello\r\n",
|
||||
// 无结束边界
|
||||
}
|
||||
if err := stores.Mails.Create(msgWithRFC822); err != nil {
|
||||
t.Fatalf("create msg: %v", err)
|
||||
}
|
||||
if err := stores.Mails.Create(msgTruncated); err != nil {
|
||||
t.Fatalf("create msg: %v", err)
|
||||
}
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddRange(1, 2)
|
||||
|
||||
// BODY:历史上 message/rfc822 消息解析失败 → nil BodyStructure → panic
|
||||
msgs := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBody}, msgs); err != nil {
|
||||
t.Fatalf("fetch body: %v", err)
|
||||
}
|
||||
got := 0
|
||||
for range msgs {
|
||||
got++
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("FETCH BODY 返回 %d/2 封", got)
|
||||
}
|
||||
|
||||
// BODYSTRUCTURE:截断 multipart 在 extended 解析时报错 → nil → panic
|
||||
msgs2 := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBodyStructure}, msgs2); err != nil {
|
||||
t.Fatalf("fetch bodystructure: %v", err)
|
||||
}
|
||||
got2 := 0
|
||||
for range msgs2 {
|
||||
got2++
|
||||
}
|
||||
if got2 != 2 {
|
||||
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", got2)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -52,6 +52,13 @@ func newMailStore(database *gorm.DB) MailStore {
|
||||
|
||||
// Create inserts a new message record.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Outbound OutboundStore
|
||||
ProtocolLogs ProtocolLogStore
|
||||
MailboxState MailboxStateStore
|
||||
}
|
||||
|
||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||
@@ -27,6 +28,7 @@ func NewStores(database *gorm.DB) *Stores {
|
||||
Bans: newBanStore(database),
|
||||
Outbound: newOutboundStore(database),
|
||||
ProtocolLogs: newProtocolLogStore(database),
|
||||
MailboxState: newMailboxStateStore(database),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +39,4 @@ var _ = db.Message{}
|
||||
var _ = db.Attachment{}
|
||||
var _ = db.BanEntry{}
|
||||
var _ = db.ProtocolLog{}
|
||||
var _ = db.MailboxState{}
|
||||
@@ -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