一、前言:短信验证码 ≠ 调个接口那么简单!
很多开发者认为:“发短信验证码不就是调个第三方 API 吗?”
但真实生产环境中,你必须考虑:
- ❌ 用户疯狂点击“获取验证码”,导致短信费用飙升
- ❌ 黑产用脚本批量注册,消耗系统资源
- ❌ 验证码被暴力破解,账号被盗
- ❌ 同一手机号频繁请求,影响正常用户
一个健壮的短信验证码功能,核心不在“发短信”,而在“控频 + 安全 + 校验”。
本文将带你用 Spring Boot + Redis 实现一套可直接用于生产环境的短信验证码系统,包含:
✅ 图形验证码前置校验
✅ 滑动窗口限流(IP + 手机号双维度)
✅ 验证码存储与自动过期
✅ 一次性使用 + 防重放
✅ 完整代码示例
二、整体设计流程图
用户点击【获取验证码】
↓
[1] 前端携带图形验证码 token
↓
[2] 后端校验图形验证码(防止机器刷)
↓
[3] 检查 IP 今日发送次数(防代理刷)
↓
[4] 检查该手机号 60 秒内是否已发送(防频繁点)
↓
[5] 生成 6 位随机码,存入 Redis(5分钟有效)
↓
[6] 调用短信平台(阿里云/腾讯云等)发送
↓
[7] 返回成功(前端开始倒计时)
↓
用户提交表单 + 验证码
↓
[8] 校验验证码是否正确 & 未过期 & 未使用
↓
[9] 验证通过,执行业务逻辑(注册/登录/找回密码)
三、技术选型
| Spring Boot 3.x | Web 框架 |
| Redis | 存储验证码、限流计数器 |
| StringRedisTemplate | 操作 Redis(字符串友好) |
| Kaptcha / EasyCaptcha | 生成图形验证码(本文用 EasyCaptcha) |
| 第三方短信平台 | 阿里云短信、腾讯云短信等 |
四、核心实现步骤
1. 添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!– 图形验证码 –>
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
2. Redis Key 命名规范(重要!)
// 验证码存储
sms:code:{phone} → value = "123456"
// 手机号发送频率控制(60秒内只能发1次)
sms:limit:phone:{phone} → value = "1" (带 TTL 60s)
// IP 发送次数限制(每天最多20次)
sms:limit:ip:{ip} → value = 计数(带 TTL 24h)
3. 图形验证码生成接口
@RestController
public class CaptchaController {
@GetMapping("/captcha")
public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 生成验证码(4位数字)
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48);
captcha.setLen(4);
// 将验证码存入 Session(或 Redis,此处简化用 Session)
request.getSession().setAttribute("captcha", captcha.text());
// 输出图片
response.setContentType("image/png");
captcha.out(response.getOutputStream());
}
}
✅ 前端访问 /captcha 获取图片,提交时携带用户输入的算术结果。
4. 发送短信验证码接口(核心!)
@RestController
public class SmsController {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String SMS_CODE_KEY_PREFIX = "sms:code:";
private static final String SMS_PHONE_LIMIT_PREFIX = "sms:limit:phone:";
private static final String SMS_IP_LIMIT_PREFIX = "sms:limit:ip:";
@PostMapping("/send-sms-code")
public ResponseEntity<String> sendSmsCode(
@RequestParam String phone,
@RequestParam String captchaInput,
HttpServletRequest request) {
// 1. 校验手机号格式
if (!isValidPhone(phone)) {
return ResponseEntity.badRequest().body("手机号格式错误");
}
// 2. 校验图形验证码
String sessionCaptcha = (String) request.getSession().getAttribute("captcha");
if (sessionCaptcha == null || !sessionCaptcha.equals(captchaInput)) {
return ResponseEntity.badRequest().body("图形验证码错误");
}
String clientIp = getClientIP(request);
// 3. 检查 IP 日限额(例如每天最多20次)
String ipLimitKey = SMS_IP_LIMIT_PREFIX + clientIp;
Integer ipCount = getRedisCount(ipLimitKey, 24 * 3600);
if (ipCount >= 20) {
return ResponseEntity.status(429).body("操作过于频繁,请明天再试");
}
// 4. 检查该手机号是否60秒内已发送
String phoneLimitKey = SMS_PHONE_LIMIT_PREFIX + phone;
Boolean isPhoneLimited = redisTemplate.hasKey(phoneLimitKey);
if (Boolean.TRUE.equals(isPhoneLimited)) {
return ResponseEntity.status(429).body("请60秒后再试");
}
// 5. 生成6位随机验证码
String code = String.valueOf((int) ((Math.random() * 9 + 1) * 100000));
// 6. 存入 Redis(5分钟有效)
redisTemplate.opsForValue().set(SMS_CODE_KEY_PREFIX + phone, code, 5, TimeUnit.MINUTES);
// 7. 设置限流标记(60秒)
redisTemplate.opsForValue().set(phoneLimitKey, "1", 60, TimeUnit.SECONDS);
// 8. 更新 IP 计数
redisTemplate.opsForValue().increment(ipLimitKey);
redisTemplate.expire(ipLimitKey, 24, TimeUnit.HOURS);
// 9. 【模拟】调用短信平台(实际替换为阿里云/腾讯云 SDK)
System.out.println("【测试】向 " + phone + " 发送验证码: " + code);
return ResponseEntity.ok("验证码已发送,请注意查收");
}
// 辅助方法:获取 Redis 中的计数值
private Integer getRedisCount(String key, int expireSeconds) {
String val = redisTemplate.opsForValue().get(key);
if (val == null) {
redisTemplate.opsForValue().set(key, "0", expireSeconds, TimeUnit.SECONDS);
return 0;
}
return Integer.parseInt(val);
}
// 辅助方法:获取客户端真实 IP
private String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
// 手机号正则校验
private boolean isValidPhone(String phone) {
return phone.matches("^1[3-9]\\\\d{9}$");
}
}
5. 验证码校验接口(用于注册/登录)
@PostMapping("/verify-code")
public ResponseEntity<String> verifyCode(@RequestParam String phone,
@RequestParam String inputCode) {
String realCode = redisTemplate.opsForValue().get(SMS_CODE_KEY_PREFIX + phone);
if (realCode == null) {
return ResponseEntity.badRequest().body("验证码已过期");
}
if (!realCode.equals(inputCode)) {
return ResponseEntity.badRequest().body("验证码错误");
}
// 验证成功!删除验证码(一次性使用)
redisTemplate.delete(SMS_CODE_KEY_PREFIX + phone);
// TODO: 执行注册/登录逻辑
return ResponseEntity.ok("验证成功");
}
✅ 关键点:验证成功后立即删除验证码,防止重复使用!
五、安全加固措施总结
| 机器刷验证码 | 前置图形验证码(或行为验证) |
| 单手机号狂点 | 60秒内限1次(Redis + TTL) |
| IP 批量注册 | IP 日限额(如20次/天) |
| 验证码爆破 | 5分钟过期 + 一次性使用 |
| 短信费用失控 | 监控告警 + 第三方平台额度限制 |
💡 进阶建议:
- 敏感操作(如改密)增加二次验证
- 使用阿里云“风险识别”服务检测黑产 IP
- 验证码长度可动态调整(如 6~8 位)
六、常见问题与避坑指南
❌ 坑 1:验证码存 Session
问题:集群部署时 Session 不共享
解决:必须用 Redis 存储
❌ 坑 2:只限制手机号,不限 IP
后果:黑产换号狂刷
解决:IP + 手机号 双维度限流
❌ 坑 3:验证码可重复使用
风险:中间人攻击重放
解决:验证后立即删除
❌ 坑 4:未校验手机号格式
后果:Redis 被恶意 Key 占满
解决:严格正则校验
七、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!
网硕互联帮助中心


评论前必须登录!
注册