服务端安全加固:多维限流(按 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user