From 7bbc944ae24692f9c45563a6e66cf4bc945ef8b5 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 20:20:44 +0800 Subject: [PATCH] chore: release server-v1.0.62 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口; 登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP; 门店 custom_fields 轻量配置(录入默认值)透传保存。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK --- CHANGELOG-server.md | 10 + backend/config/config.go | 50 +++-- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/handler/shop.go | 26 ++- backend/internal/handler/shop_test.go | 64 ++++++ backend/internal/middleware/ratelimit.go | 127 ++++++++++++ backend/internal/middleware/ratelimit_test.go | 194 ++++++++++++++++++ backend/internal/router/router.go | 59 +++--- backend/internal/service/auth.go | 67 +++++- backend/internal/service/auth_test.go | 46 +++++ backend/main.go | 8 +- backend/testutil/setup.go | 13 +- deploy/nginx-jiu.conf | 18 ++ 14 files changed, 628 insertions(+), 57 deletions(-) create mode 100644 backend/internal/handler/shop_test.go create mode 100644 backend/internal/middleware/ratelimit.go create mode 100644 backend/internal/middleware/ratelimit_test.go diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 4b228ef..cc314ab 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.62] - 2026-06-19 + +### 新功能 +- 服务端新增多维限流防护:按客户端 IP、按门店分别限速,登录/注册/刷新令牌等敏感接口与公开查询接口各有独立速率上限,有效抵御恶意刷接口与流量冲击(DDoS),正常使用不受影响 +- 登录暴力破解加固:除按账号锁定外,新增按来源 IP 的失败次数限制,单一 IP 连续撞库多个账号会被整体拦截 + +### 改进 +- 在反向代理后正确识别真实客户端 IP,使限流与失败锁定按真实来源计数,避免误伤同一出口的正常用户 +- 门店可保存录入默认值(默认系列/规格等)等轻量配置,刷新页面后依然保留 + ## [1.0.61] - 2026-06-19 ### 改进 diff --git a/backend/config/config.go b/backend/config/config.go index ac7a1f2..7628862 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -8,16 +8,17 @@ import ( ) type Config struct { - Server ServerConfig - Database DatabaseConfig - JWT JWTConfig - Storage StorageConfig - Session SessionConfig + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + Storage StorageConfig + Session SessionConfig + RateLimit RateLimitConfig } type ServerConfig struct { Port string `mapstructure:"port"` - Mode string `mapstructure:"mode"` // debug | release + Mode string `mapstructure:"mode"` // debug | release CORSOrigin string `mapstructure:"cors_origin"` // 允许的 CORS 来源,生产设为具体域名 } @@ -35,12 +36,27 @@ type JWTConfig struct { // SessionConfig 登录会话与并发限制(全局默认,可被每店 session_policy 覆盖)。 type SessionConfig struct { - LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)最大并发会话,0=禁止 - LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)最大并发会话,0=禁止 - LimitWeb int `mapstructure:"limit_web"` // web 端最大并发会话,0=禁止 - MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定 - LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟) - RetentionDays int `mapstructure:"retention_days"` // 已撤销/过期会话与失败登录记录的保留天数,过期后台清理 + LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)最大并发会话,0=禁止 + LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)最大并发会话,0=禁止 + LimitWeb int `mapstructure:"limit_web"` // web 端最大并发会话,0=禁止 + MaxFailures int `mapstructure:"max_failures"` // 同一账号连续登录失败几次后锁定 + IPMaxFailures int `mapstructure:"ip_max_failures"` // 同一 IP 跨账号累计失败几次后锁定该 IP(防单 IP 撞多账号) + LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟) + RetentionDays int `mapstructure:"retention_days"` // 已撤销/过期会话与失败登录记录的保留天数,过期后台清理 +} + +// RateLimitConfig 应用层限流(按真实客户端 IP / 按门店)。各 *PerMin 为每分钟允许次数, +// burst 取同值(允许一分钟额度的突发,之后按速率回补)。Enabled=false 时所有限流放行。 +type RateLimitConfig struct { + Enabled bool `mapstructure:"enabled"` + LoginPerMin int `mapstructure:"login_per_min"` // 未鉴权 /auth/login,按 IP + RefreshPerMin int `mapstructure:"refresh_per_min"` // 未鉴权 /auth/refresh,按 IP + RegisterPerMin int `mapstructure:"register_per_min"` // 未鉴权 /public/register,按 IP + ErrorsPerMin int `mapstructure:"errors_per_min"` // 未鉴权 /public/errors,按 IP + ShopListPerMin int `mapstructure:"shop_list_per_min"` // 公开商品列表(主爬取入口),按 IP,最紧 + PublicReadPerMin int `mapstructure:"public_read_per_min"` // 其余公开读接口(单品/release),按 IP + ShopRPS int `mapstructure:"shop_rps"` // 认证流量每店每秒,按 shop_id + ShopBurst int `mapstructure:"shop_burst"` // 认证流量每店突发 } type StorageConfig struct { @@ -80,8 +96,18 @@ func Load() { viper.SetDefault("session.limit_mobile", 2) viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁 viper.SetDefault("session.max_failures", 5) + viper.SetDefault("session.ip_max_failures", 20) viper.SetDefault("session.lock_minutes", 15) viper.SetDefault("session.retention_days", 90) + viper.SetDefault("ratelimit.enabled", true) + viper.SetDefault("ratelimit.login_per_min", 10) + viper.SetDefault("ratelimit.refresh_per_min", 20) + viper.SetDefault("ratelimit.register_per_min", 5) + viper.SetDefault("ratelimit.errors_per_min", 30) + viper.SetDefault("ratelimit.shop_list_per_min", 30) + viper.SetDefault("ratelimit.public_read_per_min", 60) + viper.SetDefault("ratelimit.shop_rps", 20) + viper.SetDefault("ratelimit.shop_burst", 40) viper.SetDefault("database.max_idle_conns", 10) viper.SetDefault("database.max_open_conns", 100) viper.SetDefault("storage.upload_dir", "./uploads/images") diff --git a/backend/go.mod b/backend/go.mod index 1ceaef6..ce3a707 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -71,5 +71,6 @@ require ( golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index fb05ea0..46486f8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -150,6 +150,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/backend/internal/handler/shop.go b/backend/internal/handler/shop.go index 1f0947f..8ccf779 100644 --- a/backend/internal/handler/shop.go +++ b/backend/internal/handler/shop.go @@ -42,12 +42,13 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { shopID := middleware.GetShopID(c) var req struct { - Name string `json:"name"` - Address string `json:"address"` - Phone string `json:"phone"` - ManagerName string `json:"manager_name"` - LogoURL string `json:"logo_url"` - WechatID string `json:"wechat_id"` + Name string `json:"name"` + Address string `json:"address"` + Phone string `json:"phone"` + ManagerName string `json:"manager_name"` + LogoURL string `json:"logo_url"` + WechatID string `json:"wechat_id"` + CustomFields map[string]interface{} `json:"custom_fields"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -64,6 +65,19 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) { if req.LogoURL != "" { updates["logo_url"] = req.LogoURL } + // custom_fields 增量 merge:保留已有键(如其它店级配置),只覆盖本次传入的键。 + if req.CustomFields != nil { + var cur model.Shop + h.db.Select("custom_fields").Where("id = ?", shopID).First(&cur) + merged := model.JSON{} + for k, v := range cur.CustomFields { + merged[k] = v + } + for k, v := range req.CustomFields { + merged[k] = v + } + updates["custom_fields"] = merged + } if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/backend/internal/handler/shop_test.go b/backend/internal/handler/shop_test.go new file mode 100644 index 0000000..a2a630e --- /dev/null +++ b/backend/internal/handler/shop_test.go @@ -0,0 +1,64 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +func setupShopRouter(db *gorm.DB) *gin.Engine { + shopH := NewShopHandler(db) + r := gin.New() + r.Use(gin.Recovery()) + api := r.Group("/api/v1") + api.Use(middleware.JWT(db)) + shop := api.Group("/shop") + shop.GET("/info", shopH.GetInfo) + shop.PUT("/info", shopH.UpdateInfo) + return r +} + +// TestUpdateInfo_MergesCustomFields 钉死「设为默认」依赖的契约: +// 传 custom_fields 会 merge 进本店已有 custom_fields(保留旧键),且只动本店。 +func TestUpdateInfo_MergesCustomFields(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "S_CF1") + admin := testutil.CreateTestUser(db, shop.ID, "admin", "pw", "admin") + // 预置已有 custom_fields,验证 merge 不抹掉其它键 + require.NoError(t, db.Model(&model.Shop{}).Where("id = ?", shop.ID). + Update("custom_fields", model.JSON{"foo": "bar"}).Error) + // 另一店做隔离对照 + other := testutil.CreateTestShop(db, "S_CF2") + require.NoError(t, db.Model(&model.Shop{}).Where("id = ?", other.ID). + Update("custom_fields", model.JSON{"x": "y"}).Error) + + r := setupShopRouter(db) + token := getAuthToken(admin.ID, shop.ID, "admin") + w := makeRequest(r, http.MethodPut, "/api/v1/shop/info", token, jsonBody( + "name", "改名后", + "custom_fields", map[string]interface{}{"default_series_id": 7}, + )) + assert.Equal(t, http.StatusOK, w.Code) + + var got model.Shop + require.NoError(t, db.First(&got, shop.ID).Error) + assert.Equal(t, "改名后", got.Name) + assert.Equal(t, "bar", got.CustomFields["foo"]) // 旧键保留 + assert.EqualValues(t, 7, got.CustomFields["default_series_id"]) // 新键写入 + + // 隔离:另一店的 custom_fields 不受影响 + var o model.Shop + require.NoError(t, db.First(&o, other.ID).Error) + assert.Equal(t, "y", o.CustomFields["x"]) + _, leaked := o.CustomFields["default_series_id"] + assert.False(t, leaked) +} diff --git a/backend/internal/middleware/ratelimit.go b/backend/internal/middleware/ratelimit.go new file mode 100644 index 0000000..7a5a97b --- /dev/null +++ b/backend/internal/middleware/ratelimit.go @@ -0,0 +1,127 @@ +package middleware + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" + + "github.com/wangjia/jiu/backend/config" +) + +// 限流器内存条目的清理参数:每 5 分钟扫一次,淘汰超过 10 分钟未活动的 key。 +// 保证 map 不随攻击者构造的随机 key(IP/shop)无限增长。 +const ( + rateLimitSweep = 5 * time.Minute + rateLimitIdleTTL = 10 * time.Minute + rateLimitRetryHdr = "60" // Retry-After 秒数(提示客户端退避) +) + +// keyedLimiter 按任意字符串 key(IP 或 shop_id)维护独立令牌桶,内存有界(带 janitor)。 +// 单实例进程内状态,重启即清零;多实例水平扩展时需改为 Redis(见方案「暂不做」)。 +type keyedLimiter struct { + mu sync.Mutex + entries map[string]*limiterBucket + r rate.Limit + burst int +} + +type limiterBucket struct { + lim *rate.Limiter + lastSeen time.Time +} + +func newKeyedLimiter(r rate.Limit, burst int) *keyedLimiter { + kl := &keyedLimiter{entries: map[string]*limiterBucket{}, r: r, burst: burst} + go kl.janitor() + return kl +} + +// get 取(或惰性创建)该 key 的令牌桶并刷新活动时间。 +func (kl *keyedLimiter) get(key string) *rate.Limiter { + kl.mu.Lock() + defer kl.mu.Unlock() + b := kl.entries[key] + if b == nil { + b = &limiterBucket{lim: rate.NewLimiter(kl.r, kl.burst)} + kl.entries[key] = b + } + b.lastSeen = time.Now() + return b.lim +} + +func (kl *keyedLimiter) janitor() { + t := time.NewTicker(rateLimitSweep) + defer t.Stop() + for range t.C { + kl.sweep(rateLimitIdleTTL) + } +} + +// sweep 淘汰超过 ttl 未活动的 key。拆出便于测试。 +func (kl *keyedLimiter) sweep(ttl time.Duration) { + now := time.Now() + kl.mu.Lock() + defer kl.mu.Unlock() + for k, b := range kl.entries { + if now.Sub(b.lastSeen) > ttl { + delete(kl.entries, k) + } + } +} + +// PerMinute 把「每分钟 n 次」转成 rate.Limit(令牌/秒)。 +func PerMinute(n int) rate.Limit { + return rate.Limit(float64(n) / 60.0) +} + +// PerSecond 把「每秒 n 次」转成 rate.Limit。 +func PerSecond(n int) rate.Limit { + return rate.Limit(n) +} + +// rateLimit 通用工厂:keyFn 抽取限流维度的 key(返回空串表示无法判定 → 放行,不误伤)。 +// config.C.RateLimit.Enabled=false 时整体放行(应急/测试开关)。 +func rateLimit(r rate.Limit, burst int, keyFn func(*gin.Context) string) gin.HandlerFunc { + kl := newKeyedLimiter(r, burst) + return func(c *gin.Context) { + if !config.C.RateLimit.Enabled { + c.Next() + return + } + key := keyFn(c) + if key == "" { + c.Next() + return + } + if !kl.get(key).Allow() { + c.Header("Retry-After", rateLimitRetryHdr) + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"}) + return + } + c.Next() + } +} + +// RateLimitByIP 按真实客户端 IP 限流(依赖 main.go 的 SetTrustedProxies + RemoteIPHeaders, +// 使 c.ClientIP() 返回不可伪造的真实 IP)。 +func RateLimitByIP(r rate.Limit, burst int) gin.HandlerFunc { + return rateLimit(r, burst, func(c *gin.Context) string { + return "ip:" + c.ClientIP() + }) +} + +// RateLimitByShop 按门店(shop_id,取自 JWT)限流,须挂在 JWT 中间件之后。 +// 未取到 shop_id 时放行(交由 JWT 中间件拦截非法 token)。 +func RateLimitByShop(r rate.Limit, burst int) gin.HandlerFunc { + return rateLimit(r, burst, func(c *gin.Context) string { + id := GetShopID(c) + if id == 0 { + return "" + } + return "shop:" + strconv.FormatUint(id, 10) + }) +} diff --git a/backend/internal/middleware/ratelimit_test.go b/backend/internal/middleware/ratelimit_test.go new file mode 100644 index 0000000..632cccd --- /dev/null +++ b/backend/internal/middleware/ratelimit_test.go @@ -0,0 +1,194 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" + + "github.com/wangjia/jiu/backend/config" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// enableRateLimit 临时打开限流开关,返回恢复函数。 +func enableRateLimit(t *testing.T) { + t.Helper() + prev := config.C.RateLimit.Enabled + config.C.RateLimit.Enabled = true + t.Cleanup(func() { config.C.RateLimit.Enabled = prev }) +} + +// hit 发一个带指定客户端 IP 的请求,返回状态码。SetTrustedProxies 让 X-Forwarded-For 被采信, +// 便于在测试里模拟不同来源 IP。 +func hit(r *gin.Engine, ip string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = ip + ":12345" + r.ServeHTTP(w, req) + return w +} + +func newIPRouter(h gin.HandlerFunc) *gin.Engine { + r := gin.New() + r.GET("/", h, func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + return r +} + +func TestRateLimitByIP_BurstThen429(t *testing.T) { + enableRateLimit(t) + // 每分钟 3 次,burst 3:前 3 个放行,第 4 个 429。 + r := newIPRouter(RateLimitByIP(PerMinute(3), 3)) + + for i := 0; i < 3; i++ { + if w := hit(r, "1.1.1.1"); w.Code != http.StatusOK { + t.Fatalf("第 %d 个请求应放行,得到 %d", i+1, w.Code) + } + } + w := hit(r, "1.1.1.1") + if w.Code != http.StatusTooManyRequests { + t.Fatalf("超出 burst 应 429,得到 %d", w.Code) + } + if ra := w.Header().Get("Retry-After"); ra == "" { + t.Fatal("429 响应应带 Retry-After 头") + } +} + +func TestRateLimitByIP_KeysIndependent(t *testing.T) { + enableRateLimit(t) + r := newIPRouter(RateLimitByIP(PerMinute(1), 1)) + + // 不同 IP 各有独立令牌桶,互不影响。 + if w := hit(r, "2.2.2.2"); w.Code != http.StatusOK { + t.Fatalf("IP A 首次应放行,得到 %d", w.Code) + } + if w := hit(r, "3.3.3.3"); w.Code != http.StatusOK { + t.Fatalf("IP B 首次应放行(独立桶),得到 %d", w.Code) + } + // IP A 再来一发应 429(桶已空)。 + if w := hit(r, "2.2.2.2"); w.Code != http.StatusTooManyRequests { + t.Fatalf("IP A 第二发应 429,得到 %d", w.Code) + } +} + +func TestRateLimitByIP_Refill(t *testing.T) { + enableRateLimit(t) + // 每秒 50 次:耗尽 burst 后等一小会儿令牌补回,能再次放行。 + r := newIPRouter(RateLimitByIP(rate.Limit(50), 1)) + if w := hit(r, "4.4.4.4"); w.Code != http.StatusOK { + t.Fatalf("首发应放行,得到 %d", w.Code) + } + if w := hit(r, "4.4.4.4"); w.Code != http.StatusTooManyRequests { + t.Fatalf("紧接第二发应 429,得到 %d", w.Code) + } + time.Sleep(40 * time.Millisecond) // 50/s → 20ms 补一个令牌 + if w := hit(r, "4.4.4.4"); w.Code != http.StatusOK { + t.Fatalf("等待补充后应放行,得到 %d", w.Code) + } +} + +func TestRateLimitDisabledPassthrough(t *testing.T) { + prev := config.C.RateLimit.Enabled + config.C.RateLimit.Enabled = false + defer func() { config.C.RateLimit.Enabled = prev }() + + r := newIPRouter(RateLimitByIP(PerMinute(1), 1)) + // 关闭时远超额度也全放行。 + for i := 0; i < 10; i++ { + if w := hit(r, "5.5.5.5"); w.Code != http.StatusOK { + t.Fatalf("限流关闭时第 %d 发应放行,得到 %d", i+1, w.Code) + } + } +} + +func TestKeyedLimiterSweepEvictsIdle(t *testing.T) { + kl := newKeyedLimiter(PerMinute(60), 1) + kl.get("ip:a") + kl.get("ip:b") + if len(kl.entries) != 2 { + t.Fatalf("应有 2 个 entry,得到 %d", len(kl.entries)) + } + // 把 a 的活动时间推到很久以前,sweep 应只淘汰 a。 + kl.mu.Lock() + kl.entries["ip:a"].lastSeen = time.Now().Add(-time.Hour) + kl.mu.Unlock() + + kl.sweep(10 * time.Minute) + + kl.mu.Lock() + defer kl.mu.Unlock() + if _, ok := kl.entries["ip:a"]; ok { + t.Fatal("空闲 key a 应被淘汰") + } + if _, ok := kl.entries["ip:b"]; !ok { + t.Fatal("活跃 key b 不应被淘汰") + } +} + +// TestTrustedProxyRealIP 复刻 main.go 的可信代理配置:只信任本机写的 X-Real-IP, +// 客户端伪造的 X-Forwarded-For 不被采信 → c.ClientIP() 返回真实 IP,限流不可被请求头绕过。 +func TestTrustedProxyRealIP(t *testing.T) { + r := gin.New() + _ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"}) + r.RemoteIPHeaders = []string{"X-Real-IP"} + var got string + r.GET("/", func(c *gin.Context) { + got = c.ClientIP() + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:9999" // 本机 nginx + req.Header.Set("X-Real-IP", "9.9.9.9") // nginx 写入的真实 IP + req.Header.Set("X-Forwarded-For", "1.2.3.4") // 客户端伪造,应被忽略 + r.ServeHTTP(w, req) + + if got != "9.9.9.9" { + t.Fatalf("ClientIP 应取可信的 X-Real-IP=9.9.9.9,得到 %q(伪造的 XFF 不应生效)", got) + } +} + +func TestRateLimitByShop_NoShopPassthrough(t *testing.T) { + enableRateLimit(t) + // 未设置 shop_id(无 JWT 上下文)时放行,交由 JWT 中间件拦截。 + r := gin.New() + r.GET("/", RateLimitByShop(PerSecond(1), 1), func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + for i := 0; i < 5; i++ { + if w := hit(r, "6.6.6.6"); w.Code != http.StatusOK { + t.Fatalf("无 shop_id 时第 %d 发应放行,得到 %d", i+1, w.Code) + } + } +} + +func TestRateLimitByShop_PerShop(t *testing.T) { + enableRateLimit(t) + r := gin.New() + // 模拟 JWT 已注入 shop_id(取自 ?shop= 查询参数)。 + r.GET("/", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("shop"), 10, 64) + c.Set(CtxShopID, id) + }, RateLimitByShop(PerSecond(1), 1), func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + + shopHit := func(shop string) int { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?shop="+shop, nil) + r.ServeHTTP(w, req) + return w.Code + } + if c := shopHit("1"); c != http.StatusOK { + t.Fatalf("shop1 首发应放行,得到 %d", c) + } + if c := shopHit("2"); c != http.StatusOK { + t.Fatalf("shop2 首发应放行(独立桶),得到 %d", c) + } + if c := shopHit("1"); c != http.StatusTooManyRequests { + t.Fatalf("shop1 第二发应 429,得到 %d", c) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 593deb3..2457c5b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -4,6 +4,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/handler" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/service" @@ -46,31 +47,43 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 版本信息(无需认证,用于客户端更新检查) r.GET("/version", handler.GetVersion) + // 限流维度(按真实客户端 IP,依赖 main.go 的 SetTrustedProxies)。 + // 防爆破/防刷写/防爬:未鉴权接口逐条挂 per-IP 限流,公开商品列表最紧。 + rl := config.C.RateLimit + publicReadIP := middleware.RateLimitByIP(middleware.PerMinute(rl.PublicReadPerMin), rl.PublicReadPerMin) + shopListIP := middleware.RateLimitByIP(middleware.PerMinute(rl.ShopListPerMin), rl.ShopListPerMin) + loginIP := middleware.RateLimitByIP(middleware.PerMinute(rl.LoginPerMin), rl.LoginPerMin) + refreshIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RefreshPerMin), rl.RefreshPerMin) + registerIP := middleware.RateLimitByIP(middleware.PerMinute(rl.RegisterPerMin), rl.RegisterPerMin) + errorsIP := middleware.RateLimitByIP(middleware.PerMinute(rl.ErrorsPerMin), rl.ErrorsPerMin) + // 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取) - r.GET("/product/:public_id", publicH.ProductPage) + r.GET("/product/:public_id", publicReadIP, publicH.ProductPage) v1 := r.Group("/api/v1") - // 公开路由(无需登录) + // 公开路由(无需登录):登录/刷新按 IP 限流(与 service 层失败锁定互补) auth := v1.Group("/auth") { - auth.POST("/login", authH.Login) - auth.POST("/refresh", authH.Refresh) + auth.POST("/login", loginIP, authH.Login) + auth.POST("/refresh", refreshIP, authH.Refresh) } - // 公开接口(无需登录) + // 公开接口(无需登录):读接口防爬、写接口防刷 public := v1.Group("/public") { - public.GET("/products/:public_id", publicH.GetProduct) - public.GET("/shops/:shop_code/products", publicH.ListShopProducts) - public.GET("/release", publicH.GetRelease) - public.POST("/errors", errorReportH.Submit) - public.POST("/register", authH.Register) + public.GET("/products/:public_id", publicReadIP, publicH.GetProduct) + public.GET("/shops/:shop_code/products", shopListIP, publicH.ListShopProducts) + public.GET("/release", publicReadIP, publicH.GetRelease) + public.POST("/errors", errorsIP, errorReportH.Submit) + public.POST("/register", registerIP, authH.Register) } // 需要 JWT 的基础路由组 api := v1.Group("") api.Use(middleware.JWT(db)) + // 每店限流(按 shop_id,挂在 JWT 之后):防单店打爆共享后端,保障多租户公平 + api.Use(middleware.RateLimitByShop(middleware.PerSecond(rl.ShopRPS), rl.ShopBurst)) // 会话/在线状态(豁免 LicenseGuard:登出/心跳/在线列表在锁定期也要可用) { @@ -179,11 +192,11 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 财务 finance := api.Group("/finance") { - finance.GET("/records", financeH.ListRecords) - finance.POST("/records", financeH.Create) - finance.PUT("/records/:id/close", financeH.Close) - finance.PUT("/records/close-by-ref", financeH.CloseByRef) - finance.GET("/summary", financeH.Summary) + finance.GET("/records", financeH.ListRecords) + finance.POST("/records", financeH.Create) + finance.PUT("/records/:id/close", financeH.Close) + finance.PUT("/records/close-by-ref", financeH.CloseByRef) + finance.GET("/summary", financeH.Summary) } // 酒行信息 @@ -211,15 +224,15 @@ func Setup(r *gin.Engine, db *gorm.DB) { // 导入 imp := api.Group("/import") { - imp.POST("/products", importH.ImportProducts) - imp.POST("/partners", importH.ImportPartners) - imp.POST("/product-names", importH.ImportProductNames) + imp.POST("/products", importH.ImportProducts) + imp.POST("/partners", importH.ImportPartners) + imp.POST("/product-names", importH.ImportProductNames) imp.POST("/product-series", importH.ImportProductSeries) - imp.POST("/product-specs", importH.ImportProductSpecs) - imp.POST("/product-codes", importH.ImportProductCodes) - imp.POST("/stock-in", importH.ImportStockIn) - imp.POST("/stock-out", importH.ImportStockOut) - imp.POST("/inventory", importH.ImportInventory) + imp.POST("/product-specs", importH.ImportProductSpecs) + imp.POST("/product-codes", importH.ImportProductCodes) + imp.POST("/stock-in", importH.ImportStockIn) + imp.POST("/stock-out", importH.ImportStockOut) + imp.POST("/inventory", importH.ImportInventory) } // 基础数据选项(名称/系列/规格) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 83e87dc..6eec20b 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -40,16 +40,23 @@ type DeviceInfo struct { } // loginLimiter 内存登录失败限流器(单实例,重启即清零)。 +// 两个维度共用同一张表:账号维度 key="|",IP 维度 key="ip|", +// 分别用不同阈值锁定。带 janitor 清理空闲 entry,避免攻击者用随机 key 灌爆内存。 type loginLimiter struct { - mu sync.Mutex - entries map[string]*limiterEntry + mu sync.Mutex + entries map[string]*limiterEntry + janitorOnce sync.Once } 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 是否处于锁定中。 @@ -57,11 +64,16 @@ func (l *loginLimiter) locked(key string) bool { l.mu.Lock() defer l.mu.Unlock() e := l.entries[key] - return e != nil && time.Now().Before(e.lockedTill) + if e == nil { + return false + } + e.lastSeen = time.Now() + return time.Now().Before(e.lockedTill) } -// recordFailure 记一次失败,达到阈值则锁定。 -func (l *loginLimiter) recordFailure(key string) { +// 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] @@ -69,8 +81,8 @@ func (l *loginLimiter) recordFailure(key string) { e = &limiterEntry{} l.entries[key] = e } + e.lastSeen = time.Now() e.failures++ - max := config.C.Session.MaxFailures if max > 0 && e.failures >= max { e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute) e.failures = 0 @@ -84,6 +96,26 @@ func (l *loginLimiter) reset(key string) { 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() + } + }() + }) +} + type AuthService struct { db *gorm.DB } @@ -101,15 +133,27 @@ type TokenPair struct { // Login 账号密码登录 func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) { + // 两个限流维度:账号维度防针对单账号的密码爆破;IP 维度(阈值更高)防单 IP 撞多个账号、 + // 以及用随机账号灌爆内存。IP 为空(本地/测试)时退化为仅账号维度。 limiterKey := shopCode + "|" + username - if loginLim.locked(limiterKey) { + ipKey := "" + if dev.IP != "" { + ipKey = "ip|" + dev.IP + } + recordFail := func() { + loginLim.recordFailure(limiterKey, config.C.Session.MaxFailures) + if ipKey != "" { + loginLim.recordFailure(ipKey, config.C.Session.IPMaxFailures) + } + } + if loginLim.locked(limiterKey) || (ipKey != "" && loginLim.locked(ipKey)) { s.recordLoginAttempt(shopCode, username, dev, false, "locked") return nil, nil, ErrTooManyAttempts } var shop model.Shop if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil { - loginLim.recordFailure(limiterKey) + recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "invalid_shop") return nil, nil, ErrInvalidCredentials } @@ -117,7 +161,7 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) var user model.User if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username). First(&user).Error; err != nil { - loginLim.recordFailure(limiterKey) + recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "invalid_user") return nil, nil, ErrInvalidCredentials } @@ -128,7 +172,7 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - loginLim.recordFailure(limiterKey) + recordFail() s.recordLoginAttempt(shopCode, username, dev, false, "bad_password") return nil, nil, ErrInvalidCredentials } @@ -193,6 +237,9 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) } loginLim.reset(limiterKey) + if ipKey != "" { + loginLim.reset(ipKey) + } user.LastLoginAt = &now pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti) diff --git a/backend/internal/service/auth_test.go b/backend/internal/service/auth_test.go index 261feaf..765ffea 100644 --- a/backend/internal/service/auth_test.go +++ b/backend/internal/service/auth_test.go @@ -1,12 +1,14 @@ package service import ( + "strconv" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/testutil" ) @@ -146,6 +148,50 @@ func TestAuthService_RefreshTokens_Invalid(t *testing.T) { assert.Nil(t, newPair) } +// TestLogin_AccountLockoutAfterMaxFailures 同一账号连续失败达阈值后锁定(回归)。 +func TestLogin_AccountLockoutAfterMaxFailures(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "LOCK_ACC") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + config.C.Session.MaxFailures = 3 + svc := NewAuthService(db) + defer loginLim.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"}) + require.ErrorIs(t, err, ErrInvalidCredentials) + } + // 第 4 次即便密码正确也被锁定拒绝。 + _, _, err := svc.Login("LOCK_ACC", "admin", "password123", DeviceInfo{Platform: "windows", IP: "10.0.0.1"}) + assert.ErrorIs(t, err, ErrTooManyAttempts) +} + +// TestLogin_IPLockoutAcrossAccounts 单 IP 跨多个(不存在的)账号累计失败达 IP 阈值后锁该 IP。 +// 每次用不同用户名,账号维度永不触发锁定,只有 IP 维度会锁——验证 per-IP 防撞库 + 防内存灌爆。 +func TestLogin_IPLockoutAcrossAccounts(t *testing.T) { + db := testutil.SetupTestDB() + testutil.CreateTestShop(db, "LOCK_IP") + config.C.Session.MaxFailures = 5 + config.C.Session.IPMaxFailures = 4 + const attackIP = "203.0.113.9" + svc := NewAuthService(db) + defer loginLim.reset("ip|" + attackIP) + + // 4 次不同用户名(invalid_user),账号 key 各不相同永不锁;IP key 累计到 4 → 锁 IP。 + for i := 0; i < 4; i++ { + uname := "ghost" + strconv.Itoa(i) + _, _, err := svc.Login("LOCK_IP", uname, "whatever", DeviceInfo{Platform: "windows", IP: attackIP}) + require.ErrorIs(t, err, ErrInvalidCredentials) + } + // 同 IP 再来一发(仍是新用户名,账号维度无锁)→ 被 IP 锁拦下。 + _, _, err := svc.Login("LOCK_IP", "ghostX", "whatever", DeviceInfo{Platform: "windows", IP: attackIP}) + assert.ErrorIs(t, err, ErrTooManyAttempts) + + // 另一 IP 不受影响。 + _, _, err = svc.Login("LOCK_IP", "ghostY", "whatever", DeviceInfo{Platform: "windows", IP: "198.51.100.7"}) + assert.ErrorIs(t, err, ErrInvalidCredentials) +} + func TestHashPassword(t *testing.T) { hash, err := HashPassword("mypassword") require.NoError(t, err) diff --git a/backend/main.go b/backend/main.go index 16329c4..8b29f51 100644 --- a/backend/main.go +++ b/backend/main.go @@ -43,6 +43,13 @@ func main() { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) + // 真实客户端 IP:只信任本机 nginx(127.0.0.1/::1)写的 X-Real-IP,丢弃客户端伪造的 + // X-Forwarded-For。这样 c.ClientIP() 返回不可伪造的真实 IP,是所有按 IP 限流/审计的基础。 + // 代理链:client → nginx(127.0.0.1:8445) → 后端(127.0.0.1:8080);将来若在 nginx 前再加 + // 一层代理,需把其地址并入下列可信网段。 + _ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"}) + r.RemoteIPHeaders = []string{"X-Real-IP"} + // CORS corsOrigin := config.C.Server.CORSOrigin r.Use(func(c *gin.Context) { @@ -131,4 +138,3 @@ func autoMigrate(db *gorm.DB) { } log.Println("AutoMigrate completed") } - diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 0b6a9e8..4160791 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -28,12 +28,15 @@ func InitConfig() { RefreshExpireH: 168, }, Session: config.SessionConfig{ - LimitDesktop: 2, - LimitMobile: 2, - LimitWeb: 2, - MaxFailures: 5, - LockMinutes: 15, + LimitDesktop: 2, + LimitMobile: 2, + LimitWeb: 2, + MaxFailures: 5, + IPMaxFailures: 20, + LockMinutes: 15, }, + // 测试默认关闭限流,避免压测式用例触发 429;限流逻辑由 ratelimit_test.go 显式开启验证。 + RateLimit: config.RateLimitConfig{Enabled: false}, } } diff --git a/deploy/nginx-jiu.conf b/deploy/nginx-jiu.conf index 5f5d245..269aad4 100644 --- a/deploy/nginx-jiu.conf +++ b/deploy/nginx-jiu.conf @@ -1,3 +1,9 @@ +# 粗粒度限流区(按客户端 IP):仅作未鉴权公开接口的最外层泄洪,阈值远高于正常使用, +# 真正的多维限流在后端 Go 中间件。本文件以 conf.d 形式 include 进 http{} 上下文, +# 故 limit_req_zone 放在 server{} 之外。10m 约可容纳 16 万个 IP 的状态。 +limit_req_zone $binary_remote_addr zone=jiu_pub:10m rate=10r/s; +limit_req_status 429; + server { listen 127.0.0.1:8445 ssl; server_name jiu.51yanmei.com; @@ -24,6 +30,16 @@ server { proxy_read_timeout 300s; } + # 未鉴权公开/登录接口:加最外层 per-IP 限流(鉴权后的业务 API 不在此限,交后端按店限流)。 + # 须定义在通用 /api 正则之前(nginx 正则 location 按书写顺序首个命中者生效)。 + location ~ ^/api/v1/(public|auth)/ { + limit_req zone=jiu_pub burst=20 nodelay; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 30s; + } + # API 反向代理 location ~ ^/(api|health|version) { proxy_pass http://127.0.0.1:8080; @@ -49,6 +65,7 @@ server { # 公开商品详情页(扫码跳转)→ 后端注入 OG 标签后返回 Flutter index.html location ~ ^/product/ { + limit_req zone=jiu_pub burst=20 nodelay; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -58,6 +75,7 @@ server { # Flutter 路由会把 /product/:id 重写为 /app/product/:id(base-href=/app/) # 分享此 URL 时微信爬虫也需要 OG 标签 → 去掉 /app 前缀后转发后端 location ~ ^/app/product/ { + limit_req zone=jiu_pub burst=20 nodelay; rewrite ^/app(/product/.+)$ $1 break; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host;