diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c51d487..2d2c513 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -32,8 +32,10 @@ const ( argonThreads = 4 argonKeyLen = 32 - maxFailures = 5 + // 同一来源在窗口内累计失败 maxFailures 次(登录与分享密码合计)即封禁 lockout + maxFailures = 30 failureWindow = 15 * time.Minute + lockout = 15 * time.Minute ) var ( @@ -52,10 +54,12 @@ type Manager struct { mu sync.Mutex failures map[string][]time.Time + blocked map[string]time.Time // 来源 -> 解封时间 + now func() time.Time } func New(s *store.Store) (*Manager, error) { - m := &Manager{store: s, failures: map[string][]time.Time{}} + m := &Manager{store: s, failures: map[string][]time.Time{}, blocked: map[string]time.Time{}, now: time.Now} ok, err := m.IsSetUp() if err != nil { return nil, err @@ -149,7 +153,8 @@ func HashSharePassword(password string) string { return hashPassword(password) } -// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速 +// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速。 +// 成功不清零失败计数:否则知道某个分享密码的人可以借此反复重置计数,无限尝试登录密码 func (m *Manager) CheckSharePassword(ip, hash, password string) error { if !m.allow(ip) { return ErrRateLimited @@ -225,26 +230,37 @@ func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err err return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli()) } -// ---- 限速:同一来源在时间窗口内失败过多则拒绝 ---- +// ---- 限速:同一来源在时间窗口内失败过多则封禁一段时间 ---- func (m *Manager) allow(ip string) bool { m.mu.Lock() defer m.mu.Unlock() - cutoff := time.Now().Add(-failureWindow) + until, ok := m.blocked[ip] + if ok && m.now().Before(until) { + return false + } + delete(m.blocked, ip) + return true +} + +func (m *Manager) recordFailure(ip string) { + m.mu.Lock() + defer m.mu.Unlock() + now := m.now() + cutoff := now.Add(-failureWindow) recent := m.failures[ip][:0] for _, t := range m.failures[ip] { if t.After(cutoff) { recent = append(recent, t) } } + recent = append(recent, now) + if len(recent) >= maxFailures { + m.blocked[ip] = now.Add(lockout) + delete(m.failures, ip) + return + } m.failures[ip] = recent - return len(recent) < maxFailures -} - -func (m *Manager) recordFailure(ip string) { - m.mu.Lock() - m.failures[ip] = append(m.failures[ip], time.Now()) - m.mu.Unlock() } func (m *Manager) clearFailures(ip string) { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..53141d5 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,94 @@ +package auth + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "rawmark/internal/store" +) + +func newManager(t *testing.T) (*Manager, *time.Time) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "rawmark.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + m, err := New(st) + if err != nil { + t.Fatal(err) + } + if err := m.Setup(m.SetupCode(), "password123"); err != nil { + t.Fatal(err) + } + clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + m.now = func() time.Time { return clock } + return m, &clock +} + +// 登录与分享密码合计 30 次失败后封禁 15 分钟,期间正确密码也被拒绝 +func TestRateLimit(t *testing.T) { + m, clock := newManager(t) + share := HashSharePassword("share-pass") + for i := 0; i < maxFailures-1; i++ { + var err error + if i%2 == 0 { + _, err = m.Login("1.1.1.1", "wrong") + } else { + err = m.CheckSharePassword("1.1.1.1", share, "wrong") + } + if !errors.Is(err, ErrBadCredentials) { + t.Fatalf("第 %d 次失败: %v", i+1, err) + } + } + // 分享密码正确不清零计数 + if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); err != nil { + t.Fatal(err) + } + if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("第 30 次失败: %v", err) + } + if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) { + t.Fatalf("封禁期间正确密码也应被拒绝: %v", err) + } + if err := m.CheckSharePassword("1.1.1.1", share, "share-pass"); !errors.Is(err, ErrRateLimited) { + t.Fatalf("封禁同样作用于分享密码: %v", err) + } + if _, err := m.Login("2.2.2.2", "password123"); err != nil { + t.Fatalf("其他来源不受影响: %v", err) + } + *clock = clock.Add(lockout - time.Second) + if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) { + t.Fatalf("未满 15 分钟仍封禁: %v", err) + } + *clock = clock.Add(time.Second) + if _, err := m.Login("1.1.1.1", "password123"); err != nil { + t.Fatalf("15 分钟后解封: %v", err) + } +} + +// 窗口外的失败不计入;登录成功清零 +func TestRateLimitWindow(t *testing.T) { + m, clock := newManager(t) + for i := 0; i < maxFailures-1; i++ { + m.Login("1.1.1.1", "wrong") + } + *clock = clock.Add(failureWindow + time.Second) + for i := 0; i < maxFailures-1; i++ { + m.Login("1.1.1.1", "wrong") + } + if _, err := m.Login("1.1.1.1", "password123"); err != nil { + t.Fatalf("15 分钟前的失败不应计入: %v", err) + } + for i := 0; i < maxFailures-1; i++ { + m.Login("1.1.1.1", "wrong") + } + if _, err := m.Login("1.1.1.1", "wrong"); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("登录成功后计数应已清零: %v", err) + } + if _, err := m.Login("1.1.1.1", "password123"); !errors.Is(err, ErrRateLimited) { + t.Fatalf("清零后再失败 30 次应封禁: %v", err) + } +}