// Package auth 的登录失败短锁。 package auth import ( "crypto/rand" "math/big" "sync" "time" ) // Guard 拦登录爆破:按用户名累计失败次数,到阈值后按指数退避短暂锁住。 // // 键只用用户名、**不带 IP**——clientIP 优先信 X-Forwarded-For 的第一段, // 而那是客户端可伪造的,拿它当键就等于「每换一个请求头换一把新锁」。 // 本站只有一个账号(启动时创建,没有注册入口),用户名是稳定的, // 所以它才是可靠的爆破计数键。 // // 状态在内存里:站点是单进程部署,重启即清空,代价可接受。 type Guard struct { mu sync.Mutex items map[string]entry now func() time.Time // 测试注入用 } type entry struct { fails int until time.Time // 锁到什么时候 seen time.Time } const ( // guardFreeTries 是阈值:前几次失败不锁,正常手滑不该被罚。 guardFreeTries = 5 // guardBaseLock 是刚过阈值时的锁定时长,之后每次失败翻倍。 guardBaseLock = 2 * time.Second guardMaxLock = 15 * time.Minute // guardMaxKeys 与 guardIdleTTL 只管内存:有人拿随机用户名刷接口时, // 记录不能无限长下去。 guardMaxKeys = 1024 guardIdleTTL = time.Hour ) // NewGuard 建一个空白的失败计数器。 func NewGuard() *Guard { return &Guard{items: map[string]entry{}, now: time.Now} } // RetryAfter 返回该键还要等多久才能再试;0 表示可以试。 func (g *Guard) RetryAfter(key string) time.Duration { g.mu.Lock() defer g.mu.Unlock() return g.retryAfterLocked(key) } func (g *Guard) retryAfterLocked(key string) time.Duration { e, ok := g.items[key] if !ok { return 0 } if d := e.until.Sub(g.now()); d > 0 { return d } return 0 } // Fail 记一次失败,返回这次之后需要等待的时长(0 表示还没到阈值)。 func (g *Guard) Fail(key string) time.Duration { g.mu.Lock() defer g.mu.Unlock() g.pruneLocked() e := g.items[key] e.fails++ e.seen = g.now() if e.fails >= guardFreeTries { // 第 5 次失败锁 2 秒,第 6 次 4 秒……封顶 15 分钟 lock := guardBaseLock << uint(e.fails-guardFreeTries) if lock > guardMaxLock || lock <= 0 { lock = guardMaxLock } e.until = g.now().Add(lock) } g.items[key] = e return g.retryAfterLocked(key) } // Succeed 登录成功就清掉计数:口令是对的,之前的失败不再累积到下次。 func (g *Guard) Succeed(key string) { g.mu.Lock() delete(g.items, key) g.mu.Unlock() } // Reset 清空全部计数(测试与本地排查用)。 func (g *Guard) Reset() { g.mu.Lock() g.items = map[string]entry{} g.mu.Unlock() } func (g *Guard) pruneLocked() { now := g.now() for k, v := range g.items { if now.Sub(v.seen) > guardIdleTTL { delete(g.items, k) } } for k := range g.items { if len(g.items) < guardMaxKeys { break } delete(g.items, k) } } // RandomPassword 生成 n 位随机口令,字符集去掉了容易看错的 0/O/1/I/L。 // 用于「没配 ROND_ADMIN_PASSWORD 时别再写死出厂口令」。 func RandomPassword(n int) (string, error) { const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" var b = make([]byte, n) for i := range b { idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { return "", err } b[i] = alphabet[idx.Int64()] } return string(b), nil }