Files
Scoreboard/server/internal/auth/ratelimit.go
T
AskaEth 2c983bcfdf fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试:

- 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、
  scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status)
  过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成
  服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED,
  message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。
- 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的
  原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文,
  日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数,
  拦截点不再退化成 response.go 里的构造函数。
- /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」),
  超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号,
  数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。
- 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential):
  401 的语义是「本请求没有有效会话」,而这里会话是好的。
- 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动,
  否则堆键数就能冲掉别人的计数)。
- createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。
  数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results /
  score_ledger 的级联,风险高不做),这一层先兜住。
- 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、
  5xx 不外泄原文、限流键清理不误删。
2026-09-11 14:28:25 +08:00

86 lines
2.0 KiB
Go

package auth
import (
"sync"
"time"
)
// Limiter 是按键(IP / 用户)计数的滑动窗口限流器。
// 进程内内存实现:现场单实例部署足够,不引入额外存储。
type Limiter struct {
mu sync.Mutex
hits map[string][]time.Time
limit int
window time.Duration
now func() time.Time
}
// NewLimiter 创建限流器;limit <= 0 表示不限流。
func NewLimiter(limit int, window time.Duration, now func() time.Time) *Limiter {
if now == nil {
now = time.Now
}
return &Limiter{hits: make(map[string][]time.Time), limit: limit, window: window, now: now}
}
// sweepThreshold 是触发全表清理的键数量。
//
// hits 只在「这个键被再次访问」时才修剪自己的时间片,所以一个只来一次的
// 随机 IP(或伪造 X-Forwarded-For 的扫描器)留下的空键会一直躺在 map 里:
// 单机内网虽然打不满,但这个 map 没有上界本身就是隐患,超过阈值就顺手清一遍。
const sweepThreshold = 1024
// Allow 记录一次访问并报告是否放行。
func (l *Limiter) Allow(key string) bool {
if l == nil || l.limit <= 0 {
return true
}
now := l.now()
cutoff := now.Add(-l.window)
l.mu.Lock()
defer l.mu.Unlock()
kept := l.hits[key][:0]
for _, t := range l.hits[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.limit {
l.hits[key] = kept
return false
}
l.hits[key] = append(kept, now)
if len(l.hits) > sweepThreshold {
l.sweepLocked(cutoff)
}
return true
}
// sweepLocked 删掉窗口内已经没有任何记录的键。调用方必须已持有 l.mu。
func (l *Limiter) sweepLocked(cutoff time.Time) {
for key, times := range l.hits {
live := false
for _, t := range times {
if t.After(cutoff) {
live = true
break
}
}
if !live {
delete(l.hits, key)
}
}
}
// Reset 清空某个键的计数,例如登录成功后不再累计失败次数。
func (l *Limiter) Reset(key string) {
if l == nil {
return
}
l.mu.Lock()
delete(l.hits, key)
l.mu.Unlock()
}