Files
mailgo/internal/store/ban_store.go
T
kevin f2493da03e 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 次封禁档位;新增档位升级与列表过滤单测
2026-08-19 19:07:25 +08:00

155 lines
5.0 KiB
Go

package store
import (
"time"
"mail_go/internal/db"
"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
}
// banStoreGorm implements BanStore using GORM.
type banStoreGorm struct {
db *gorm.DB
}
// newBanStore creates a new GORM-backed BanStore.
func newBanStore(database *gorm.DB) BanStore {
return &banStoreGorm{db: database}
}
// Create inserts a new ban entry record.
func (s *banStoreGorm) Create(entry *db.BanEntry) error {
return s.db.Create(entry).Error
}
// GetByIP retrieves the most recent ban entry for a given IP address.
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
var entry db.BanEntry
if err := s.db.Where("ip_address = ?", ip).Order("id DESC").First(&entry).Error; err != nil {
return nil, err
}
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
}
// 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
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 := query.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
return nil, 0, err
}
return entries, total, nil
}
// IsBanned checks whether an IP address is currently banned.
// An IP is considered banned if there is a record with expires_at in the future.
func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
var entry db.BanEntry
if err := s.db.Where("ip_address = ? AND expires_at > ?", ip, time.Now()).First(&entry).Error; err != nil {
return false, nil
}
return true, &entry
}
// IncrementFail increments the fail count for an IP address.
// 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
err := s.db.Where("ip_address = ?", ip).First(&entry).Error
if err != nil {
// No record exists, create a new one
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 {
return 0, createErr
}
return 1, nil
}
// Record exists, increment fail count
newCount := entry.FailCount + 1
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil {
return 0, updateErr
}
return newCount, nil
}
// 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
}