Featured image of post 怎么设计一个生产级的验证码系统

怎么设计一个生产级的验证码系统

验证码看着简单,做对得考虑安全、成本、用户体验三方平衡——本文讲透从生成、存储、发送、校验到防刷的完整设计

谁都遇到过的工具,但很少人做对

短信验证码这事看似简单——“生成 6 位数字、发短信、校验”,但只要你接过线上系统,就一定遇到过这些事故:

  • 凌晨突然被短信轰炸刷了几万条短信,扣费上万
  • 某个手机号被竞争对手循环请求,正常用户收不到
  • 攻击者拿到泄漏的手机号字典,爆破登录接口
  • 客服收到投诉:“我点了 5 次还没收到验证码”——其实是发出去了,被运营商拦了

验证码的设计要同时平衡安全、成本、用户体验三个维度,任何一个出问题都会被现实毒打。本文把一个生产级验证码系统应该考虑的事情过一遍。


一、需求拆解

一个验证码功能,至少要回答这些问题:

问题决策点
验证码长度?6 位数字(短信) / 6 位字母数字(邮箱)
有效期?5 分钟(业内默认)
同一手机号 1 分钟内能再请求吗?
同一手机号 1 天最多发几次?5-10 次
同一 IP 1 小时最多发几次?20 次(防扫号)
校验失败几次后锁定?5 次
用过/锁定后能复用吗?否,立即作废
短信和邮箱分开计数吗?通常分开

这些数字没有标准答案,根据业务的安全 vs 体验权衡来定。后面所有实现都基于这些规则。


二、整体架构

核心组件:

  • 验证码生成:随机数字串 + 时间戳
  • 存储:Redis(带 TTL)
  • 限流:Redis 计数器 + Lua 脚本
  • 发送:解耦短信通道
  • 校验:原子读取 + 删除

三、生成与存储

1. 生成

短信验证码不要用 Math.random(),用 SecureRandom

1
2
3
4
5
6
private static final SecureRandom RANDOM = new SecureRandom();

public String generate() {
    int code = RANDOM.nextInt(900000) + 100000;   // 100000-999999
    return String.valueOf(code);
}

Math.random() 是伪随机,对一般业务够用,但对验证码这种安全相关的事,能用 SecureRandom 就用 SecureRandom

2. 存储

Redis 是最自然的选择——key 带场景标识,value 存"验证码 + 校验失败计数",TTL 5 分钟:

1
SET sms:login:13800138000 "{\"code\":\"123456\",\"failCount\":0,\"sentAt\":\"...\"}" EX 300

不要用纯字符串 code——校验失败次数、剩余次数都要记下来。

3. 关键:场景隔离

同一手机号在不同业务场景下的验证码必须分开存

1
2
3
sms:login:13800138000      → 登录用
sms:register:13800138000   → 注册用
sms:reset-pwd:13800138000  → 找回密码用

如果不分场景,攻击者从注册接口拿到验证码,能用来登录别的场景——这是真实发生过的越权事故。


四、多维限流:核心防刷

只做"1 分钟限发一次"是远远不够的。生产系统至少要做四层限流:

1. 同手机号 + 同场景:60 秒间隔

最朴素的——前端按钮置灰是一道,后端必须自己再校验一次

1
2
3
4
5
String key = "sms:cooldown:login:" + phone;
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS);
if (!ok) {
    throw new BusinessException("请求过于频繁");
}

2. 同手机号每天总量上限

24 小时内最多 10 条,超过拒绝:

1
2
3
4
5
6
7
8
String key = "sms:daily:" + phone;
Long count = redis.opsForValue().increment(key);
if (count == 1) {
    redis.expire(key, 24, TimeUnit.HOURS);
}
if (count > 10) {
    throw new BusinessException("今日发送已达上限");
}

3. 同 IP 限流

防扫号的关键——攻击者写脚本遍历手机号字典:

1
2
3
4
5
6
7
8
9
String key = "sms:ip:" + ip;
Long count = redis.opsForValue().increment(key);
if (count == 1) {
    redis.expire(key, 1, TimeUnit.HOURS);
}
if (count > 20) {
    log.warn("suspicious IP: {}", ip);
    throw new BusinessException("请求过于频繁");
}

4. 全局熔断:每分钟总短信量

防 DDoS 时的最后一道——配置中心维护一个全局阈值:

1
2
3
4
5
6
7
String key = "sms:global:" + minute;
Long count = redis.opsForValue().increment(key);
if (count > globalLimitPerMinute) {
    log.error("SMS global rate limit breached");
    alarm();
    throw new BusinessException("服务繁忙");
}

短信成本可不便宜——这层不开,攻击场景下损失会被迅速放大。


五、发送:人机校验前置

仅靠 IP 限流防不住僵尸网络(每个肉鸡 IP 不同)。真正的最强防御是滑块/拼图/reCAPTCHA——人机校验通过后才允许调发送接口。

后端校验 Token 时要有"一次性"语义——同一个 Token 不能复用。

很多业务做"分层防御":

  • 第 1 次发送:免人机校验
  • 1 小时内第 2 次:要滑块
  • 第 3 次:要图形+滑块
  • 触发风控:直接拒绝

体验和安全的折中点。


六、校验:原子操作 + 失败次数

校验逻辑看起来简单,但有几个容易写错的细节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public boolean verify(String phone, String inputCode, String scene) {
    String key = "sms:" + scene + ":" + phone;
    String json = redis.opsForValue().get(key);
    if (json == null) {
        throw new BusinessException("验证码已过期");
    }
    SmsRecord record = JSON.parseObject(json, SmsRecord.class);

    if (record.getFailCount() >= 5) {
        redis.delete(key);                       // 锁定
        throw new BusinessException("校验失败次数过多");
    }

    if (!Objects.equals(record.getCode(), inputCode)) {
        record.setFailCount(record.getFailCount() + 1);
        Long ttl = redis.getExpire(key);
        // ⚠️ getExpire 在 key 不存在时返回 -2、无 TTL 时返回 -1
        // 直接当 TTL 用会把 key 写成 "立即过期" 或 "永不过期"——这里要兜底
        if (ttl == null || ttl <= 0) ttl = 300L;
        redis.opsForValue().set(key, JSON.toJSONString(record), ttl, TimeUnit.SECONDS);
        return false;
    }

    redis.delete(key);   // 校验成功后立刻作废
    return true;
}

四个最常见的坑

  1. 校验成功后忘了删除 key——同一个验证码能复用,等于没校验
  2. 校验失败时直接覆写 key——重置了 TTL,攻击者可以"持续小流量爆破"
  3. 校验失败计数与原 record 分开存储——产生不一致,要么放一起要么用 Lua 原子更新
  4. getExpire 边界值没兜底——Redis 在 key 不存在时返回 -2、无 TTL 时返回 -1,照搬当 TTL 用会出问题(上面代码已加兜底)

更稳妥的版本是用 Lua 脚本一次性完成"读取 + 比对 + 计数 + 保留 TTL"——彻底避免上面这些时序坑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- KEYS[1]=验证码 key, ARGV[1]=用户输入
local v = redis.call('GET', KEYS[1])
if not v then return -1 end          -- 已过期
local r = cjson.decode(v)
if r.failCount >= 5 then
    redis.call('DEL', KEYS[1])
    return -2                         -- 锁定
end
if r.code == ARGV[1] then
    redis.call('DEL', KEYS[1])
    return 1                          -- 成功
end
r.failCount = r.failCount + 1
local ttl = redis.call('TTL', KEYS[1])  -- 保留剩余 TTL
redis.call('SET', KEYS[1], cjson.encode(r), 'EX', ttl > 0 and ttl or 1)
return 0                              -- 失败

七、典型事故回顾

事故 1:短信轰炸

某创业公司 0 限流上线,凌晨被脚本攻击——一晚发出 8 万条短信,短信通道账户余额清零,第二天用户登录时发不出短信。

教训:任何对外发短信的接口必须先有限流,哪怕是日活 10 的小项目。

事故 2:验证码被复用

注册接口的验证码校验后没删 key,研发以为"反正 5 分钟会过期"。攻击者发现后5 分钟内反复用同一验证码注册不同账号,刷出几千个假账号。

教训:验证码是一次性的,校验后立刻 DEL

事故 3:跨场景复用

登录场景和找回密码用同一个 Redis key。攻击者通过"找回密码"功能拿到验证码,直接拿来登录目标账号——因为后端没区分。

教训:场景必须隔离,key 里带 scene。

事故 4:被竞争对手刷光额度

同一个手机号每分钟能发一次,攻击者每分钟刷一次,正常用户的请求被冷却时间挡住

教训:单手机号也要有日发送上限,不只是冷却时间。


八、邮箱验证码的差异

邮箱验证码和短信思路类似,但有几个关键差别:

维度短信邮箱
成本高(每条几分钱)极低
触达速度秒级分钟级(垃圾邮件、延迟)
可靠性高(运营商)中(垃圾箱、屏蔽)
长度建议6 位数字6 位字母数字混合
防刷优先级(成本敏感)
用户体验通常好经常被丢进垃圾箱

邮箱可以用更长的验证码(甚至链接式),因为复制粘贴方便;短信必须短,因为人手输入。

邮箱链接式校验(点击链接验证)的好处是:可以包含完整 token,避免用户手输错。但要注意 token 一次性。


九、其他增强项

1. 验证码不要在响应里返回

测试代码里偶尔为了方便会返回 code 给前端——上线前一定要删干净。生产环境的验证码只能从用户终端获取

2. 日志脱敏

1
2
log.info("send sms to {}", phone);   // ❌ 完整手机号
log.info("send sms to {}", maskPhone(phone));   // ✓ 138****8000

完整手机号留在日志里,运维拉去做大数据分析时容易出"内部数据泄漏"事故。

3. 短信通道熔断

阿里云、腾讯云的短信网关偶尔抽风。一个通道连续失败 N 次后自动切换到备用通道:

1
2
3
4
5
List<SmsProvider> providers = List.of(aliyun, tencent, huawei);
for (SmsProvider p : providers) {
    if (p.isHealthy() && p.send(phone, code)) return true;
}
return false;

4. 监控告警

至少要有:

  • 验证码发送 QPS 监控
  • 失败率监控(>5% 报警)
  • 校验失败率监控(>30% 报警,可能在被爆破)
  • 单 IP 高频请求告警

十、一份生产级 Checklist

发布前对照这份清单逐条 check:

  • 验证码用 SecureRandom 生成
  • Redis key 带 scene,绝不跨场景复用
  • TTL 5 分钟(不要更长)
  • 同手机号 60 秒间隔
  • 同手机号每天 ≤ 10 条
  • 同 IP 每小时 ≤ 20 次
  • 全局每分钟总量阈值
  • 频繁请求触发滑块校验
  • 校验成功立刻 DEL
  • 校验失败次数 ≥ 5 直接锁定
  • 校验失败时 TTL 不被重置
  • 接口响应里不返回 code
  • 日志里手机号脱敏
  • 短信通道有 fallback
  • QPS 和失败率都有监控告警

少做任何一条都可能在某一天变成事故。


小结

把全文压一句:

验证码的难度从来不在『生成 6 位数字』,而在『当系统被恶意调用时,仍然能保护用户、保护成本、不影响正常体验』。

记住三个原则:

  1. 任何对外发短信的接口必须有限流——这是底线
  2. 验证码是一次性的,校验后立刻删
  3. 场景必须隔离,不要跨业务复用 key

把这三条做对,再叠加多维限流和人机校验,你的验证码系统就基本不会被薅羊毛了。

使用 Hugo 构建
主题 StackJimmy 设计