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:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+51
-12
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
// 页面访问时顺带清理过期日志,避免日志表无限增长
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,25 +18,21 @@
|
||||
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
|
||||
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<h2>IP 黑名单</h2>
|
||||
<form method="POST" action="/admin/bans/cleanup" style="display:inline;">
|
||||
<button type="submit" class="btn btn-primary">清理过期记录</button>
|
||||
</form>
|
||||
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span>
|
||||
</div>
|
||||
{{if not .bans}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无被封禁的 IP</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP 地址</th>
|
||||
<th>状态</th>
|
||||
<th>封禁次数</th>
|
||||
<th>失败次数</th>
|
||||
<th>原因</th>
|
||||
<th>到期时间</th>
|
||||
@@ -44,16 +40,24 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .bans}}
|
||||
{{range .rows}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.IPAddress}}</td>
|
||||
<td>
|
||||
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span>
|
||||
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .BanCount}}
|
||||
{{if gt .BanCount 3}}第{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}}
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
<td>{{.FailCount}}</td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
|
||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}}</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
|
||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
|
||||
<button type="submit" class="btn btn-primary btn-sm">解封</button>
|
||||
</form>
|
||||
</td>
|
||||
@@ -61,6 +65,8 @@
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{if not .rows}}
|
||||
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无封禁记录</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .totalPages}}
|
||||
@@ -68,7 +74,7 @@
|
||||
{{if gt .page 1}}
|
||||
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
|
||||
{{if lt .page .totalPages}}
|
||||
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
|
||||
{{end}}
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
<span class="detail-label">IP 地址</span>
|
||||
<span class="detail-value">{{.entry.IPAddress}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">封禁档位</span>
|
||||
<span class="detail-value">{{if gt .entry.BanCount 3}}第 {{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">原因</span>
|
||||
<span class="detail-value">{{.entry.Reason}}</span>
|
||||
|
||||
Reference in New Issue
Block a user