为什么登录接口必须在网关层限流

给一个管理后台做安全评审时,登录接口被标了一个不那么直观的问题: 它是一个 CPU 放大器,而且这是防用户名枚举的必要设计的副作用。

恒定时间对比带来的副作用

登录验证用 bcrypt(cost 10)比对密码。如果用户不存在就立刻返回 「用户名或密码错误」,而用户存在才跑那约 76ms 的哈希计算,攻击者就能 通过响应时间的差异枚举出哪些用户名是存在的。标准解法:用户不存在时 也对一个假哈希跑一次完整的 bcrypt,让两条路径的耗时一致。

副作用随之而来:这个接口对任何未认证请求都要烧 76ms 的 CPU。 几百个并发请求就能把全部核心吃满。而这个后台只有一个管理员账号、 没有锁定机制、没有验证码——也就是说,接口面本身就这么小,没必要为 「体验」保留更大的口子。

为什么放在网关层

限流当然也能在应用里做,但放在网关层有两个实际的好处:

nginx limit_req 与 Caddy rate_limit 的语义差异

这套部署从 nginx 迁到 Caddy 时,限流换了个实现。两者语义不同:

换算时只能近似:nginx 的 5r/m + burst=5 大致对应 Caddy 的「1 分钟窗口内 5 个事件」。对登录这种真人一天点个位数的场景, 语义差异没有实际影响;真正要防的是脚本化的集中打点,两种实现都拦得住。

额度怎么定

原则是「按真实使用的量级留余量,而不是按想象的压力留余量」:

按 IP 计数在 NAT 出口场景下会误伤,所以普通 API 的额度必须按 共享出口的最坏情况估算,而不是按单客户端的正常用量。

如实记录失去的能力

迁移不是没有代价。nginx 侧的每 IP 并发连接上限(slowloris 防护) 在 Caddy 没有等价物——Go 运行时对慢连接的抵抗力更好一些,但那道闸 确实不在了。把「失去了什么」和「为什么可以接受」写进配置注释里, 比假装能力无损重要得多。