3.3 把短信接口的流程跑通了(生成→限流→存储→发送)。这一篇专门挖安全加固这一层:为什么限流和校验必须写成 Lua、每日上限的 key 为什么要计算"距次日 0 点"的 TTL、6 位验证码在数学上意味着什么、以及"发送失败不回滚配额"这个有意的取舍。

配套代码:src/service/SmsService.cckSendScript / kVerifyScript / secondsUntilTomorrow / genNumericCode

一、为什么需要加固:三个攻击面

攻击面 后果 加固手段
短信轰炸 拿接口打别人手机,败坏口碑 + 账单是你的 重发冷却 + 每日上限(都在 kSendScript
验证码爆破 6 位仅 1M 组合,脚本几十分钟撞出 校验次数上限(kVerifyScriptmaxVerifyAttempts
一码多用 并发下同一码被兑换两次 校验与消费同一 Lua 原子完成

二、发送侧 Lua:冷却 + 每日上限原子化

kSendScript 在一个脚本里完成"查冷却 → 查每日计数 → 写验证码 → 设冷却 → 计当日"。三条命令若分开做,并发下要么计数竞态(实际失败 10 次只记 5 次),要么锁定竞态(同时跨阈值各自 SETEX)。Lua 在 Redis 单线程里把"读-判-写"变不可分割。

1
2
3
4
5
6
7
8
9
-- KEYS: codeKey/cooldownKey/dailyKey;ARGV: code/ttl/cd/dailyLimit/dailyTtl
if redis.call('EXISTS', KEYS[2]) == 1 then return {-1, redis.call('TTL', KEYS[2])} end
local used = tonumber(redis.call('GET', KEYS[3]) or '0')
if used >= tonumber(ARGV[4]) then return {-2, used} end
redis.call('SETEX', KEYS[1], ARGV[2], ARGV[1])
redis.call('SETEX', KEYS[2], ARGV[3], '1')
local n = redis.call('INCR', KEYS[3])
if n == 1 then redis.call('EXPIRE', KEYS[3], ARGV[5]) end -- dailyTtl=距次日0点
return {0, n}

自然日 TTL 的坑:每日上限如果"从第一次发送起滚动 24 小时",语义就变成"任意 24 小时内 10 条",而不是"今天 10 条"。secondsUntilTomorrow 算出"距本地次日 0 点"的秒数作为 key 的 TTL,跨天自动清零,才是真正的自然日配额。

1
2
3
4
5
6
7
8
9
// src/service/SmsService.cc(节选)
int secondsUntilTomorrow() {
std::time_t now = std::time(nullptr);
std::tm tm{}; localtime_r(&now, &tm);
tm.tm_hour = 0; tm.tm_min = 0; tm.tm_sec = 0; tm.tm_mday += 1; tm.tm_isdst = -1;
std::time_t midnight = std::mktime(&tm);
long long diff = (long long)midnight - (long long)now;
return diff <= 0 ? 86400 : (int)diff; // 兜底一整天
}

三、校验侧 Lua:比对 + 消费原子化

kVerifyScript 把"读码 → 判试错次数 → 比对 → 删除"放进单线程:试错超限直接作废该码(逼攻击者重新走发送流程,而发送有上限卡着);比对通过则删 codetry,一次性消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- KEYS: codeKey/tryKey;ARGV: inputCode/maxAttempts
local stored = redis.call('GET', KEYS[1])
if not stored then return {-1, 0} end -- 不存在/已过期
local tries = tonumber(redis.call('GET', KEYS[2]) or '0')
if tries >= tonumber(ARGV[2]) then -- 试错超限→作废
redis.call('DEL', KEYS[1]); return {-3, 0}
end
if stored == ARGV[1] then -- 通过→删码(一次性)
redis.call('DEL', KEYS[1]); redis.call('DEL', KEYS[2]); return {0, 0}
end
local t = redis.call('INCR', KEYS[2]) -- 记一次试错,TTL 对齐码
redis.call('EXPIRE', KEYS[2], redis.call('TTL', KEYS[1]))
return {-2, t}

四、爆破的数学:6 位 = 1,000,000

6 位数字验证码搜索空间仅 10⁶。若不限次数,假设网络/脚本每秒试 100 次,理论 ~2.7 小时可穷举——实际还有网络延迟、风控,但"不限次数"本身不可接受。maxVerifyAttempts=5 意味着:错 5 次该码直接作废,攻击者必须重新走发送(而发送有每日上限 10 条卡着),把"爆破"成本抬高到不划算。

图 1:试错上限如何抬高爆破成本 不限次数~1M 组合可穷举 试错 5 次作废须重走发送 每日上限 10卡死发送 三层叠加:单次码只能试 5 次,且每天最多发 10 条 → 爆破成本不可接受

五、发送失败不回滚配额的取舍

sendCode 在 Lua 拿到 {0, n}(配额已扣、码已存)后才真发短信。若发送失败(腾讯云超时/凭据错),码仍在 Redis 但用户没收到——这里选择不回滚配额。理由:回滚要再写一个 Lua 且要处理"部分失败",而配额本就是防轰炸的粗粒度闸门,多扣一条无实质损失;真用户下次重发走正常流程即可。

六、对比表(朴素实现 vs Lua 加固)

维度 朴素(GET→比对→DEL 分开) Lua 原子加固(本文)
限流计数竞态 有(并发少计) 无(单线程原子)
一码多用 可能(并发双兑) 不可能(比对即删)
每日上限语义 易退化成滚动 24h 自然日(距次日 0 点 TTL)
爆破成本 低(不限次) 高(5 次作废 + 每日 10 条)

功能抉择(本篇核心权衡)

① 为什么限流/校验必须用 Lua 而不能用"多条命令 + 应用层判断"?
Redis 单线程执行 Lua,脚本内的"读-判-写"对外部观察者是不可分割的原子操作。用多条独立命令,并发请求会在命令之间插空,导致计数少计、锁定竞态、验证码双兑——这些都不是"偶发",是必然会在高并发下出现。

② 为什么每日上限要计算"距次日 0 点"而不是固定 86400 秒?
固定 24h 滚动会让"今天 23:00 发的 10 条"和"明天 00:00 发的 10 条"实际上是连续两波,失去"每天 10 条"的约束意义。secondsUntilTomorrow 让 key 恰好在跨天清零,才是真正的自然日配额。

③ 为什么发送失败不回滚配额?
配额是防轰炸的粗粒度闸门,多扣一条对用户无实质影响;为回填写第二个 Lua + 处理部分失败,复杂度和收益不成正比。取舍清晰:宁可偶发多扣一条,也不引入回滚的并发复杂度。

小结

  • 限流与校验全在 Lua 原子脚本完成,消除计数竞态、锁定竞态、一码多用。
  • 每日上限用自然日 TTL(距次日 0 点),不是滚动 24h——语义才是"今天 10 条"。
  • 6 位验证码搜索空间 1M,试错 5 次作废 + 每日 10 条双层叠加,把爆破成本抬到不可接受。
  • 发送失败不回滚配额:粗粒度闸门多扣一条无实质损失,换回滚的复杂度不划算。🐾