Files
mailgo/internal/store/auth_guard.go
T
kevin 6ee2dd8184 fix(security): 修复 P4 封禁记录数据错位 + P5 枚举爆破宽限(方案 A)
P4 #17 手动封禁 Create 非 upsert 导致数据错位:
- BanStore 新增 BanIP(ip, reason, duration):事务内清理该 IP 全部
  既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零;
  DisconnectConnection 改用(原裸 Create 为全仓库唯一调用点)
- BanEntry.IPAddress 升级 uniqueIndex;InitDB 在 AutoMigrate 前
  dedupeBanEntries 清理历史重复行(MySQL 1093 兼容写法)
- IncrementFail 原子化:SQL 侧 fail_count+1,miss 时 OnConflict
  DoNothing 插入兜底并发竞态,回读计数

P5 #18 方案 A(按失败性质区分宽限):
- RecordAuthFailure 新增 knownUser 参数:用户名不存在(枚举型
  爆破)跳过 3 次宽限、首次触发即封第 1 档;用户名存在(真实
  用户输错)保留宽限防误封
- 新增 UserStore.LoginExists(邮箱/裸用户名);五个失败调用点
  接线(Web 登录查存在性;SMTP/IMAP/POP3 用登录名;LDAP 侧
  存在性不可判定,保守按已知用户处理)
- 封禁原因注明「未知用户名,跳过宽限」便于后台审计

新增 8 个测试(-race 通过):BanIP 单行 upsert、唯一索引约束、
16 协程并发计数精确、dedupe 清理/表不存在静默、未知用户即时
封禁、已知用户宽限回归、LoginExists 矩阵。
2026-08-20 11:25:10 +08:00

82 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"fmt"
"net"
"time"
)
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
// 解析失败返回空字符串,调用方应据此跳过封禁逻辑(不误封)。
func ClientIPFromAddr(addr net.Addr) string {
if addr == nil {
return ""
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
// RecordAuthFailure 记录一次登录/认证失败(Web 表单、LDAP 与 SMTP/IMAP/POP3
// 协议层统一入口):
// - 失败计数累加(每 IP 一条记录,upsert);
// - 达到 maxFail 阈值时触发次数 BanCount+1
// knownUser(用户名存在、疑似真实用户输错)前 freeTriggers3)次
// 只计数不封禁(防误封);
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
// 第 1 档封禁;
// 封禁时长按档位递增(stageDuration),上限半年;
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
//
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string, knownUser bool) (banned bool, failCount int) {
if ip == "" || maxFail <= 0 {
return false, 0
}
failCount, _ = s.Bans.IncrementFail(ip)
if failCount < maxFail {
return false, failCount
}
entry, err := s.Bans.GetByIP(ip)
if err != nil || entry == nil {
return false, failCount
}
// 已处于封禁中(例如并发请求竞态)不重复触发、不重设档位
if entry.ExpiresAt.After(time.Now()) {
return true, failCount
}
banCount := entry.BanCount + 1
entry.BanCount = banCount
entry.FailCount = failCount
// 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 3 次宽限
effectiveCount := banCount
if !knownUser {
effectiveCount += freeTriggers
}
// 宽限期内只计数,不封禁(保留零到期时间与空原因)
if effectiveCount <= freeTriggers {
if err := s.Bans.Update(entry); err != nil {
return false, failCount
}
return false, failCount
}
banNum := effectiveCount - freeTriggers
if knownUser {
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
} else {
entry.Reason = fmt.Sprintf("第%d次封禁:%s(未知用户名,跳过宽限;第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
}
entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
if err := s.Bans.Update(entry); err != nil {
return false, failCount
}
return true, failCount
}