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:
wangjia
2026-07-05 10:42:23 +08:00
parent e850a1987c
commit 3c687e5e0b
15 changed files with 894 additions and 169 deletions
+15 -79
View File
@@ -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
+2 -2
View File
@@ -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++ {