Lock out a source for 15 minutes after 30 failed passwords
Login and share password failures share one counter per source. A correct share password does not reset it, so it cannot be used to keep guessing the login password.
This commit is contained in:
1 parent
5ac01894e6
commit
5b326a70bf
2 files changed
+122
-12
No files matched your search
+28
-12
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user