本文从《登录验证码发送间隔限制的设计与实现》出发重写。原文使用 Caffeine 解决单实例内的 60 秒防重复;这里把问题扩展到多实例、恶意刷取和验证码生命周期,并以 Redis 作为所有实例共享的判定点。

前端按钮倒计时只能改善体验,不能保护接口。攻击者可以跳过页面直接请求 API;服务部署多实例后,本地 Caffeine 也只能看到落到当前进程的流量。

一个可上线的验证码服务至少要同时回答四个问题:

  1. 同一目标多久可以再次发送;
  2. 同一目标、IP 或设备在一段时间内最多发送多少次;
  3. 验证码怎样保存、校验和一次性失效;
  4. 短信或邮件服务商超时、失败时,限流状态怎样处理。

先把规则拆成不同维度

单个 email -> 60 秒 不能阻止攻击者轮换邮箱轰炸服务商,也不能阻止一个目标被大量代理 IP 骚扰。常见规则可以这样设计:

维度示例规则主要目的
目标地址60 秒 1 次防止重复点击和单目标轰炸
目标地址1 小时 5 次、24 小时 10 次控制单用户成本
IP1 小时 30 次限制批量注册与脚本
设备或会话1 小时 10 次提高更换账号的成本
全局服务商异常时降级防止故障放大账单

具体数字应由业务风险、正常重试习惯、发送成本和误伤率共同决定。规则命中后记录指标,但不要把原始手机号、邮箱或验证码写进标签和日志。

Redis key 不要直接暴露个人信息

手机号和邮箱基数不高,仅做普通 SHA-256 仍可能被字典枚举。可以先规范化,再用服务端索引密钥计算 HMAC:

targetId = HMAC-SHA-256(indexKey, normalize(channel + ":" + target))

Redis 中只出现不可逆的 targetId

otp:cooldown:login:{targetId}
otp:quota:target:hour:{targetId}
otp:code:login:{targetId}
otp:attempts:login:{targetId}

HMAC 的索引密钥与 Redis 数据分开管理。它不是验证码签名密钥,两个用途应使用不同密钥。

60 秒冷却:用一个原子命令占位

冷却锁可以用 SET NX EX 一次完成:

SET otp:cooldown:login:{targetId} {requestId} NX EX 60
  • 返回成功:当前请求获得发送资格;
  • 返回空:冷却期内已经有请求占位,拒绝发送;
  • value 使用唯一 requestId,便于在确定发送失败时只释放自己的占位。

不要用 EXISTS 后再 SET。两个命令之间存在并发窗口,多次请求可能同时判断“尚未发送”。

如果服务商明确返回失败,可以用 compare-and-delete Lua 脚本释放本次占位:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

不能直接 DEL,否则旧请求的失败回调可能误删新请求刚建立的冷却锁。对于结果未知的超时,宁可保留短暂冷却并提示稍后重试,也不要立即重发,避免服务商实际已接收请求而产生重复短信。

时间窗口配额:INCR 与过期必须在一个原子步骤中

固定窗口计数器的常见错误是先 INCR,发现结果为 1 再单独 EXPIRE。如果客户端在两步之间断开,key 可能永不过期。

Redis 官方文档建议用事务或 Lua 保证计数和设置 TTL 一起完成:

local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return current

调用方分别检查目标、IP、设备等维度:

count(target-hour, 3600000) <= 5
count(ip-hour,     3600000) <= 30
count(device-hour, 3600000) <= 10

Lua 会让单个计数器的“读取—修改—设置过期”保持原子。若需要让多个维度严格地同时扣减,还要考虑 Redis Cluster 的 hash slot;多数业务更适合逐维判定,并把少量并发下的保守拒绝视为可接受结果。

固定窗口实现简单,但窗口边界可能允许突发流量。对成本或攻击风险更高的接口,可以使用滑动窗口或令牌桶;选择算法前先确认业务是否真的需要更高复杂度。

验证码本身也要有生命周期

通过发送限流后,才生成验证码并调用服务商。验证码处理至少满足:

  • 使用密码学安全随机数生成器,不使用 Math.random()
  • 有效期短,例如 5 分钟;
  • 数据库或 Redis 不保存明文验证码,可保存带服务端密钥的 HMAC;
  • 限制校验失败次数,例如最多 5 次;
  • 校验成功后立即删除,保证一次性;
  • 新验证码生成后,按产品语义让旧验证码失效;
  • 验证码和完整目标地址不得进入日志、追踪 span 或第三方埋点。

校验时,应用先对用户输入计算同样的 HMAC,再与 Redis 中的值做常量时间比较。失败则原子增加 attempts;达到上限后删除验证码,要求重新获取。

一条完整的发送链路

推荐顺序如下:

  1. 规范化目标地址,计算不含个人信息的 targetId
  2. 校验请求格式、场景和人机验证结果;
  3. 检查全局熔断或服务商降级状态;
  4. 获取 60 秒冷却占位;
  5. 检查并增加目标、IP、设备配额;
  6. 生成验证码,保存 HMAC、TTL 和尝试次数;
  7. 携带幂等 request id 调用发送服务商;
  8. 明确失败时按 request id 释放冷却并删除未发送验证码;
  9. 返回统一响应,不暴露账号是否存在;
  10. 记录成功率、延迟、拒绝原因和费用指标。

这里存在一个产品取舍:配额应在发送前扣减,还是仅成功后扣减?安全优先的系统通常在调用服务商前占用额度,避免并发穿透;对于服务商明确失败,可以补偿本次计数,但补偿逻辑也要防止旧请求误改新窗口。很多场景宁可接受一次误扣,也不要设计脆弱的复杂回滚。

前端倒计时仍然有价值,但不是真相来源

前端可以在服务端返回成功后开始倒计时,并根据服务端返回的 retryAfterSeconds 恢复页面状态。刷新页面、切换设备或修改 JavaScript 都不能绕过后端限制。

不要无论请求成功失败都固定倒计时 60 秒。格式错误、服务不可用和真实冷却命中应给出不同的可恢复提示;对外响应可以保持模糊,内部指标则要保留准确原因。

上线前检查清单

  • 限流状态由所有应用实例共享;
  • 冷却占位使用 SET NX EX,没有 check-then-set 竞态;
  • 计数与 TTL 通过 Lua、事务或原生命令原子执行;
  • 同时覆盖目标、IP、设备和全局成本维度;
  • Redis key 与日志不包含明文手机号、邮箱或验证码;
  • 验证码使用安全随机数、短 TTL、失败次数上限和一次性失效;
  • 服务商超时与明确失败采用不同补偿策略;
  • 前端倒计时只负责体验,服务端始终重新判定;
  • 响应不会帮助攻击者枚举注册账号。

参考资料