注册/登录要不要验证码?要——但验证码接口本身就是个高价值攻击面:它既能当"免费短信轰炸机"打别人的手机,又能被脚本爆破出 6 位验证码。这一篇把验证码接口做完整:生成(CSPRNG)→ 限流(冷却 + 每日上限)→ 存储(Redis)→ 发送(腾讯云直连),每一道都对应一个具体的攻击面。

配套代码:src/service/SmsService.{h,cc}(生成/限流/存储/校验)、src/service/SmsSender.{h,cc}(腾讯云直连)、src/controllers/SmsController.ccsrc/service/SmsSender.cc 的 TC3 签名。

一、短信验证码接口:是什么、坑在哪、本质一句话

是什么POST /api/sms/send 给指定手机号生成 6 位验证码,限流后存入 Redis(有效期 + 重发冷却 + 每日上限),再通过腾讯云短信 API 发出去。校验在注册(3.1)/登录(3.2)流程里调 verifyCode

坑在哪

  • 短信轰炸:攻击者拿你的接口狂发别人的手机号,败坏口碑 + 账单是你的。
  • 验证码爆破:6 位数字仅 100 万组合,不限次数脚本几十分钟撞出来。
  • 一码多用:GET→比对→DEL 三步分开,并发下同一验证码被兑换两次(注册场景可绕过验证码重复注册)。
  • 同步阻塞 IO 线程:libcurl 是同步的,直接 curl_easy_perform 会把 Drogon IO 线程卡几十毫秒。

本质一句话:短信验证码接口的本质是"在易用与安全之间画线"——CSPRNG 生成 + Redis 三道限流闸门 + Lua 原子校验 + 发送挪到工作线程

二、三条安全红线:限流闸门

SmsService::Config 把三条红线固化成配置:

红线 配置 防什么
每日发送上限 dailyLimit 默认 10 短信轰炸:打别人手机刷爆账单
重发冷却 resendInterval 默认 60s 轰炸另一半:1 秒发 60 条把上限瞬间打满
校验次数上限 maxVerifyAttempts 默认 5 验证码爆破:超限即作废该码

验证码本身用 CSPRNG(OpenSSL RAND_bytes 生成 6 位,且用拒绝采样保证均匀——std::rand()%1000000 既可预测又有取模偏斜,绝不能用。

1
2
3
4
5
6
7
8
9
// src/service/SmsService.cc(节选)
std::string genNumericCode() {
const std::uint32_t kLimit = 4294000000u; // 10^6 的整数倍,拒绝采样去偏斜
std::uint32_t v = 0;
do { if (RAND_bytes((unsigned char*)&v, sizeof(v)) != 1) return {}; }
while (v >= kLimit);
char buf[8]; std::snprintf(buf, sizeof(buf), "%06u", v % 1000000u);
return std::string(buf);
}

三、发送限流:一个 Lua 把"限流 + 写码"原子化

发送流程的限流与写码必须在同一个 Lua 脚本里完成,否则"配额已扣但写码失败"或"并发同时过限流"都会出乱子。Lua 在 Redis 单线程里执行,"读-判-写"不可分割。

1
2
3
4
5
6
7
8
9
-- kSendScript(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}

C++ 侧拿到 {0, n} 才真的去发短信;返回 {-1, cdTtl}TooFrequent{-2, used}DailyLimit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/service/SmsService.cc(节选)
redis_->execCommandAsync(
[this, phone, code, cb](const drogon::nosql::RedisResult &r) {
auto arr = r.asArray();
const long long c0 = arr[0].asInteger();
if (c0 == -1) { cb(SendResult::TooFrequent, "retry in " + std::to_string(arr[1].asInteger()) + "s"); return; }
if (c0 == -2) { cb(SendResult::DailyLimit, "daily limit reached"); return; }
sender_->sendCode(phone, code, [cb, n = arr[1].asInteger()](bool ok, const std::string &d) {
cb(ok ? SendResult::Ok : SendResult::RedisError, d + " (today #" + std::to_string(n) + ")");
});
},
[cb, phone](const std::exception &e) { cb(SendResult::RedisError, e.what()); },
"EVAL %s 3 %s %s %s %s %d %d %d %d", kSendScript,
ck.c_str(), cdk.c_str(), dk.c_str(), code.c_str(),
cfg_.codeTtlSeconds, cfg_.resendIntervalSeconds, cfg_.dailyLimit, dailyTtl);

四、校验必须原子:防"一码多用"

校验与消费(DEL)必须在同一个 Lua 里完成,杜绝"两个并发都 GET 到正确码、都通过、都 DEL"——注册场景这意味着同一手机号绕过验证码重复注册。校验通过即删,一次性。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- kVerifyScript(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]); -- 记一次试错
redis.call('EXPIRE', KEYS[2], redis.call('TTL', KEYS[1]))
return {-2, t}

五、发送:直连腾讯云,不走官方 SDK

SmsSender 直连腾讯云 API 3.0,自实现 TC3-HMAC-SHA256 签名,约 120 行,依赖只有 libcurl + OpenSSL。官方 tencentcloud-sdk-cpp 要拉 core 模块(数百文件、依赖 protobuf),编译十几分钟,而我们要的只是「签一个 TC3 签名 + POST 一段 JSON」。

关键工程约束:libcurl 是同步阻塞的,必须丢进独立工作线程,完成后 queueInLoop 切回 IO 线程——否则短信 API 往返几十毫秒会卡死同线程排队的秒杀请求。

1
2
3
4
5
6
// src/service/SmsSender.cc(核心约束)
// 一次性 std::thread + detach:短信低频(有每日上限),建线程~50us 相对 API 往返可忽略
std::thread([phone, code, cfg = cfg_, cb = std::move(cb)]() {
// 1) 拼请求体 2) TC3 签名(HMAC 链 + SHA256)3) curl_easy_perform POST
// 4) queueInLoop 切回 IO 线程触发 cb
}).detach();

降级策略sms.enabled=false 或凭据为空时,SmsSender 不真发短信,只在日志里打 SMS_MOCK phone=... code=...——本地开发/压测能跑通整条链路,也不误发产生费用。验证码从这条日志里读即可(bash scripts/verify-auth.sh 一键自检就是这么干的)。

图 1:短信发送的三道闸门(冷却 / 每日上限 / 校验次数) ① 重发冷却sms:cd TTL=60s ② 每日上限sms:day TTL=次日0点 ③ 校验次数sms:try ≤5 次 全在 Lua 单线程原子执行读-判-写 不可分割,无并发竞态 三关过 → 真发短信(工作线程)→ 配额已扣不回滚

六、可运行验证

1
2
3
4
5
6
# 发验证码(mock 模式只写日志,不真发)
curl -s -X POST http://127.0.0.1:8080/api/sms/send \
-H 'Content-Type: application/json' -d '{"phone":"13800001111"}'

# 从日志捞验证码,再注册(grep SMS_MOCK logs/seckill.log)
bash scripts/verify-auth.sh 13800001111 abc123 # 发码→注册→登录→锁定→登出 端到端

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

① 为什么限流 + 写码必须一个 Lua,而不是先 INCR 再 SET?
三条命令分开做,并发下会有两类竞态:计数竞态(10 个请求同时读到 4、各自 +1 写回 5,实际失败 10 次却只记 5 次);锁定竞态(两个请求同时跨阈值各自 SETEX)。Lua 在 Redis 单线程里把"读-改-写"变成不可分割的一步,从根上消除。

② 为什么校验也要 Lua,而不是 GET→比对→DEL?
三步分开,两个并发请求可能同时 GET 到同一正确码、都通过、都 DEL——一个验证码被兑换两次。注册场景下同一手机号能绕过验证码重复注册。Lua 让"比对与删除"同一步完成,一次性消费。

③ 为什么短信自实现腾讯云直连,而不引官方 SDK?
官方 C++ SDK 编译十几分钟、依赖 protobuf 等重;我们要的只是「TC3 签名 + POST JSON」,自实现约 120 行、依赖只有已在装的 libcurl + OpenSSL。零编译负担、签名可审计、出问题能直接打印待签串比对。

④ 为什么发送要丢进工作线程,而不直接在 IO 线程 curl?
libcurl curl_easy_perform 同步阻塞几十毫秒,会卡死 Drogon IO 线程上排队的秒杀请求。短信低频、建线程 ~50us 可忽略,用一次性线程 + queueInLoop 切回最省心。

小结

  • 验证码用 CSPRNG 拒绝采样生成 6 位,均匀且不可预测;绝不 std::rand()%1e6
  • 三道限流闸门(重发冷却 / 每日上限 / 校验次数)全在 Lua 原子脚本里完成,消除并发竞态。
  • 校验与消费同一 Lua 完成,一次性消费防"一码多用"。
  • 发送直连腾讯云 + 自实现 TC3 签名(~120 行),工作线程执行不阻塞 IO 线程;无凭据降级为只打日志(mock)。🐾