From f2493da03ebf7df9d6ac74f76efb0d48212e09c0 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 19 Aug 2026 19:07:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20IP=20=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=80=A7=E5=B0=81=E7=A6=81=EF=BC=8C=E5=89=8D3=E6=AC=A1?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E4=B8=8D=E5=B0=81=E7=A6=81=E3=80=81=E7=AC=AC?= =?UTF-8?q?4=E6=AC=A1=E8=B5=B7=E6=8C=89=E6=A1=A3=E4=BD=8D=E9=80=92?= =?UTF-8?q?=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 封禁规则:达到失败阈值记为一次触发,前 3 次只计数不封禁; 第 4 次起封禁并按档位递增:30分钟(ban_duration_min)→ 3小时 → 3个月 → 半年(上限);封禁过期后保留记录作为升档依据, 成功登录或管理员解封清零 - BanEntry 新增 BanCount(累计触发次数),每 IP 一条记录 upsert, 不再重复建行;RecordAuthFailure 统一 Web/LDAP/SMTP/IMAP/POP3 五处封禁逻辑,原因带档位(如"第1次封禁:登录失败次数过多 (第4次触发,失败5次)") - 黑名单页修复:列表仅显示已封禁或曾封禁记录(原因/到期时间必填), 新增封禁次数列与封禁中/已过期状态徽章,移除清理过期按钮 - 用户封禁页显示第 N 次封禁档位;新增档位升级与列表过滤单测 --- README.md | 6 +- internal/db/models.go | 4 + internal/imap_server/backend.go | 4 +- internal/pop3_server/server.go | 4 +- internal/smtp_server/server.go | 3 +- internal/store/auth_guard.go | 55 +++++-- internal/store/auth_guard_test.go | 207 ++++++++++++++++++++++--- internal/store/ban_store.go | 63 ++++++-- internal/web/handlers/admin.go | 24 +-- internal/web/handlers/auth.go | 34 +--- internal/web/render_test.go | 12 ++ internal/web/server.go | 1 - internal/web/templates/admin/bans.html | 36 +++-- internal/web/templates/banned.html | 4 + 14 files changed, 350 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 50a3a76..3cfa792 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + - **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计 - **协议调用日志**:SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理) - **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭) -- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封 +- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封 - **多数据库**:默认 SQLite,可切换 MySQL - **跨平台**:Linux 生产部署 + Windows 本地调试 @@ -127,7 +127,9 @@ ldap_use_tls = false [ban] max_fail_attempts = 5 # 登录失败次数阈值 -ban_duration_min = 30 # 封禁时长(分钟) +ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增: + # 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限) + # 前 3 次达到阈值只计数不封禁,成功登录后清零 [caddy] data_dir = "" # Caddy 数据目录(含 certificates/ 的那个), diff --git a/internal/db/models.go b/internal/db/models.go index 56f6cac..ffc78a4 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -114,6 +114,10 @@ type BanEntry struct { IPAddress string `gorm:"size:45;index;not null" json:"ip_address"` Reason string `gorm:"size:255" json:"reason"` FailCount int `gorm:"default:0" json:"fail_count"` + // BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。 + // 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。 + // 成功登录或管理员解封会删除记录,次数随之清零。 + BanCount int `gorm:"default:0" json:"ban_count"` ExpiresAt time.Time `gorm:"index" json:"expires_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index 94f1243..af18ba1 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -44,8 +44,8 @@ func (b *imapBackend) Login(connInfo *imap.ConnInfo, username, password string) user, err := b.stores.Users.Authenticate(username, password) if err != nil { - // 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries) - b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin) + // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) + b.stores.RecordAuthFailure(clientIP, b.banCfg.MaxFailAttempts, b.banCfg.BanDurationMin, "邮件协议认证失败次数过多") b.recordLogin(clientIP, username, false, "用户名或密码错误", "LOGIN 失败", 0, now) return nil, fmt.Errorf("invalid credentials: %w", err) } diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index 16d5123..e5a66ae 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -377,8 +377,8 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( authUser, err := s.stores.Users.Authenticate(user.Username, password) if err != nil { - // 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries) - s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin) + // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) + s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多") sendResponse(conn, "-ERR authentication failed") return nil, nil, nil } diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 8167bad..2a20926 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -199,11 +199,12 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) { user, err := s.backend.server.stores.Users.Authenticate(username, password) if err != nil { - // 认证失败计数,达到阈值封禁(与 Web 登录共用 ban_entries) + // 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries) s.backend.server.stores.RecordAuthFailure( s.clientIP, s.backend.server.banCfg.MaxFailAttempts, s.backend.server.banCfg.BanDurationMin, + "邮件协议认证失败次数过多", ) s.recordFail("用户名或密码错误") return smtp.ErrAuthFailed diff --git a/internal/store/auth_guard.go b/internal/store/auth_guard.go index 57c0aed..ecc3bae 100644 --- a/internal/store/auth_guard.go +++ b/internal/store/auth_guard.go @@ -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: +// 前 freeTriggers(3)次只计数不封禁; +// 从第 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 } diff --git a/internal/store/auth_guard_test.go b/internal/store/auth_guard_test.go index 2deaca7..15fe47f 100644 --- a/internal/store/auth_guard_test.go +++ b/internal/store/auth_guard_test.go @@ -3,6 +3,7 @@ package store import ( "net" "path/filepath" + "strings" "testing" "time" @@ -24,46 +25,210 @@ func newTestStores(t *testing.T) *Stores { return NewStores(gdb) } -// TestRecordAuthFailureBansAfterThreshold 验证连续认证失败达到阈值后封禁。 -func TestRecordAuthFailureBansAfterThreshold(t *testing.T) { +// TestRecordAuthFailureFreeTriggers 验证前 3 次达到阈值只计数不封禁, +// 第 4 次起封禁(第 1 次封禁 = 配置时长)。 +func TestRecordAuthFailureFreeTriggers(t *testing.T) { s := newTestStores(t) const ip = "203.0.113.10" - const maxFail = 3 + const maxFail = 2 - // 前两次失败不封禁 - for i := 1; i < maxFail; i++ { - banned, count := s.RecordAuthFailure(ip, maxFail, 30) - if banned { - t.Fatalf("attempt %d should not be banned yet", i) + failOnce := func() bool { + banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多") + return banned + } + + // 第 1 次触发需要 maxFail 次失败 + for f := 0; f < maxFail; f++ { + if failOnce() { + t.Fatalf("trigger 1 (fail %d) should not ban yet", f+1) } - if count != i { - t.Fatalf("attempt %d: fail count = %d, want %d", i, count, i) + } + // 达到阈值后失败计数持续累计,之后每次失败都会再次触发 + for i := 2; i <= 3; i++ { + if failOnce() { + t.Fatalf("trigger %d should not ban yet", i) } } - // 第三次失败触发封禁 - banned, count := s.RecordAuthFailure(ip, maxFail, 30) - if !banned { - t.Fatal("attempt reaching threshold should ban the IP") + entry, err := s.Bans.GetByIP(ip) + if err != nil { + t.Fatalf("get entry: %v", err) } - if count != maxFail { - t.Fatalf("fail count = %d, want %d", count, maxFail) + if entry.BanCount != 3 { + t.Fatalf("ban_count = %d, want 3", entry.BanCount) + } + if !entry.ExpiresAt.IsZero() { + t.Fatal("observation record must not have expiry") + } + + // 第 4 次触发封禁,时长 = firstBanMin(30 分钟) + if !failOnce() { + t.Fatal("4th trigger should ban the IP") + } + + entry, err = s.Bans.GetByIP(ip) + if err != nil { + t.Fatalf("get entry: %v", err) + } + wantExpiry := time.Now().Add(30 * time.Minute) + if entry.ExpiresAt.Before(wantExpiry.Add(-time.Minute)) || entry.ExpiresAt.After(wantExpiry.Add(time.Minute)) { + t.Fatalf("ban expiry = %v, want ~%v", entry.ExpiresAt, wantExpiry) + } + if !strings.Contains(entry.Reason, "第1次封禁") { + t.Fatalf("reason = %q, want 第1次封禁", entry.Reason) + } + if entry.BanCount != 4 { + t.Fatalf("ban_count = %d, want 4", entry.BanCount) } // IP 现在处于封禁状态 - banned, entry := s.Bans.IsBanned(ip) - if !banned { + if banned, _ := s.Bans.IsBanned(ip); !banned { t.Fatal("IP should be banned") } - if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) { - t.Fatalf("ban expiry too short: %v", entry.ExpiresAt) +} + +// TestStagedBanEscalation 验证封禁档位递增:30分钟 → 3小时 → 3个月 → 半年(上限)。 +func TestStagedBanEscalation(t *testing.T) { + s := newTestStores(t) + const ip = "203.0.113.11" + const maxFail = 2 + + failOnce := func() bool { + banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多") + return banned + } + + // 第 1 次触发需要 maxFail 次失败;此后每次失败即触发下一轮 + if failOnce() { + t.Fatal("fail 1 must not trigger") + } + if failOnce() { // 触发 1 + t.Fatal("trigger 1 must not ban") + } + if failOnce() { // 触发 2 + t.Fatal("trigger 2 must not ban") + } + if failOnce() { // 触发 3 + t.Fatal("trigger 3 must not ban") + } + + // 第 4 次触发:30 分钟 + if !failOnce() { + t.Fatal("trigger 4 should ban") + } + expectBanDuration(t, s, ip, 4, 30*time.Minute) + + // 第 5 次:3 小时 + expireBan(t, s, ip) + if !failOnce() { + t.Fatal("trigger 5 should ban") + } + expectBanDuration(t, s, ip, 5, 3*time.Hour) + + // 第 6 次:3 个月 + expireBan(t, s, ip) + if !failOnce() { + t.Fatal("trigger 6 should ban") + } + expectBanDuration(t, s, ip, 6, 90*24*time.Hour) + + // 第 7 次:半年 + expireBan(t, s, ip) + if !failOnce() { + t.Fatal("trigger 7 should ban") + } + expectBanDuration(t, s, ip, 7, 180*24*time.Hour) + + // 第 8 次:仍为半年(上限) + expireBan(t, s, ip) + if !failOnce() { + t.Fatal("trigger 8 should ban") + } + expectBanDuration(t, s, ip, 8, 180*24*time.Hour) + + entry, _ := s.Bans.GetByIP(ip) + if !strings.Contains(entry.Reason, "第5次封禁") { + t.Fatalf("reason = %q, want 第5次封禁", entry.Reason) + } +} + +// expectBanDuration 断言该 IP 当前封禁时长约为 min(允许 2 分钟误差)。 +func expectBanDuration(t *testing.T, s *Stores, ip string, trigger int, min time.Duration) { + t.Helper() + entry, err := s.Bans.GetByIP(ip) + if err != nil { + t.Fatalf("trigger %d: %v", trigger, err) + } + diff := entry.ExpiresAt.Sub(time.Now()) + if diff < min-2*time.Minute || diff > min+2*time.Minute { + t.Fatalf("trigger %d: ban duration = %v, want ~%v", trigger, diff, min) + } +} + +// expireBan 把该 IP 的封禁记录改成已过期(模拟时间流逝)。 +func expireBan(t *testing.T, s *Stores, ip string) { + t.Helper() + entry, err := s.Bans.GetByIP(ip) + if err != nil { + t.Fatalf("get entry: %v", err) + } + entry.ExpiresAt = time.Now().Add(-time.Minute) + if err := s.Bans.Update(entry); err != nil { + t.Fatalf("update entry: %v", err) + } +} + +// TestBanListOnlyBannedOrExpired 验证列表只返回封禁(含已过期)记录, +// 仅计数的观察记录不出现。 +func TestBanListOnlyBannedOrExpired(t *testing.T) { + s := newTestStores(t) + + // 观察记录:失败计数,未封禁(无到期时间) + if _, err := s.Bans.IncrementFail("203.0.113.20"); err != nil { + t.Fatalf("increment: %v", err) + } + // 当前生效的封禁 + if err := s.Bans.Create(&db.BanEntry{ + IPAddress: "198.51.100.21", + Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)", + FailCount: 5, + BanCount: 4, + ExpiresAt: time.Now().Add(30 * time.Minute), + }); err != nil { + t.Fatalf("create ban: %v", err) + } + // 已过期的封禁(历史) + if err := s.Bans.Create(&db.BanEntry{ + IPAddress: "198.51.100.22", + Reason: "第2次封禁:登录失败次数过多(第5次触发,失败6次)", + FailCount: 6, + BanCount: 5, + ExpiresAt: time.Now().Add(-24 * time.Hour), + }); err != nil { + t.Fatalf("create expired ban: %v", err) + } + + entries, total, err := s.Bans.List(1, 10) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 2 { + t.Fatalf("total = %d, want 2", total) + } + if len(entries) != 2 { + t.Fatalf("len = %d, want 2", len(entries)) + } + for _, e := range entries { + if e.ExpiresAt.Before(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("observation record leaked into list: %+v", e) + } } } // TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。 func TestRecordAuthFailureEmptyIPSafe(t *testing.T) { s := newTestStores(t) - banned, count := s.RecordAuthFailure("", 3, 30) + banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多") if banned || count != 0 { t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count) } diff --git a/internal/store/ban_store.go b/internal/store/ban_store.go index 50b8a87..9df25a9 100644 --- a/internal/store/ban_store.go +++ b/internal/store/ban_store.go @@ -8,16 +8,48 @@ import ( "gorm.io/gorm" ) +// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁: +// 第 4 次 = ban_duration_min(默认 30 分钟)→ 第 5 次 = 3 小时 → +// 第 6 次 = 3 个月 → 第 7 次起 = 半年(上限)。 +const ( + // freeTriggers 达到失败阈值但暂不封禁的触发次数(前 3 次只计数)。 + freeTriggers = 3 + banStage2Min = 3 * 60 // 3 小时 + banStage3Day = 90 // 3 个月 + banStage4Day = 180 // 半年(上限) + banMaxDay = banStage4Day +) + +// stageDuration 返回第 banCount 次封禁(banCount 从 1 开始)的时长。 +// firstBanMin 是第一次封禁的分钟数(来自配置 [ban] ban_duration_min)。 +func stageDuration(banCount int, firstBanMin int) time.Duration { + switch banCount { + case 1: + if firstBanMin <= 0 { + firstBanMin = 30 + } + return time.Duration(firstBanMin) * time.Minute + case 2: + return time.Duration(banStage2Min) * time.Minute + case 3: + return time.Duration(banStage3Day) * 24 * time.Hour + default: + return time.Duration(banMaxDay) * 24 * time.Hour + } +} + // BanStore defines the interface for IP ban operations. type BanStore interface { Create(entry *db.BanEntry) error GetByIP(ip string) (*db.BanEntry, error) + Update(entry *db.BanEntry) error Delete(id uint) error + // List 返回已封禁或曾封禁的记录(不含仅计数未封禁的观察记录)。 List(page, size int) ([]db.BanEntry, int64, error) IsBanned(ip string) (bool, *db.BanEntry) + // IncrementFail 累加该 IP 的失败次数(无记录时创建),保留 BanCount。 IncrementFail(ip string) (int, error) ResetFail(ip string) error - Cleanup() error } // banStoreGorm implements BanStore using GORM. @@ -44,22 +76,33 @@ func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) { return &entry, nil } +// Update saves changes to an existing ban entry record. +func (s *banStoreGorm) Update(entry *db.BanEntry) error { + return s.db.Save(entry).Error +} + // Delete removes a ban entry by ID. func (s *banStoreGorm) Delete(id uint) error { return s.db.Delete(&db.BanEntry{}, id).Error } -// List retrieves a paginated list of ban entries. +// banEpochSentinel 用于区分“未封禁的计数记录”(expires_at 为零值): +// 所有实际封禁记录的到期时间都晚于 2000 年。 +var banEpochSentinel = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + +// List retrieves a paginated list of ban entries that are or have been +// banned (expires_at set). 仅计数的观察记录(零到期时间、无原因)不返回。 func (s *banStoreGorm) List(page, size int) ([]db.BanEntry, int64, error) { var entries []db.BanEntry var total int64 - if err := s.db.Model(&db.BanEntry{}).Count(&total).Error; err != nil { + query := s.db.Model(&db.BanEntry{}).Where("expires_at > ?", banEpochSentinel) + if err := query.Count(&total).Error; err != nil { return nil, 0, err } offset := (page - 1) * size - if err := s.db.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil { + if err := query.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil { return nil, 0, err } return entries, total, nil @@ -76,7 +119,8 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) { } // IncrementFail increments the fail count for an IP address. -// If no record exists, it creates one with fail_count=1 and a zero expires_at. +// If no record exists, it creates one with fail_count=1, ban_count=0 and a +// zero expires_at (not yet banned). Existing BanCount is preserved. // Returns the updated fail count. func (s *banStoreGorm) IncrementFail(ip string) (int, error) { var entry db.BanEntry @@ -86,6 +130,7 @@ func (s *banStoreGorm) IncrementFail(ip string) (int, error) { entry = db.BanEntry{ IPAddress: ip, FailCount: 1, + BanCount: 0, ExpiresAt: time.Time{}, // Zero time, not yet banned } if createErr := s.db.Create(&entry).Error; createErr != nil { @@ -103,13 +148,7 @@ func (s *banStoreGorm) IncrementFail(ip string) (int, error) { } // ResetFail resets the fail count for an IP address by deleting its record. +// 成功登录(或管理员解封)后调用:封禁档位历史随之清零。 func (s *banStoreGorm) ResetFail(ip string) error { return s.db.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error } - -// Cleanup removes expired ban entries. -// It deletes records where expires_at is in the past and is not zero -// (preserving records that have fail counts but are not yet banned). -func (s *banStoreGorm) Cleanup() error { - return s.db.Where("expires_at < ? AND expires_at > ?", time.Now(), time.Time{}).Delete(&db.BanEntry{}).Error -} diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 251cd32..b54729f 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -721,9 +721,6 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { // ListBans renders the IP ban list page. func (h *AdminHandler) ListBans(c *gin.Context) { - // Clean up expired entries first - h.stores.Bans.Cleanup() - page := getPageParam(c, "page", 1) bans, total, err := h.stores.Bans.List(page, 20) @@ -732,6 +729,13 @@ func (h *AdminHandler) ListBans(c *gin.Context) { return } + // 标记当前是否仍处于封禁中 + now := time.Now() + rows := make([]banRow, 0, len(bans)) + for _, b := range bans { + rows = append(rows, banRow{BanEntry: b, Active: b.ExpiresAt.After(now)}) + } + currentUser, _ := c.Get("currentUser") totalPages := int(total) / 20 @@ -744,7 +748,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) { c.HTML(200, "admin_bans", gin.H{ "currentUser": currentUser, - "bans": bans, + "rows": rows, "total": total, "page": page, "pageSize": 20, @@ -753,6 +757,12 @@ func (h *AdminHandler) ListBans(c *gin.Context) { }) } +// banRow 是黑名单列表行:附带了当前是否封禁中的标记。 +type banRow struct { + db.BanEntry + Active bool +} + // UnbanIP removes a ban entry by ID. func (h *AdminHandler) UnbanIP(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) @@ -769,12 +779,6 @@ func (h *AdminHandler) UnbanIP(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/bans") } -// CleanupBans removes all expired ban entries. -func (h *AdminHandler) CleanupBans(c *gin.Context) { - h.stores.Bans.Cleanup() - c.Redirect(http.StatusFound, "/admin/bans") -} - // ListProtocolLogs 渲染协议调用日志页(SMTP/IMAP/POP3 调用记录,支持筛选)。 func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { // 页面访问时顺带清理过期日志,避免日志表无限增长 diff --git a/internal/web/handlers/auth.go b/internal/web/handlers/auth.go index 206037d..da7c459 100644 --- a/internal/web/handlers/auth.go +++ b/internal/web/handlers/auth.go @@ -11,7 +11,6 @@ import ( "mail_go/config" "mail_go/internal/auth" - "mail_go/internal/db" "mail_go/internal/store" "github.com/gin-contrib/sessions" @@ -74,19 +73,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { user, err := h.stores.Users.Authenticate(email, password) if err != nil { - failCount, _ := h.stores.Bans.IncrementFail(ip) - - if failCount >= h.banCfg.MaxFailAttempts { - banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute - banEntry := &db.BanEntry{ - IPAddress: ip, - Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount), - FailCount: failCount, - ExpiresAt: time.Now().Add(banDuration), - } - h.stores.Bans.Create(banEntry) - - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry}) + banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多") + if banned { + entry, _ := h.stores.Bans.GetByIP(ip) + c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) return } @@ -156,18 +146,10 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) { if err != nil { log.Printf("LDAP 认证失败: %v", err) - failCount, _ := h.stores.Bans.IncrementFail(ip) - if failCount >= h.banCfg.MaxFailAttempts { - banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute - banEntry := &db.BanEntry{ - IPAddress: ip, - Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount), - FailCount: failCount, - ExpiresAt: time.Now().Add(banDuration), - } - h.stores.Bans.Create(banEntry) - - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry}) + banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多") + if banned { + entry, _ := h.stores.Bans.GetByIP(ip) + c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) return } diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 83439b8..915fa6a 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -68,6 +68,18 @@ func TestRenderAllPages(t *testing.T) { }}, {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}}, {"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, + {"admin_bans", ginH{ + "currentUser": user, "activeFolder": "bans", + "rows": []struct { + db.BanEntry + Active bool + }{ + {BanEntry: db.BanEntry{IPAddress: "203.0.113.7", BanCount: 4, FailCount: 5, Reason: "第1次封禁:登录失败次数过多(第4次触发,失败5次)", ExpiresAt: now.Add(20 * time.Minute)}, Active: true}, + {BanEntry: db.BanEntry{IPAddress: "203.0.113.9", BanCount: 5, FailCount: 6, Reason: "第2次封禁:邮件协议认证失败次数过多(第5次触发,失败6次)", ExpiresAt: now.Add(-24 * time.Hour)}, Active: false}, + {BanEntry: db.BanEntry{IPAddress: "10.0.0.2", BanCount: 1, FailCount: 5, Reason: "", ExpiresAt: time.Time{}}, Active: false}, + }, + "total": 3, "page": 1, "pageSize": 20, "totalPages": 1, + }}, {"admin_protocol_logs", ginH{ "currentUser": user, "activeFolder": "protocol-logs", "logs": []db.ProtocolLog{ diff --git a/internal/web/server.go b/internal/web/server.go index 674d55f..4fc3fee 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -296,7 +296,6 @@ func (ws *WebServer) registerRoutes() { admin.POST("/outbound/:id/cancel", adminHandler.CancelOutbound) admin.GET("/bans", adminHandler.ListBans) admin.POST("/bans/:id/unban", adminHandler.UnbanIP) - admin.POST("/bans/cleanup", adminHandler.CleanupBans) admin.GET("/protocol-logs", adminHandler.ListProtocolLogs) admin.POST("/protocol-logs/cleanup", adminHandler.CleanupProtocolLogs) } diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html index 6f42ff2..5c41dd9 100644 --- a/internal/web/templates/admin/bans.html +++ b/internal/web/templates/admin/bans.html @@ -18,25 +18,21 @@ 用户管理 所有邮件 外发队列 - 协议日志 -IP黑名单 + 协议日志 + IP黑名单

IP 黑名单

-
- -
+ 阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)
- {{if not .bans}} -

暂无被封禁的 IP

- {{else}} - + + @@ -44,16 +40,24 @@ - {{range .bans}} + {{range .rows}} - + + - - + + @@ -61,6 +65,8 @@ {{end}}
ID IP 地址状态封禁次数 失败次数 原因 到期时间
{{.ID}} {{.IPAddress}} + {{if .Active}}封禁中 + {{else}}已过期{{end}} + + {{if .BanCount}} + {{if gt .BanCount 3}}第{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}} + {{else}}—{{end}} + {{.FailCount}}{{.Reason}}{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if .Reason}}{{.Reason}}{{else}}—{{end}}{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}}
+ onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
+ {{if not .rows}} +

暂无封禁记录

{{end}}
{{if .totalPages}} @@ -68,7 +74,7 @@ {{if gt .page 1}} 上一页 {{end}} - 第 {{.page}} / {{.totalPages}} 页 + 第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条) {{if lt .page .totalPages}} 下一页 {{end}} diff --git a/internal/web/templates/banned.html b/internal/web/templates/banned.html index 3a53467..e700b2d 100644 --- a/internal/web/templates/banned.html +++ b/internal/web/templates/banned.html @@ -51,6 +51,10 @@ IP 地址 {{.entry.IPAddress}}
+
+ 封禁档位 + {{if gt .entry.BanCount 3}}第 {{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}} +
原因 {{.entry.Reason}}