## 缺陷 `SELECT MIN(ts) FROM rate_limits ...` 的聚合结果被 SQLite 驱动按 **string** 返回,扫进 `*time.Time` 失败 ⇒ 落到兜底 `return false, 60`。 ⇒ 所有 429 的 `retry_after` 恒为 60,与真实剩余窗口 (最长 `sessionRateWindow` = 1h)完全无关。调用方拿到的重试提示是错的: 限流窗口还有 55 分钟,它却说 60 秒后重试。 ## 修法 `SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2 ORDER BY ts ASC LIMIT 1` 排序取值走**结果集本身**,驱动按列类型给 `time.Time`;语义等价。 ★ 同一形状的坑今天已出现两次:上午 2h 冷静期因 UTC vs HKT 差 8 小时而形同虚设, 晚上权限记账因两处 `if` 守卫而静默失效。**根子都是「SQLite 侧的时间/类型处理 与直觉不符」,而症状在别处。** ## 判据 4 格(retry_after 反映真实窗口 / 绝不超过 window / 窗口滚动后放行 / 只数窗口内的记录),其中主判据显式对比「修复前 60,修复后 ≈window」。 本改动此前已随 2026-10-01 的两次部署进入线上二进制(vcs.modified=true), 本次补提交以让 provenance 对得上。
84 lines
2.6 KiB
Go
84 lines
2.6 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
)
|
||
|
||
// 登录失败限速:同一用户名连续 N 次失败后锁定一段时间。
|
||
// 用 DB 而非进程内内存计数器,多实例部署时各实例共享同一份计数。
|
||
const (
|
||
maxLoginFailures = 5
|
||
lockoutDuration = 5 * time.Minute
|
||
failureWindow = 15 * time.Minute
|
||
)
|
||
|
||
// LoginLimiter 通过 DB 实现的登录失败限速器。
|
||
type LoginLimiter struct{}
|
||
|
||
var limiter = &LoginLimiter{}
|
||
|
||
// Locked 返回该用户名是否处于锁定期,以及剩余秒数。
|
||
// 不记账,只读。
|
||
func (l *LoginLimiter) Locked(ctx context.Context, name string) (bool, int) {
|
||
bucket := "login:" + name
|
||
cutoff := time.Now().Add(-failureWindow)
|
||
|
||
var count int
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||
bucket, cutoff).Scan(&count)
|
||
if err != nil || count < maxLoginFailures {
|
||
return false, 0
|
||
}
|
||
|
||
// 找到最早那条记录 + lockoutDuration = 解锁时间
|
||
//
|
||
// 用 ORDER BY ts ASC LIMIT 1 而不是 MIN(ts):SQLite 驱动把聚合结果
|
||
// MIN(ts) 当 string 返回,扫进 *time.Time 直接报错(unsupported Scan,
|
||
// storing driver.Value type string into type *time.Time),err != nil
|
||
// 会让下面直接 return false, 0 —— 也就是「失败次数再多也永远不锁」。
|
||
// 排序取值走的是结果集本身,驱动按列类型给 time.Time;语义等价。
|
||
// 同一处方言差异在 db/migrate.go:125 用 CAST(... AS TEXT) 处理过。
|
||
var earliest time.Time
|
||
err = db.DB.QueryRowContext(ctx,
|
||
`SELECT ts FROM rate_limits WHERE bucket = $1 AND ts >= $2
|
||
ORDER BY ts ASC LIMIT 1`,
|
||
bucket, cutoff).Scan(&earliest)
|
||
if err != nil || earliest.IsZero() {
|
||
return false, 0
|
||
}
|
||
|
||
unlockAt := earliest.Add(lockoutDuration)
|
||
now := time.Now()
|
||
if now.Before(unlockAt) {
|
||
remain := int(unlockAt.Sub(now).Seconds()) + 1
|
||
if remain < 1 {
|
||
remain = 1
|
||
}
|
||
return true, remain
|
||
}
|
||
|
||
// 锁定期已过,清理旧记录
|
||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1 AND ts < $2`,
|
||
bucket, unlockAt)
|
||
return false, 0
|
||
}
|
||
|
||
// Fail 记录一次登录失败。达到阈值时不额外标记 ——
|
||
// Locked() 用 COUNT >= maxLoginFailures 自然判定锁定。
|
||
func (l *LoginLimiter) Fail(ctx context.Context, name string) {
|
||
bucket := "login:" + name
|
||
db.DB.ExecContext(ctx,
|
||
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
|
||
bucket, time.Now())
|
||
}
|
||
|
||
// Reset 登录成功后清除失败计数。
|
||
func (l *LoginLimiter) Reset(ctx context.Context, name string) {
|
||
bucket := "login:" + name
|
||
db.DB.ExecContext(ctx, `DELETE FROM rate_limits WHERE bucket = $1`, bucket)
|
||
}
|