- H4: sliding-window rate limiter (10 attempts / 5min per IP+username) on login and register; successful login resets the counter; pure stdlib, no new dependency - H8: docker-compose.test.yml DB/ADMIN passwords now :?required (align with main); docker-compose.main.yml AUTH_REGISTRATION_ENABLED defaults to false - M8: fix FAV_PUBLISHER_ENABLE -> FAV_PUBLISHER_ENABLED typo so the gate test actually guards the real flag Co-authored-by: Cursor <cursoragent@cursor.com>
81 lines
1.9 KiB
Go
81 lines
1.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// loginRateLimiter 基于 IP+用户名的滑动窗口登录限流器(纯 stdlib,防爆破/撞库)。
|
|
// 窗口内超过 maxAttempts 次尝试即拒绝,窗口过期后自动重置。
|
|
type loginRateLimiter struct {
|
|
mu sync.Mutex
|
|
attempts map[string][]time.Time
|
|
maxAttempts int
|
|
window time.Duration
|
|
}
|
|
|
|
func newLoginRateLimiter(maxAttempts int, window time.Duration) *loginRateLimiter {
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 10
|
|
}
|
|
if window <= 0 {
|
|
window = 5 * time.Minute
|
|
}
|
|
return &loginRateLimiter{
|
|
attempts: make(map[string][]time.Time),
|
|
maxAttempts: maxAttempts,
|
|
window: window,
|
|
}
|
|
}
|
|
|
|
// allow 判断 key 本次尝试是否放行;放行则记录一次尝试。
|
|
func (l *loginRateLimiter) allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := time.Now()
|
|
cutoff := now.Add(-l.window)
|
|
|
|
// 滑出窗口的尝试丢弃
|
|
ts := l.attempts[key]
|
|
kept := ts[:0]
|
|
for _, t := range ts {
|
|
if t.After(cutoff) {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
if len(kept) >= l.maxAttempts {
|
|
l.attempts[key] = kept
|
|
return false
|
|
}
|
|
l.attempts[key] = append(kept, now)
|
|
return true
|
|
}
|
|
|
|
// reset 认证成功后清空该 key 的计数(避免正常用户被自己的历史失败累积误伤)。
|
|
func (l *loginRateLimiter) reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.attempts, key)
|
|
}
|
|
|
|
// clientIP 取客户端 IP(优先 X-Forwarded-For 首跳,回落 RemoteAddr)。
|
|
func clientIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if first, _, _ := strings.Cut(xff, ","); strings.TrimSpace(first) != "" {
|
|
return strings.TrimSpace(first)
|
|
}
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func (l *loginRateLimiter) key(r *http.Request, username string) string {
|
|
return clientIP(r) + "|" + strings.ToLower(strings.TrimSpace(username))
|
|
}
|