feat(security): IP 阶段性封禁,前3次触发不封禁、第4次起按档位递增

- 封禁规则:达到失败阈值记为一次触发,前 3 次只计数不封禁;
  第 4 次起封禁并按档位递增:30分钟(ban_duration_min)→ 3小时
  → 3个月 → 半年(上限);封禁过期后保留记录作为升档依据,
  成功登录或管理员解封清零
- BanEntry 新增 BanCount(累计触发次数),每 IP 一条记录 upsert,
  不再重复建行;RecordAuthFailure 统一 Web/LDAP/SMTP/IMAP/POP3
  五处封禁逻辑,原因带档位(如"第1次封禁:登录失败次数过多
  (第4次触发,失败5次)")
- 黑名单页修复:列表仅显示已封禁或曾封禁记录(原因/到期时间必填),
  新增封禁次数列与封禁中/已过期状态徽章,移除清理过期按钮
- 用户封禁页显示第 N 次封禁档位;新增档位升级与列表过滤单测
This commit is contained in:
2026-08-19 19:07:25 +08:00
parent 353bfa88f2
commit f2493da03e
14 changed files with 350 additions and 107 deletions
+40 -15
View File
@@ -4,8 +4,6 @@ import (
"fmt"
"net"
"time"
"mail_go/internal/db"
)
// ClientIPFromAddr 从 net.Addr 提取客户端 IP 字符串(去掉端口)。
@@ -21,23 +19,50 @@ func ClientIPFromAddr(addr net.Addr) string {
return host
}
// RecordAuthFailure 记录一次协议层(SMTP/IMAP/POP3)认证失败:
// 失败计数累加,达到 maxFail 阈值时封禁该 IP(封禁时长 minutes 分钟)。
// 返回 (是否触发封禁, 当前失败计数)。Web 登录的封禁逻辑在
// handlers.AuthHandler 中,与这里独立。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, minutes int) (banned bool, failCount int) {
// RecordAuthFailure 记录一次登录/认证失败(Web 表单、LDAP 与 SMTP/IMAP/POP3
// 协议层统一入口):
// - 失败计数累加(每 IP 一条记录,upsert);
// - 达到 maxFail 阈值时触发次数 BanCount+1
// 前 freeTriggers3)次只计数不封禁;
// 从第 4 次起封禁,时长按档位递增(stageDuration),上限半年;
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
//
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string) (banned bool, failCount int) {
if ip == "" || maxFail <= 0 {
return false, 0
}
failCount, _ = s.Bans.IncrementFail(ip)
if failCount >= maxFail {
_ = s.Bans.Create(&db.BanEntry{
IPAddress: ip,
Reason: fmt.Sprintf("邮件协议认证失败次数过多 (%d次)", failCount),
FailCount: failCount,
ExpiresAt: time.Now().Add(time.Duration(minutes) * time.Minute),
})
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
}
return false, failCount
banCount := entry.BanCount + 1
entry.BanCount = banCount
entry.FailCount = failCount
// 前 3 次只计数,不封禁(保留零到期时间与空原因)
if banCount <= freeTriggers {
if err := s.Bans.Update(entry); err != nil {
return false, failCount
}
return false, failCount
}
banNum := banCount - freeTriggers
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
}