手机号注册、找回密码、敏感操作二次确认——这些场景几乎都要用短信验证码。功能看似简单,却暗藏不少坑:验证码存哪里、怎么防刷、怎么限制频率、怎么保证时效。如果防护不到位,一条短信几毛钱,被恶意刷一晚上就是几千块损失。本篇讲透短信验证码的完整实现与安全防护。
一、验证码生成与存储
验证码通常是 4-6 位数字,生成用 random 即可。关键在于存储——验证码必须存在服务端,绝不能由前端掌管。尧图推荐用 Redis 存储,原因有三:自带过期机制(设 TTL 自动失效,不用手动清理)、读写极快(毫秒级,不拖累主流程)、支持原子操作(避免并发问题)。
// 发送验证码
async function sendSmsCode(phone) {
// 1. 频率限制:60秒内不可重复发送
const lockKey = `sms_lock:${phone}`;
if (await redis.get(lockKey)) {
return fail('发送过于频繁,请稍后再试');
}
// 2. 生成6位随机验证码
const code = String(Math.floor(100000 + Math.random() * 900000));
// 3. 存入Redis,5分钟过期
await redis.set(`sms_code:${phone}`, code, 'EX', 300);
// 4. 设置发送锁,60秒
await redis.set(lockKey, '1', 'EX', 60);
// 5. 调用短信平台发送
const result = await smsProvider.send({
phone,
template: 'SMS_123456',
params: { code, minutes: 5 }
});
return result.success ? success('已发送') : fail('发送失败');
}
存储 key 用"业务前缀+手机号"的格式(如 sms_code:13800138000),方便排查和管理。验证码有效期通常设 5 分钟,太短用户来不及输入,太长安全性下降。验证通过后要立即删除验证码,防止同一验证码被重复使用。
二、频率限制与防刷
短信费用按条计费,被刷的代价是真金白银。防刷要做多层限制,单一限制挡不住有心人。尧图项目里至少会做三道防线:单手机号频率限制、单 IP 频率限制、图形验证码前置。
// 多层频率限制
async function checkRateLimit(phone, ip) {
// 1. 单手机号:60秒1条,1小时5条,1天10条
const phoneMin = await redis.incr(`sms_phone_min:${phone}`);
if (phoneMin === 1) redis.expire(`sms_phone_min:${phone}`, 60);
if (phoneMin > 1) return fail('60秒内已发送过');
const phoneHour = await redis.incr(`sms_phone_hour:${phone}`);
if (phoneHour === 1) redis.expire(`sms_phone_hour:${phone}`, 3600);
if (phoneHour > 5) return fail('本小时发送次数已达上限');
// 2. 单IP:1小时10条,1天50条
const ipHour = await redis.incr(`sms_ip_hour:${ip}`);
if (ipHour === 1) redis.expire(`sms_ip_hour:${ip}`, 3600);
if (ipHour > 10) return fail('该IP请求过于频繁');
return success(); // 放行
}
// 3. 图形验证码前置:发送短信前先验证图形验证码
async function sendWithCaptcha(phone, captchaInput, captchaKey) {
const captchaReal = await redis.get(`captcha:${captchaKey}`);
if (!captchaReal || captchaReal.toLowerCase() !== captchaInput.toLowerCase()) {
return fail('图形验证码错误');
}
await redis.del(`captcha:${captchaKey}`); // 用完即删
return sendSmsCode(phone); // 通过后才发短信
}
图形验证码前置是最有效的防刷手段——机器识别图形验证码成本高,能挡住绝大多数自动化刷单。另外要监控异常发送模式,比如某 IP 短时间内请求大量不同手机号,直接拉黑。尧图建议接入短信平台的"号码检测"能力,自动过滤空号、风险号,既省钱又提升到达率。
三、验证码校验与安全
用户输入验证码后,服务端取出存储的验证码比对。这里有几个安全细节容易忽视:第一,校验失败不要明确提示"验证码错误"还是"手机号未发送",统一返回"验证码错误或已失效",防止攻击者据此判断手机号是否注册;第二,限制校验尝试次数,错误 5 次锁定该手机号 30 分钟,防止暴力破解。
// 验证码校验
async function verifySmsCode(phone, userInput) {
const storedCode = await redis.get(`sms_code:${phone}`);
// 1. 验证码不存在或已过期
if (!storedCode) {
return fail('验证码错误或已失效');
}
// 2. 错误次数累计,防止暴力破解
const errKey = `sms_err:${phone}`;
const errCount = await redis.incr(errKey);
if (errCount === 1) redis.expire(errKey, 1800); // 30分钟窗口
if (errCount > 5) {
await redis.del(`sms_code:${phone}`); // 锁定,清除验证码
return fail('错误次数过多,请30分钟后重试');
}
// 3. 比对验证码
if (storedCode !== userInput) {
return fail('验证码错误或已失效');
}
// 4. 验证成功:清除验证码和错误计数
await redis.del(`sms_code:${phone}`);
await redis.del(errKey);
return success('验证通过');
}
最后强调一点:验证码比对要用恒定时间比较(timingSafeEqual),而非普通的 ===。虽然普通比较在这里风险有限,但养成习惯能避免时序攻击——攻击者通过比较耗时差异逐位猜出验证码。这些细节叠加起来,才能让短信验证码功能既好用又安全,这套方案在尧图多个项目稳定运行,从未出过安全事故。