feat(backend): 限流/登录失败锁状态外置 Redis(todo #2)
新包 internal/ratelimit:内存/redis(GCRA+Lua) 双实现 + 出错逐调用降级内存 (fail-open 到内存不 fail-closed)。REDIS_ADDR 空=内存模式,行为与既往一致; 配置后跨重启保状态、支持多实例。miniredis 全覆盖测试,零真实外部依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -42,81 +42,17 @@ type DeviceInfo struct {
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
|
||||
// 两个维度共用同一张表:账号维度 key="<shopCode>|<username>",IP 维度 key="ip|<addr>",
|
||||
// 分别用不同阈值锁定。带 janitor 清理空闲 entry,避免攻击者用随机 key 灌爆内存。
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*limiterEntry
|
||||
janitorOnce sync.Once
|
||||
// 登录失败计数与锁定的状态存储在 internal/ratelimit(默认内存;REDIS_ADDR
|
||||
// 配置后外置 redis,跨重启保锁。原 loginLimiter 于 2026-07 迁入该包)。
|
||||
// 两个维度:账号维度 key="<shopCode>|<username>",IP 维度 key="ip|<addr>",
|
||||
// 分别用不同阈值锁定。
|
||||
func loginLocker() ratelimit.FailLocker {
|
||||
return ratelimit.Default().FailLocker()
|
||||
}
|
||||
|
||||
type limiterEntry struct {
|
||||
failures int
|
||||
lockedTill time.Time
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// loginLimiterIdleTTL:已解锁且超过该时长未活动的 entry 会被 janitor 清理。
|
||||
const loginLimiterIdleTTL = 30 * time.Minute
|
||||
|
||||
var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}}
|
||||
|
||||
// locked 返回该 key 是否处于锁定中。
|
||||
func (l *loginLimiter) locked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
e := l.entries[key]
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
e.lastSeen = time.Now()
|
||||
return time.Now().Before(e.lockedTill)
|
||||
}
|
||||
|
||||
// recordFailure 记一次失败,达到 max 阈值则锁定(max<=0 表示该维度不锁)。
|
||||
func (l *loginLimiter) recordFailure(key string, max int) {
|
||||
l.startJanitor()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
e := l.entries[key]
|
||||
if e == nil {
|
||||
e = &limiterEntry{}
|
||||
l.entries[key] = e
|
||||
}
|
||||
e.lastSeen = time.Now()
|
||||
e.failures++
|
||||
if max > 0 && e.failures >= max {
|
||||
e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute)
|
||||
e.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// reset 登录成功后清除失败计数。
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
}
|
||||
|
||||
// startJanitor 惰性启动后台清理(仅一次):每 5 分钟淘汰「未锁定且超过 TTL 未活动」的 entry。
|
||||
func (l *loginLimiter) startJanitor() {
|
||||
l.janitorOnce.Do(func() {
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
for k, e := range l.entries {
|
||||
if now.After(e.lockedTill) && now.Sub(e.lastSeen) > loginLimiterIdleTTL {
|
||||
delete(l.entries, k)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
})
|
||||
// loginLockFor 锁定时长(读配置,调用点求值以便测试改配置生效)。
|
||||
func loginLockFor() time.Duration {
|
||||
return time.Duration(config.C.Session.LockMinutes) * time.Minute
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
@@ -144,12 +80,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
ipKey = "ip|" + dev.IP
|
||||
}
|
||||
recordFail := func() {
|
||||
loginLim.recordFailure(limiterKey, config.C.Session.MaxFailures)
|
||||
loginLocker().RecordFailure(limiterKey, config.C.Session.MaxFailures, loginLockFor())
|
||||
if ipKey != "" {
|
||||
loginLim.recordFailure(ipKey, config.C.Session.IPMaxFailures)
|
||||
loginLocker().RecordFailure(ipKey, config.C.Session.IPMaxFailures, loginLockFor())
|
||||
}
|
||||
}
|
||||
if loginLim.locked(limiterKey) || (ipKey != "" && loginLim.locked(ipKey)) {
|
||||
if loginLocker().Locked(limiterKey) || (ipKey != "" && loginLocker().Locked(ipKey)) {
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "locked")
|
||||
return nil, nil, ErrTooManyAttempts
|
||||
}
|
||||
@@ -258,9 +194,9 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginLim.reset(limiterKey)
|
||||
loginLocker().Reset(limiterKey)
|
||||
if ipKey != "" {
|
||||
loginLim.reset(ipKey)
|
||||
loginLocker().Reset(ipKey)
|
||||
}
|
||||
user.LastLoginAt = &now
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestLogin_AccountLockoutAfterMaxFailures(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
config.C.Session.MaxFailures = 3
|
||||
svc := NewAuthService(db)
|
||||
defer loginLim.reset("LOCK_ACC|admin")
|
||||
defer loginLocker().Reset("LOCK_ACC|admin")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, err := svc.Login("LOCK_ACC", "admin", "wrong", DeviceInfo{Platform: "windows", IP: "10.0.0.1"})
|
||||
@@ -175,7 +175,7 @@ func TestLogin_IPLockoutAcrossAccounts(t *testing.T) {
|
||||
config.C.Session.IPMaxFailures = 4
|
||||
const attackIP = "203.0.113.9"
|
||||
svc := NewAuthService(db)
|
||||
defer loginLim.reset("ip|" + attackIP)
|
||||
defer loginLocker().Reset("ip|" + attackIP)
|
||||
|
||||
// 4 次不同用户名(invalid_user),账号 key 各不相同永不锁;IP key 累计到 4 → 锁 IP。
|
||||
for i := 0; i < 4; i++ {
|
||||
|
||||
Reference in New Issue
Block a user