news 2026/4/17 17:28:23

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java短信验证码保卫战,当羊毛党遇上“铁公鸡”

大家好,我是小悟。

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

想象一下这个场景:你的短信验证码接口就像双十一的购物车,一群“羊毛党”开着机器人拖拉机,以每秒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. 第一层:前端防护
    • 图形验证码(专治简单机器人)
    • 滑动验证码(专治中级机器人)
    • 点击按钮防重放(防止连续点击)
  2. 第二层:频率限制
    • IP级别限流(防止单一IP攻击)
    • 手机号级别限流(防止针对特定号码)
    • 设备指纹限流(更精准的识别)
  3. 第三层:行为分析
    • 请求时间分布分析(机器人请求太规律)
    • 鼠标轨迹分析(机器人不会手抖)
    • 操作间隔分析(机器人反应太快)
  4. 第四层:业务逻辑
    • 验证码有效期控制(通常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. 成本意识:既要安全,也要考虑用户体验和实现成本

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

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

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

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 2:57:54

Android学Dart学习笔记第十三节 注解

序言 是的没错&#xff0c;dart中也有注解&#xff0c;而且和java很像 比如这个Deprecated、override 都是非常熟悉的注解。 但是我们依然要过一下&#xff0c;目的不是深入了解dart中每个注解的实际使用场景&#xff0c;而是一种泛的了解。 文档描述 注解又叫MetadataUse meta…

作者头像 李华
网站建设 2026/4/17 18:59:56

基于springboot的水果购物管理系统的设计与实现

由于互联网技术不断进步&#xff0c;网络不断来到人们的身边&#xff0c;很多信息将会对我们的社会产生影响。生活中普遍存在的企业经营管理等方面逐渐变得有序化以及网络化。传统手工作业逐渐被现代工具所取代&#xff0c;网上购物系统越来越广泛。加上我国是水果种植面积和产…

作者头像 李华
网站建设 2026/4/18 3:17:27

Excel中Lookup函数实现临界点归入下一个等级的方法

Excel中经常会遇到根据得分得到相应的评级的问题&#xff0c;例如&#xff1a;0≤得分<30为智障&#xff0c;30≤得分<60为轻障&#xff0c;60≤得分<70为不合格&#xff0c;70≤得分<80为勉强合格&#xff0c;80≤得分<90为合格&#xff0c;90≤得分<100为优…

作者头像 李华
网站建设 2026/4/18 3:19:03

49、dhcpd 参考指南

dhcpd 参考指南 1. 简介 本文将详细介绍 dhcpd 命令及其配置文件 dhcpd.conf 的语法,它是 Internet Software Consortium (ISC) 动态主机配置协议 (DHCP) 服务器 ISC dhcpd 的参考资料。需要注意的是,dhcpd 仍在开发中,相关信息基于 Beta Release 5 Patch Level 16,软件后…

作者头像 李华
网站建设 2026/4/18 3:13:03

25、数据整理、可视化与关系型数据库入门

数据整理、可视化与关系型数据库入门 1. 数据整理与可视化练习 在数据整理和可视化方面,有几个有趣的练习可以帮助我们提升相关技能。 1.1 鸣禽的生活史 Martin(2015)对温带和热带环境中的鸣禽进行了研究。他发现,在面临较高巢穴捕食风险的物种中,其峰值生长率更高;而…

作者头像 李华