chore: release server-v1.0.62
Deploy Server / release-deploy-server (push) Successful in 51s

服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口;
登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP;
门店 custom_fields 轻量配置(录入默认值)透传保存。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
This commit is contained in:
wangjia
2026-06-19 20:20:44 +08:00
parent 0d967e899a
commit 7bbc944ae2
14 changed files with 628 additions and 57 deletions
+57 -10
View File
@@ -40,16 +40,23 @@ type DeviceInfo struct {
}
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
// 两个维度共用同一张表:账号维度 key="<shopCode>|<username>"IP 维度 key="ip|<addr>"
// 分别用不同阈值锁定。带 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)