Java短信验证码保卫战,当羊毛党遇上“铁公鸡”

简介: 你的短信验证码接口就像双十一的购物车,一群“羊毛党”开着机器人拖拉机,以每秒100次的速度疯狂点击“发送验证码”按钮。短信费就像漏气的气球一样瘪下去,而真正的用户却收不到验证码,急得像热锅上的蚂蚁。

大家好,我是小悟。

一、被盗刷的惨状:验证码的“春运”现场

想象一下这个场景:你的短信验证码接口就像双十一的购物车,一群“羊毛党”开着机器人拖拉机,以每秒100次的速度疯狂点击“发送验证码”按钮。你的短信费就像漏气的气球一样瘪下去,而真正的用户却收不到验证码,急得像热锅上的蚂蚁。

更可怕的是,可能:

  • 用你的钱给隔壁老王发“我爱你”短信
  • 测试出所有已注册手机号(撞库攻击
  • 让你的服务器累到怀疑人生,直接躺平(DDoS

二、防御战术大全:给接口装上“金钟罩”

1. 频率限制:给“点击狂魔”戴上手铐

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * 短信卫士 - 专治各种手速过快
 */
public class SmsGuard {
    
    // 使用Guava Cache存储访问频率
    private static final Cache<String, AtomicInteger> IP_CACHE = 
        CacheBuilder.newBuilder()
            .expireAfterWrite(1, TimeUnit.HOURS)
            .build();
    
    private static final Cache<String, AtomicInteger> PHONE_CACHE = 
        CacheBuilder.newBuilder()
            .expireAfterWrite(1, TimeUnit.HOURS)
            .build();
    
    /**
     * 检查这个IP是不是在开挂
     * @param ip 客户端IP
     * @param maxAttempts 最大尝试次数(比如1小时10次)
     * @return true=正常用户,false=疑似黑客
     */
    public static boolean isIpAllowed(String ip, int maxAttempts) {
        try {
            AtomicInteger counter = IP_CACHE.get(ip, () -> new AtomicInteger(0));
            int attempts = counter.incrementAndGet();
            
            if (attempts > maxAttempts) {
                System.out.println("检测到IP " + ip + " 疑似开挂,已拦截!");
                return false;
            }
            return true;
        } catch (Exception e) {
            return false; // 出错时保守一点,拒绝访问
        }
    }
    
    /**
     * 检查这个手机号是不是在刷验证码
     * @param phone 手机号
     * @param maxSmsPerHour 每小时最多发几条
     * @return true=可以发,false=发太多了
     */
    public static boolean isPhoneAllowed(String phone, int maxSmsPerHour) {
        try {
            AtomicInteger counter = PHONE_CACHE.get(phone, () -> new AtomicInteger(0));
            int sentCount = counter.incrementAndGet();
            
            if (sentCount > maxSmsPerHour) {
                System.out.println("手机号 " + phone + " 今天已经收到" + sentCount + "条验证码,让它歇会儿吧");
                return false;
            }
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

2. 图形验证码:让机器人“看图说话”

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Random;
/**
 * 验证码生成器 - 专治眼瞎的机器人
 */
public class CaptchaGenerator {
    
    /**
     * 生成能让机器人怀疑人生的验证码
     * @return [0]=图片Base64, [1]=验证码答案
     */
    public static String[] generateCaptcha() {
        int width = 120;
        int height = 40;
        
        // 创建一张让机器人哭泣的图片
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        
        // 设置背景色(随机浅色)
        g.setColor(getRandomLightColor());
        g.fillRect(0, 0, width, height);
        
        // 画干扰线(让机器人眼花缭乱)
        g.setColor(Color.BLACK);
        Random random = new Random();
        for (int i = 0; i < 10; i++) {
            int x1 = random.nextInt(width);
            int y1 = random.nextInt(height);
            int x2 = random.nextInt(width);
            int y2 = random.nextInt(height);
            g.drawLine(x1, y1, x2, y2);
        }
        
        // 生成随机验证码(避开容易混淆的字符)
        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
        StringBuilder captchaText = new StringBuilder();
        for (int i = 0; i < 4; i++) {
            char c = chars.charAt(random.nextInt(chars.length()));
            captchaText.append(c);
            
            // 扭曲、旋转、变色 - 三连击!
            g.setFont(new Font("Arial", Font.BOLD | Font.ITALIC, 30 + random.nextInt(5)));
            g.setColor(getRandomDarkColor());
            
            // 轻微旋转字符
            double theta = random.nextDouble() * 0.5 - 0.25;
            g.rotate(theta, 20 + i * 25, 25);
            g.drawString(String.valueOf(c), 20 + i * 25, 25);
            g.rotate(-theta, 20 + i * 25, 25);
        }
        
        g.dispose();
        
        try {
            // 转换为Base64
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ImageIO.write(image, "png", baos);
            String base64Image = java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
            
            return new String[]{"data:image/png;base64," + base64Image, captchaText.toString()};
        } catch (Exception e) {
            throw new RuntimeException("验证码生成失败", e);
        }
    }
    
    private static Color getRandomLightColor() {
        Random random = new Random();
        return new Color(200 + random.nextInt(55), 
                        200 + random.nextInt(55), 
                        200 + random.nextInt(55));
    }
    
    private static Color getRandomDarkColor() {
        Random random = new Random();
        return new Color(random.nextInt(150), 
                        random.nextInt(150), 
                        random.nextInt(150));
    }
}

3. 滑动验证码:让机器人“学走路”

import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 滑动验证码 - 专治不会用鼠标的机器人
 */
public class SlideCaptchaService {
    
    // 存储验证会话
    private static final ConcurrentHashMap<String, SlideCaptchaData> SESSIONS = 
        new ConcurrentHashMap<>();
    
    /**
     * 生成滑动验证码挑战
     */
    public static SlideChallenge generateChallenge() {
        String sessionId = UUID.randomUUID().toString();
        
        // 随机生成目标位置(这里简化了,实际应该有图片处理)
        int targetX = 100 + new Random().nextInt(200);
        int targetY = 50 + new Random().nextInt(100);
        
        SlideCaptchaData data = new SlideCaptchaData(targetX, targetY);
        SESSIONS.put(sessionId, data);
        
        // 设置5分钟过期
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                SESSIONS.remove(sessionId);
            }
        }, 5 * 60 * 1000);
        
        return new SlideChallenge(sessionId, targetX, targetY);
    }
    
    /**
     * 验证滑动结果
     */
    public static boolean verify(String sessionId, int userX, int userY) {
        SlideCaptchaData data = SESSIONS.get(sessionId);
        if (data == null) {
            return false; // 会话过期
        }
        
        // 允许±5像素的误差(人类手抖,机器人太精确反而可疑)
        boolean isValid = Math.abs(userX - data.targetX) <= 5 && 
                         Math.abs(userY - data.targetY) <= 5;
        
        if (isValid) {
            SESSIONS.remove(sessionId); // 一次性使用
        }
        
        return isValid;
    }
    
    static class SlideCaptchaData {
        int targetX;
        int targetY;
        
        SlideCaptchaData(int targetX, int targetY) {
            this.targetX = targetX;
            this.targetY = targetY;
        }
    }
    
    static class SlideChallenge {
        String sessionId;
        int targetX;
        int targetY;
        
        SlideChallenge(String sessionId, int targetX, int targetY) {
            this.sessionId = sessionId;
            this.targetX = targetX;
            this.targetY = targetY;
        }
    }
}

4. 完整的短信发送服务

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
 * 短信发送服务 - 武装到牙齿的版本
 */
@Service
public class SmsService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private RiskControlService riskControlService;
    
    /**
     * 发送验证码(安全加强版)
     */
    public ApiResponse sendVerificationCode(String phone, String ip, String captchaCode, String sessionId) {
        
        // 1. 检查IP风险
        if (!riskControlService.checkIpRisk(ip)) {
            return ApiResponse.error("您的网络环境存在风险,请稍后再试");
        }
        
        // 2. 验证图形验证码(如果有)
        if (captchaCode != null && !validateCaptcha(sessionId, captchaCode)) {
            return ApiResponse.error("验证码错误,请重新输入");
        }
        
        // 3. 频率控制:同一手机号1分钟内只能发1次
        String minuteKey = "sms:minute:" + phone;
        if (Boolean.TRUE.equals(redisTemplate.hasKey(minuteKey))) {
            return ApiResponse.error("操作过于频繁,请1分钟后再试");
        }
        
        // 4. 频率控制:同一手机号1小时内最多5次
        String hourKey = "sms:hour:" + phone;
        Long hourCount = redisTemplate.opsForValue().increment(hourKey);
        if (hourCount != null && hourCount == 1) {
            redisTemplate.expire(hourKey, 1, TimeUnit.HOURS);
        }
        if (hourCount != null && hourCount > 5) {
            return ApiResponse.error("今日验证码发送次数已达上限");
        }
        
        // 5. 生成6位随机验证码
        String code = String.format("%06d", new Random().nextInt(999999));
        
        // 6. 存储验证码(5分钟过期)
        String codeKey = "sms:code:" + phone;
        redisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES);
        
        // 7. 设置1分钟冷却期
        redisTemplate.opsForValue().set(minuteKey, "1", 1, TimeUnit.MINUTES);
        
        // 8. 记录发送日志(用于分析)
        logSmsSent(phone, ip, code);
        
        // 9. 调用第三方短信服务(实际发送)
        boolean sent = realSendSms(phone, code);
        
        if (sent) {
            // 10. 返回脱敏的手机号
            String maskedPhone = phone.substring(0, 3) + "****" + phone.substring(7);
            return ApiResponse.success("验证码已发送至" + maskedPhone);
        } else {
            return ApiResponse.error("短信发送失败,请稍后重试");
        }
    }
    
    /**
     * 验证短信验证码
     */
    public boolean verifyCode(String phone, String userCode) {
        String codeKey = "sms:code:" + phone;
        String correctCode = redisTemplate.opsForValue().get(codeKey);
        
        if (correctCode == null) {
            return false; // 验证码已过期
        }
        
        // 验证成功后删除验证码(防止重复使用)
        boolean isValid = correctCode.equals(userCode);
        if (isValid) {
            redisTemplate.delete(codeKey);
        }
        
        return isValid;
    }
    
    private void logSmsSent(String phone, String ip, String code) {
        // 这里应该记录到数据库或日志系统
        System.out.println(String.format(
            "短信发送日志: phone=%s, ip=%s, code=%s, time=%s",
            phone, ip, code, new java.util.Date()
        ));
    }
    
    private boolean realSendSms(String phone, String code) {
        // 调用真实的短信服务商接口
        // 这里简化处理,实际应该用HTTP客户端调用
        try {
            System.out.println(String.format(
                "发送短信到 %s: 您的验证码是%s,5分钟内有效,打死也不要告诉别人哦!",
                phone, code
            ));
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

5. 风控服务:火眼金睛识破坏人

/**
 * 风控服务 - 专治各种不服
 */
@Service
public class RiskControlService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 综合风险评估
     */
    public RiskLevel assessRisk(String phone, String ip, String userAgent) {
        int riskScore = 0;
        
        // 1. IP地址检查
        if (isSuspiciousIp(ip)) {
            riskScore += 30;
        }
        
        // 2. User-Agent检查
        if (isSuspiciousUserAgent(userAgent)) {
            riskScore += 20;
        }
        
        // 3. 请求频率检查
        if (isHighFrequency(ip)) {
            riskScore += 40;
        }
        
        // 4. 手机号归属地 vs IP归属地
        if (!isLocationConsistent(phone, ip)) {
            riskScore += 20;
        }
        
        // 5. 历史行为检查
        if (hasBadHistory(ip)) {
            riskScore += 50;
        }
        
        // 根据分数返回风险等级
        if (riskScore >= 80) {
            return RiskLevel.HIGH;
        } else if (riskScore >= 50) {
            return RiskLevel.MEDIUM;
        } else {
            return RiskLevel.LOW;
        }
    }
    
    /**
     * 检查IP风险
     */
    public boolean checkIpRisk(String ip) {
        String key = "risk:ip:" + ip;
        Long count = redisTemplate.opsForValue().increment(key);
        
        if (count == 1) {
            redisTemplate.expire(key, 1, TimeUnit.HOURS);
        }
        
        // 1小时内超过50次请求视为风险
        return count == null || count <= 50;
    }
    
    enum RiskLevel {
        LOW,    // 低风险:正常通过
        MEDIUM, // 中风险:需要额外验证
        HIGH    // 高风险:直接拒绝
    }
    
    // 其他检测方法...
}

三、防御体系总结:打造铁桶阵

多层防御体系

  1. 第一层:前端防护
  • 图形验证码(专治简单机器人)
  • 滑动验证码(专治中级机器人)
  • 点击按钮防重放(防止连续点击)


  1. 第二层:频率限制
  • IP级别限流(防止单一IP攻击)
  • 手机号级别限流(防止针对特定号码)
  • 设备指纹限流(更精准的识别)


  1. 第三层:行为分析
  • 请求时间分布分析(机器人请求太规律)
  • 鼠标轨迹分析(机器人不会手抖)
  • 操作间隔分析(机器人反应太快)


  1. 第四层:业务逻辑
  • 验证码有效期控制(通常5分钟)
  • 验证码一次性使用(用后即焚)
  • 错误次数限制(防止暴力破解)


监控与预警

/**
 * 监控服务 - 短信接口的"心电图"
 */
@Service
public class SmsMonitorService {
    
    // 关键指标监控
    public void monitorMetrics() {
        // 1. 成功率监控
        // 2. 响应时间监控
        // 3. 异常请求监控
        // 4. 费用消耗监控
        
        // 发现异常立即告警
        // - 短信量突增
        // - 成功率突降
        // - 特定IP大量请求
    }
    
    /**
     * 自动熔断机制
     */
    public void circuitBreaker(String phonePrefix) {
        // 如果某个号段异常,自动临时屏蔽
        // 比如:170号段被大量攻击,自动限制该号段
    }
}

实践建议

  1. 按需发送:只有必要的时候才发验证码,比如注册、登录、支付
  2. 内容脱敏:短信中不要包含完整手机号
  3. 成本控制:设置每日、每月短信预算上限
  4. 验证码复杂度:6位数字足够,别搞太复杂
  5. 失败处理:失败时给出友好提示,但不要泄露细节
  6. 定期审计:定期检查日志,发现异常模式

四、道高一尺,魔高一丈

安全是一场持久战。今天防住了普通机器人,明天可能就有高级AI来挑战。关键在于:

  1. 不要依赖单一防御:多层防御才靠谱
  2. 保持更新:安全方案需要与时俱进
  3. 监控报警:早发现、早处理、早止损
  4. 成本意识:既要安全,也要考虑用户体验和实现成本

最最重要的是:不要把验证码接口当成公共厕所,谁想来就来! 给它装上门禁、摄像头、保安,还要收门票(验证手段),这样才能让羊毛党知难而退。

Java短信验证码保卫战,当羊毛党遇上“铁公鸡”.png

谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。


您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

相关文章
|
12天前
|
人工智能 JavaScript Ubuntu
5分钟上手龙虾AI!OpenClaw部署(阿里云+本地)+ 免费多模型配置保姆级教程(MiniMax、Claude、阿里云百炼)
OpenClaw(昵称“龙虾AI”)作为2026年热门的开源个人AI助手,由PSPDFKit创始人Peter Steinberger开发,核心优势在于“真正执行任务”——不仅能聊天互动,还能自动处理邮件、管理日程、订机票、写代码等,且所有数据本地处理,隐私完全可控。它支持接入MiniMax、Claude、GPT等多类大模型,兼容微信、Telegram、飞书等主流聊天工具,搭配100+可扩展技能,成为兼顾实用性与隐私性的AI工具首选。
19351 107
|
4天前
|
人工智能 安全 Linux
【OpenClaw保姆级图文教程】阿里云/本地部署集成模型Ollama/Qwen3.5/百炼 API 步骤流程及避坑指南
2026年,AI代理工具的部署逻辑已从“单一云端依赖”转向“云端+本地双轨模式”。OpenClaw(曾用名Clawdbot)作为开源AI代理框架,既支持对接阿里云百炼等云端免费API,也能通过Ollama部署本地大模型,完美解决两类核心需求:一是担心云端API泄露核心数据的隐私安全诉求;二是频繁调用导致token消耗过高的成本控制需求。
4004 6
|
7天前
|
人工智能 安全 API
OpenClaw“小龙虾”进阶保姆级攻略!阿里云/本地部署+百炼API配置+4种Skills安装方法
很多用户成功部署OpenClaw(昵称“小龙虾”)后,都会陷入“看似能用却不好用”的困境——默认状态下的OpenClaw更像一个聊天机器人,缺乏连接外部工具、执行实际任务的能力。而Skills(技能插件)作为OpenClaw的“动手能力核心”,正是打破这一局限的关键:装对Skills,它能帮你自动化处理流程、检索全网资源、管理平台账号,真正变身“能做事的AI管家”。
4907 7
|
9天前
|
人工智能 安全 前端开发
Team 版 OpenClaw:HiClaw 开源,5 分钟完成本地安装
HiClaw 基于 OpenClaw、Higress AI Gateway、Element IM 客户端+Tuwunel IM 服务器(均基于 Matrix 实时通信协议)、MinIO 共享文件系统打造。
7581 5
|
7天前
|
人工智能 API 网络安全
Mac mini × OpenClaw 保姆级配置教程(附阿里云/本地部署OpenClaw配置百炼API图文指南)
Mac mini凭借小巧机身、低功耗和稳定性能,成为OpenClaw(原Clawdbot)本地部署的首选设备——既能作为家用AI节点实现7×24小时运行,又能通过本地存储保障数据隐私,搭配阿里云部署方案,可灵活满足“长期值守”与“隐私优先”的双重需求。对新手而言,无需复杂命令行操作,无需专业技术储备,按本文步骤复制粘贴代码,即可完成OpenClaw的全流程配置,同时接入阿里云百炼API,解锁更强的AI任务执行能力。
6189 1
|
17天前
|
人工智能 自然语言处理 JavaScript
2026年Windows+Ollama本地部署OpenClaw保姆级教程:本地AI Agent+阿里云上快速搭建
2026年OpenClaw凭借本地部署、私有化运行的特性,成为打造个人智能体的核心工具,而Ollama作为轻量级本地大模型管理工具,能让OpenClaw摆脱对云端大模型的依赖,实现**本地推理、数据不泄露、全流程私有化**的智能体验。本文基于Windows 11系统,从硬件环境准备、Ollama安装与模型定制、OpenClaw部署配置、技能扩展到常见问题排查,打造保姆级本地部署教程,同时补充阿里云OpenClaw(Clawdbot)快速部署步骤,兼顾本地私有化需求与云端7×24小时运行需求,文中所有代码命令均可直接复制执行,确保零基础用户也能快速搭建属于自己的本地智能体。
18747 116
|
10天前
|
人工智能 JSON API
保姆级教程:OpenClaw阿里云及本地部署+模型切换流程+GLM5.0/Seedance2.0/MiniMax M2.5接入指南
2026年,GLM5.0、Seedance2.0、MiniMax M2.5等旗舰大模型相继发布,凭借出色的性能与极具竞争力的成本优势,成为AI工具的热门选择。OpenClaw作为灵活的AI Agent平台,支持无缝接入这些主流模型,通过简单配置即可实现“永久切换、快速切换、主备切换”三种模式,让不同场景下的任务执行更高效、更稳定。
6758 4
|
10天前
|
人工智能 JavaScript API
保姆级教程:OpenClaw阿里云/本地部署配置Tavily Search skill 实时联网,让OpenClaw“睁眼看世界”
默认状态下的OpenClaw如同“闭门造车”的隐士,仅能依赖模型训练数据回答问题,无法获取实时新闻、最新数据或训练截止日期后的新信息。2026年,激活其联网能力的最优方案是配置Tavily Search技能——无需科学上网、无需信用卡验证,每月1000次免费搜索额度完全满足个人需求,搭配ClawHub技能市场,还能一键拓展天气查询、邮件管理等实用功能。
6686 5

热门文章

最新文章