一、Bug 场景
在一个基于 Java 的 Web 应用中,用户注册或找回密码等功能依赖短信验证码进行身份验证。然而,近期发现短信验证码接口被恶意用户频繁调用,导致大量短信被发送,不仅增加了运营成本,还影响了正常用户的使用体验,甚至可能因触发运营商短信发送限制而导致服务不可用。
二、代码示例
短信发送服务类(有缺陷)
java
import java.util.Random;
public class SmsService {
public void sendSms(String phoneNumber) {
// 生成 6 位随机验证码
int code = new Random().nextInt(900000) + 100000;
System.out.println("向 " + phoneNumber + " 发送短信验证码: " + code);
// 实际应用中应调用短信发送 API 发送验证码
}
}
短信验证码接口控制器(有缺陷)
java
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class SmsVerificationCodeController {
private SmsService smsService = new SmsService();
public void sendVerificationCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
String phoneNumber = request.getParameter("phoneNumber");
if (phoneNumber != null) {
smsService.sendSms(phoneNumber);
response.getWriter().println("短信验证码已发送");
} else {
response.getWriter().println("手机号不能为空");
}
}
}
三、问题描述
- 预期行为:正常用户在需要时能够获取短信验证码,且恶意用户无法通过频繁调用接口来盗刷短信验证码,确保短信发送资源合理使用,服务稳定运行。
- 实际行为:恶意用户可以通过编写自动化脚本,不断调用短信验证码接口,导致大量不必要的短信被发送。这是因为当前接口没有任何限制机制,无论是正常用户还是恶意用户,只要提供了手机号,就可以无限制地获取短信验证码。
四、解决方案
- 增加频率限制:通过记录用户请求频率,限制同一手机号在一定时间内的短信发送次数。可以使用 Redis 来存储手机号的发送记录和时间戳。
java
import redis.clients.jedis.Jedis;
import java.util.Random;
public class SmsService {
private static final int MAX_SENDS_PER_MINUTE = 3; // 每分钟最多发送 3 次
private Jedis jedis;
public SmsService(Jedis jedis) {
this.jedis = jedis;
}
public boolean canSendSms(String phoneNumber) {
String key = "sms:limit:" + phoneNumber;
Long count = jedis.incr(key);
if (count == 1) {
jedis.expire(key, 60); // 设置过期时间为 1 分钟
}
return count <= MAX_SENDS_PER_MINUTE;
}
public void sendSms(String phoneNumber) {
if (canSendSms(phoneNumber)) {
int code = new Random().nextInt(900000) + 100000;
System.out.println("向 " + phoneNumber + " 发送短信验证码: " + code);
// 实际应用中应调用短信发送 API 发送验证码
} else {
System.out.println("发送频率过高,请稍后再试");
}
}
}
修改后的短信验证码接口控制器
java
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import redis.clients.jedis.Jedis;
import java.io.IOException;
public class SmsVerificationCodeController {
private SmsService smsService;
public SmsVerificationCodeController(Jedis jedis) {
this.smsService = new SmsService(jedis);
}
public void sendVerificationCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
String phoneNumber = request.getParameter("phoneNumber");
if (phoneNumber != null) {
smsService.sendSms(phoneNumber);
response.getWriter().println("短信验证码已发送");
} else {
response.getWriter().println("手机号不能为空");
}
}
}
- 使用图形验证码:在发送短信验证码之前,要求用户先输入图形验证码。用户只有正确输入图形验证码后,才能请求短信验证码,增加恶意调用的难度。
java
// 图形验证码生成和验证逻辑(示例代码,实际需更完善实现)
public class CaptchaService {
public String generateCaptcha() {
// 生成图形验证码的逻辑,返回验证码字符串
return "1234";
}
public boolean verifyCaptcha(String inputCaptcha, String storedCaptcha) {
return inputCaptcha.equals(storedCaptcha);
}
}
再次修改后的短信验证码接口控制器
java
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import redis.clients.jedis.Jedis;
import java.io.IOException;
public class SmsVerificationCodeController {
private SmsService smsService;
private CaptchaService captchaService;
public SmsVerificationCodeController(Jedis jedis) {
this.smsService = new SmsService(jedis);
this.captchaService = new CaptchaService();
}
public void sendVerificationCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
String phoneNumber = request.getParameter("phoneNumber");
String inputCaptcha = request.getParameter("captcha");
if (phoneNumber != null) {
String storedCaptcha = "1234"; // 实际应用中应从 session 或其他存储中获取
if (captchaService.verifyCaptcha(inputCaptcha, storedCaptcha)) {
smsService.sendSms(phoneNumber);
response.getWriter().println("短信验证码已发送");
} else {
response.getWriter().println("图形验证码错误");
}
} else {
response.getWriter().println("手机号不能为空");
}
}
}
- IP 访问限制:记录请求的 IP 地址,限制同一 IP 在一定时间内对短信验证码接口的访问次数。同样可以使用 Redis 来实现。
java
import redis.clients.jedis.Jedis;
import java.util.Random;
public class SmsService {
private static final int MAX_REQUESTS_PER_IP_PER_MINUTE = 10; // 每分钟每个 IP 最多请求 10 次
private Jedis jedis;
public SmsService(Jedis jedis) {
this.jedis = jedis;
}
public boolean canSendSmsFromIp(String ip) {
String key = "sms:ip:limit:" + ip;
Long count = jedis.incr(key);
if (count == 1) {
jedis.expire(key, 60); // 设置过期时间为 1 分钟
}
return count <= MAX_REQUESTS_PER_IP_PER_MINUTE;
}
// 其他方法不变
}
最终修改后的短信验证码接口控制器
java
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import redis.clients.jedis.Jedis;
import java.io.IOException;
public class SmsVerificationCodeController {
private SmsService smsService;
private CaptchaService captchaService;
public SmsVerificationCodeController(Jedis jedis) {
this.smsService = new SmsService(jedis);
this.captchaService = new CaptchaService();
}
public void sendVerificationCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
String phoneNumber = request.getParameter("phoneNumber");
String inputCaptcha = request.getParameter("captcha");
String clientIp = request.getRemoteAddr();
if (phoneNumber != null) {
String storedCaptcha = "1234"; // 实际应用中应从 session 或其他存储中获取
if (captchaService.verifyCaptcha(inputCaptcha, storedCaptcha) && smsService.canSendSmsFromIp(clientIp)) {
smsService.sendSms(phoneNumber);
response.getWriter().println("短信验证码已发送");
} else if (!captchaService.verifyCaptcha(inputCaptcha, storedCaptcha)) {
response.getWriter().println("图形验证码错误");
} else {
response.getWriter().println("请求频率过高,请稍后再试");
}
} else {
response.getWriter().println("手机号不能为空");
}
}
}