防止短信验证码接口被盗刷问题

一、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("手机号不能为空");
        }
    }
}

三、问题描述

  1. 预期行为:正常用户在需要时能够获取短信验证码,且恶意用户无法通过频繁调用接口来盗刷短信验证码,确保短信发送资源合理使用,服务稳定运行。
  2. 实际行为:恶意用户可以通过编写自动化脚本,不断调用短信验证码接口,导致大量不必要的短信被发送。这是因为当前接口没有任何限制机制,无论是正常用户还是恶意用户,只要提供了手机号,就可以无限制地获取短信验证码。

四、解决方案

  1. 增加频率限制:通过记录用户请求频率,限制同一手机号在一定时间内的短信发送次数。可以使用 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("手机号不能为空");
        }
    }
}
  1. 使用图形验证码:在发送短信验证码之前,要求用户先输入图形验证码。用户只有正确输入图形验证码后,才能请求短信验证码,增加恶意调用的难度。
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("手机号不能为空");
        }
    }
}
  1. 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("手机号不能为空");
        }
    }
}
相关推荐
怒放吧德德14 分钟前
Netty 4.2 入门指南:从概念到第一个程序
java·后端·netty
雨中飘荡的记忆2 小时前
大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案
java·redis·后端
心之语歌4 小时前
基于注解+拦截器的API动态路由实现方案
java·后端
华仔啊5 小时前
Stream 代码越写越难看?JDFrame 让 Java 逻辑回归优雅
java·后端
ray_liang6 小时前
用六边形架构与整洁架构对比是伪命题?
java·架构
Ray Liang7 小时前
用六边形架构与整洁架构对比是伪命题?
java·python·c#·架构设计
Java水解7 小时前
Java 中间件:Dubbo 服务降级(Mock 机制)
java·后端
SimonKing11 小时前
OpenCode AI辅助编程,不一样的编程思路,不写一行代码
java·后端·程序员
FastBean11 小时前
Jackson View Extension Spring Boot Starter
java·后端
Seven9713 小时前
剑指offer-79、最⻓不含重复字符的⼦字符串
java