🤖 验证码功能:人机大战的守门神

36 阅读11分钟

知识点编号:268
难度系数:⭐⭐⭐
实用指数:💯💯💯💯💯


📖 开篇:一场"机器人"入侵事件

某天凌晨3点,你的手机疯狂震动 📱:

告警:注册量异常!
- 3分钟内注册 10000 个账号
- 用户名规律:user001, user002, user003...
- 邮箱规律:xxx001@qq.com, xxx002@qq.com...

产品经理冲过来:

"小王!我们被机器人刷了!😱 快加验证码!"

你:

"验证码?那不是输入几个数字吗?"

产品经理:

"现在的验证码可不简单,有图形验证码、滑块验证码、行为验证码... 你不会以为还是20年前的技术吧?🙄"

今天,我们就来学习如何实现各种验证码! 🎯


🎯 验证码的演进史

┌──────────────────────────────────────────────────────────┐
│              验证码进化史 (1997-2024)                      │
└──────────────────────────────────────────────────────────┘

1997年 🐌  静态图片验证码
  "请输入图中的数字:5678"
  ↓ 被OCR识别率达99%

2005年 🔀  扭曲字符验证码
  "请输入图中的字符(倾斜+干扰线)"
  ↓ 用户体验差,识别率仅60%

2010年 🧩  拼图验证码
  "请将拼图拖动到正确位置"
  ↓ 体验提升,但仍可被破解

2015年 📱  短信验证码
  "请输入手机收到的验证码"
  ↓ 成本高,可被短信轰炸攻击

2018年 🎯  行为验证码(reCAPTCHA v3)
  "无需点击,后台分析用户行为"
  ↓ 体验最佳,但成本高

2020年 🧠  AI对抗验证码
  "滑块+轨迹分析+行为分析"
  ↓ 目前主流方案

🎨 验证码类型全家桶

1️⃣ 图形验证码(古老但实用)

┌─────────────────┐
│  8 7 K 3        │  ← 扭曲、干扰线、噪点
│ / \  /\ \       │
│ ───────────     │
└─────────────────┘

特点

  • ✅ 实现简单
  • ✅ 成本为0
  • ❌ 用户体验差
  • ❌ 容易被OCR破解

2️⃣ 滑块验证码(最流行)

┌────────────────────────────┐
│ [→] 向右滑动完成拼图       │
│                             │
│   ┌──┐                     │
│   │  │    ╔══╗            │
│   └──┘    ║  ║            │
│           ╚══╝            │
└────────────────────────────┘

特点

  • ✅ 用户体验好
  • ✅ 安全性高
  • ✅ 可定制
  • ⚠️ 实现复杂

3️⃣ 点选验证码

┌─────────────────────────────┐
│ 请依次点击【汽车】          │
│                              │
│  🚗   🏠   ✈️   🚗         │
│                              │
│  🌳   🚗   🌊   🏠         │
└─────────────────────────────┘

4️⃣ 短信验证码

【某某网】您的验证码是:123456
有效期5分钟,请勿告知他人。

5️⃣ 行为验证码(无感验证)

用户行为分析:
- 鼠标移动轨迹 ✅
- 键盘输入节奏 ✅
- 停留时间 ✅
- 浏览器指纹 ✅
→ 综合评分:95分(人类)

🖼️ 实战1:图形验证码(Java实现)

依赖引入

<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

配置类

@Configuration
public class KaptchaConfig {
    
    @Bean
    public Producer kaptchaProducer() {
        Properties properties = new Properties();
        
        // 图片宽度
        properties.setProperty("kaptcha.image.width", "150");
        // 图片高度
        properties.setProperty("kaptcha.image.height", "50");
        
        // 字符集
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
        // 字符长度
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        
        // 字体大小
        properties.setProperty("kaptcha.textproducer.font.size", "40");
        // 字体颜色
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        
        // 干扰线颜色
        properties.setProperty("kaptcha.noise.color", "blue");
        
        // 背景渐变色
        properties.setProperty("kaptcha.background.clear.from", "lightGray");
        properties.setProperty("kaptcha.background.clear.to", "white");
        
        DefaultKaptcha kaptcha = new DefaultKaptcha();
        Config config = new Config(properties);
        kaptcha.setConfig(config);
        
        return kaptcha;
    }
}

生成验证码

@RestController
@RequestMapping("/captcha")
public class CaptchaController {
    
    @Autowired
    private Producer kaptchaProducer;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 生成图形验证码
     */
    @GetMapping("/image")
    public void generateImage(HttpServletRequest request, 
                              HttpServletResponse response) throws IOException {
        
        // 1. 生成验证码文本
        String code = kaptchaProducer.createText();
        
        // 2. 生成图片
        BufferedImage image = kaptchaProducer.createImage(code);
        
        // 3. 生成唯一标识(存到Cookie或返回给前端)
        String uuid = UUID.randomUUID().toString();
        
        // 4. 存入Redis(5分钟过期)
        String key = "captcha:" + uuid;
        redisTemplate.opsForValue().set(key, code, Duration.ofMinutes(5));
        
        // 5. 返回UUID
        Cookie cookie = new Cookie("captcha_uuid", uuid);
        cookie.setMaxAge(300);  // 5分钟
        cookie.setPath("/");
        response.addCookie(cookie);
        
        // 6. 输出图片
        response.setContentType("image/jpeg");
        response.setHeader("Cache-Control", "no-cache");
        
        ServletOutputStream out = response.getOutputStream();
        ImageIO.write(image, "jpg", out);
        out.flush();
        out.close();
        
        log.info("✅ 生成验证码:{}", code);
    }
    
    /**
     * 校验验证码
     */
    @PostMapping("/verify")
    public Result verify(@RequestParam String code,
                        @CookieValue(name = "captcha_uuid") String uuid) {
        
        // 1. 从Redis获取
        String key = "captcha:" + uuid;
        String cachedCode = redisTemplate.opsForValue().get(key);
        
        // 2. 校验
        if (cachedCode == null) {
            return Result.fail("验证码已过期");
        }
        
        if (!cachedCode.equalsIgnoreCase(code)) {
            return Result.fail("验证码错误");
        }
        
        // 3. 验证通过后删除(一次性)
        redisTemplate.delete(key);
        
        return Result.success("验证通过");
    }
}

前端代码

<!DOCTYPE html>
<html>
<head>
    <title>图形验证码</title>
</head>
<body>
    <h2>请输入验证码</h2>
    
    <!-- 验证码图片 -->
    <img id="captchaImg" src="/captcha/image" 
         alt="验证码" 
         onclick="refreshCaptcha()" 
         style="cursor: pointer;">
    
    <p style="color: gray;">看不清?点击图片刷新</p>
    
    <!-- 输入框 -->
    <input type="text" id="code" placeholder="请输入验证码">
    <button onclick="verifyCaptcha()">提交</button>
    
    <script>
        // 刷新验证码
        function refreshCaptcha() {
            document.getElementById('captchaImg').src = 
                '/captcha/image?t=' + new Date().getTime();
        }
        
        // 校验验证码
        function verifyCaptcha() {
            const code = document.getElementById('code').value;
            
            fetch('/captcha/verify', {
                method: 'POST',
                headers: {'Content-Type': 'application/x-www-form-urlencoded'},
                body: 'code=' + code
            })
            .then(res => res.json())
            .then(data => {
                if (data.success) {
                    alert('验证通过!');
                } else {
                    alert(data.message);
                    refreshCaptcha();
                }
            });
        }
    </script>
</body>
</html>

🧩 实战2:滑块验证码(完整实现)

后端实现

1. 验证码实体

@Data
public class SlideCaptcha {
    private String uuid;           // 唯一标识
    private String backgroundImage; // 背景图(Base64)
    private String sliderImage;     // 滑块图(Base64)
    private Integer x;             // 缺口X坐标(后端保存,不返回)
    private Integer y;             // 缺口Y坐标
    private Long timestamp;        // 生成时间
}

2. 生成滑块验证码

@Service
public class SlideCaptchaService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 滑块宽度
    private static final int SLIDER_WIDTH = 50;
    // 滑块高度
    private static final int SLIDER_HEIGHT = 50;
    
    /**
     * 生成滑块验证码
     */
    public SlideCaptcha generate() throws IOException {
        // 1. 加载背景图(300x200)
        BufferedImage bgImage = loadRandomImage();
        int width = bgImage.getWidth();
        int height = bgImage.getHeight();
        
        // 2. 随机生成缺口位置
        int x = new Random().nextInt(width - SLIDER_WIDTH - 50) + 50;  // 避免太靠边
        int y = new Random().nextInt(height - SLIDER_HEIGHT - 20) + 10;
        
        // 3. 生成滑块图(包含缺口部分)
        BufferedImage sliderImage = new BufferedImage(
            SLIDER_WIDTH, SLIDER_HEIGHT, BufferedImage.TYPE_INT_ARGB
        );
        
        // 4. 切割滑块(圆角矩形+凹槽)
        cutSlider(bgImage, sliderImage, x, y);
        
        // 5. 在背景图上绘制缺口阴影
        drawShadow(bgImage, x, y);
        
        // 6. 转Base64
        String bgBase64 = imageToBase64(bgImage);
        String sliderBase64 = imageToBase64(sliderImage);
        
        // 7. 生成UUID
        String uuid = UUID.randomUUID().toString();
        
        // 8. 保存到Redis(坐标信息)
        String key = "slide_captcha:" + uuid;
        Map<String, Object> data = new HashMap<>();
        data.put("x", x);
        data.put("y", y);
        data.put("timestamp", System.currentTimeMillis());
        
        redisTemplate.opsForHash().putAll(key, data);
        redisTemplate.expire(key, Duration.ofMinutes(5));
        
        // 9. 返回结果(不包含x坐标)
        SlideCaptcha captcha = new SlideCaptcha();
        captcha.setUuid(uuid);
        captcha.setBackgroundImage(bgBase64);
        captcha.setSliderImage(sliderBase64);
        captcha.setY(y);
        
        return captcha;
    }
    
    /**
     * 切割滑块
     */
    private void cutSlider(BufferedImage bg, BufferedImage slider, int x, int y) {
        Graphics2D g2 = slider.createGraphics();
        
        // 创建圆角矩形路径
        Shape shape = createSliderShape();
        
        // 裁剪
        g2.setClip(shape);
        g2.drawImage(bg, -x, -y, null);
        
        // 在背景图上抠掉这部分(变成灰色)
        Graphics2D bgG2 = bg.createGraphics();
        bgG2.setColor(new Color(0, 0, 0, 100));  // 半透明黑色
        bgG2.translate(x, y);
        bgG2.fill(shape);
        
        g2.dispose();
        bgG2.dispose();
    }
    
    /**
     * 创建滑块形状(圆角矩形+凹凸)
     */
    private Shape createSliderShape() {
        // 简化版:圆角矩形
        // 实际应该更复杂,增加凹凸
        return new RoundRectangle2D.Float(0, 0, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
    }
    
    /**
     * 绘制缺口阴影
     */
    private void drawShadow(BufferedImage bg, int x, int y) {
        Graphics2D g2 = bg.createGraphics();
        g2.setColor(new Color(0, 0, 0, 50));
        g2.drawRect(x, y, SLIDER_WIDTH, SLIDER_HEIGHT);
        g2.dispose();
    }
    
    /**
     * 加载随机背景图
     */
    private BufferedImage loadRandomImage() throws IOException {
        // 从资源目录随机加载
        int index = new Random().nextInt(10) + 1;
        String path = "static/captcha/bg" + index + ".jpg";
        
        ClassPathResource resource = new ClassPathResource(path);
        return ImageIO.read(resource.getInputStream());
    }
    
    /**
     * 图片转Base64
     */
    private String imageToBase64(BufferedImage image) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(image, "png", baos);
        byte[] bytes = baos.toByteArray();
        return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
    }
    
    /**
     * 校验滑块位置
     */
    public boolean verify(String uuid, int moveX, List<Long> track) {
        String key = "slide_captcha:" + uuid;
        
        // 1. 获取真实坐标
        Integer realX = (Integer) redisTemplate.opsForHash().get(key, "x");
        Long timestamp = (Long) redisTemplate.opsForHash().get(key, "timestamp");
        
        if (realX == null) {
            log.warn("验证码已过期或不存在:{}", uuid);
            return false;
        }
        
        // 2. 校验位置(允许5像素误差)
        if (Math.abs(realX - moveX) > 5) {
            log.warn("位置错误:real={}, move={}", realX, moveX);
            return false;
        }
        
        // 3. 校验时间(不能太快,防止机器)
        long costTime = System.currentTimeMillis() - timestamp;
        if (costTime < 500) {
            log.warn("完成太快(机器):{}ms", costTime);
            return false;
        }
        
        // 4. 校验轨迹(防止直接拖到终点)
        if (!isValidTrack(track)) {
            log.warn("轨迹异常");
            return false;
        }
        
        // 5. 删除验证码(一次性)
        redisTemplate.delete(key);
        
        return true;
    }
    
    /**
     * 校验轨迹是否合法
     */
    private boolean isValidTrack(List<Long> track) {
        if (track == null || track.size() < 10) {
            return false;  // 轨迹点太少
        }
        
        // 检查是否有加速-减速过程(人类特征)
        boolean hasAcceleration = false;
        for (int i = 1; i < track.size() - 1; i++) {
            long speed1 = track.get(i) - track.get(i - 1);
            long speed2 = track.get(i + 1) - track.get(i);
            
            if (speed2 < speed1) {
                hasAcceleration = true;
                break;
            }
        }
        
        return hasAcceleration;
    }
}

3. 控制器

@RestController
@RequestMapping("/captcha/slide")
public class SlideCaptchaController {
    
    @Autowired
    private SlideCaptchaService captchaService;
    
    /**
     * 生成滑块验证码
     */
    @GetMapping("/generate")
    public Result<SlideCaptcha> generate() {
        try {
            SlideCaptcha captcha = captchaService.generate();
            return Result.success(captcha);
        } catch (Exception e) {
            log.error("生成验证码失败", e);
            return Result.fail("生成失败");
        }
    }
    
    /**
     * 校验滑块
     */
    @PostMapping("/verify")
    public Result verify(@RequestBody SlideVerifyDTO dto) {
        boolean success = captchaService.verify(
            dto.getUuid(), 
            dto.getMoveX(), 
            dto.getTrack()
        );
        
        if (success) {
            return Result.success("验证通过");
        } else {
            return Result.fail("验证失败");
        }
    }
}

@Data
public class SlideVerifyDTO {
    private String uuid;        // 验证码ID
    private Integer moveX;      // 滑动距离
    private List<Long> track;   // 移动轨迹(时间戳)
}

前端实现

<!DOCTYPE html>
<html>
<head>
    <title>滑块验证码</title>
    <style>
        .captcha-container {
            width: 300px;
            position: relative;
        }
        
        #bgImage {
            width: 100%;
            height: 200px;
        }
        
        #sliderImage {
            position: absolute;
            top: 0;
            left: 0;
            width: 50px;
            height: 50px;
        }
        
        .slider-bar {
            width: 300px;
            height: 40px;
            background: #e8e8e8;
            position: relative;
            margin-top: 10px;
        }
        
        .slider-btn {
            width: 40px;
            height: 40px;
            background: #3498db;
            position: absolute;
            cursor: pointer;
            text-align: center;
            line-height: 40px;
            color: white;
        }
    </style>
</head>
<body>
    <div class="captcha-container">
        <!-- 背景图 -->
        <img id="bgImage" src="" alt="背景">
        
        <!-- 滑块 -->
        <img id="sliderImage" src="" alt="滑块">
        
        <!-- 滑动条 -->
        <div class="slider-bar">
            <div class="slider-btn" id="sliderBtn"></div>
        </div>
    </div>
    
    <button onclick="loadCaptcha()">刷新</button>
    
    <script>
        let captchaData = null;
        let isDragging = false;
        let startX = 0;
        let track = [];  // 记录轨迹
        
        // 加载验证码
        async function loadCaptcha() {
            const res = await fetch('/captcha/slide/generate');
            const data = await res.json();
            
            if (data.success) {
                captchaData = data.data;
                
                // 显示图片
                document.getElementById('bgImage').src = captchaData.backgroundImage;
                document.getElementById('sliderImage').src = captchaData.sliderImage;
                document.getElementById('sliderImage').style.top = captchaData.y + 'px';
                
                // 重置滑块
                document.getElementById('sliderBtn').style.left = '0px';
                track = [];
            }
        }
        
        // 鼠标按下
        document.getElementById('sliderBtn').addEventListener('mousedown', (e) => {
            isDragging = true;
            startX = e.clientX;
            track = [Date.now()];  // 记录开始时间
        });
        
        // 鼠标移动
        document.addEventListener('mousemove', (e) => {
            if (!isDragging) return;
            
            let moveX = e.clientX - startX;
            if (moveX < 0) moveX = 0;
            if (moveX > 260) moveX = 260;  // 最大移动距离
            
            // 移动滑块和拼图
            document.getElementById('sliderBtn').style.left = moveX + 'px';
            document.getElementById('sliderImage').style.left = moveX + 'px';
            
            // 记录轨迹
            track.push(Date.now());
        });
        
        // 鼠标松开
        document.addEventListener('mouseup', async (e) => {
            if (!isDragging) return;
            isDragging = false;
            
            let moveX = e.clientX - startX;
            
            // 发送验证请求
            const res = await fetch('/captcha/slide/verify', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({
                    uuid: captchaData.uuid,
                    moveX: moveX,
                    track: track
                })
            });
            
            const data = await res.json();
            
            if (data.success) {
                alert('验证通过!✅');
            } else {
                alert('验证失败!请重试');
                loadCaptcha();
            }
        });
        
        // 页面加载时自动加载验证码
        window.onload = loadCaptcha;
    </script>
</body>
</html>

📱 实战3:短信验证码

后端实现

@Service
public class SmsCodeService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private SmsService smsService;  // 短信发送服务
    
    /**
     * 发送短信验证码
     */
    public Result sendCode(String phone) {
        // 1. 校验手机号
        if (!PhoneUtil.isValid(phone)) {
            return Result.fail("手机号格式错误");
        }
        
        // 2. 检查发送频率(60秒内只能发一次)
        String lockKey = "sms:lock:" + phone;
        Boolean hasLock = redisTemplate.hasKey(lockKey);
        if (Boolean.TRUE.equals(hasLock)) {
            return Result.fail("发送太频繁,请稍后再试");
        }
        
        // 3. 检查每日发送次数(防止恶意刷)
        String countKey = "sms:count:" + phone + ":" + LocalDate.now();
        Long count = redisTemplate.opsForValue().increment(countKey);
        if (count > 10) {
            return Result.fail("今日发送次数已达上限");
        }
        redisTemplate.expire(countKey, Duration.ofDays(1));
        
        // 4. 生成6位随机验证码
        String code = generateCode(6);
        
        // 5. 发送短信
        boolean success = smsService.send(phone, 
            String.format("您的验证码是:%s,5分钟内有效。", code));
        
        if (!success) {
            return Result.fail("短信发送失败");
        }
        
        // 6. 存入Redis(5分钟有效)
        String codeKey = "sms:code:" + phone;
        redisTemplate.opsForValue().set(codeKey, code, Duration.ofMinutes(5));
        
        // 7. 设置发送锁(60秒)
        redisTemplate.opsForValue().set(lockKey, "1", Duration.ofSeconds(60));
        
        log.info("✅ 发送验证码:phone={}, code={}", phone, code);
        
        return Result.success("发送成功");
    }
    
    /**
     * 校验验证码
     */
    public boolean verify(String phone, String code) {
        String key = "sms:code:" + phone;
        String cachedCode = redisTemplate.opsForValue().get(key);
        
        if (cachedCode == null) {
            return false;  // 不存在或已过期
        }
        
        if (!cachedCode.equals(code)) {
            return false;  // 不匹配
        }
        
        // 验证通过后删除
        redisTemplate.delete(key);
        return true;
    }
    
    /**
     * 生成随机验证码
     */
    private String generateCode(int length) {
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append(random.nextInt(10));
        }
        return sb.toString();
    }
}

短信发送服务(以阿里云为例)

@Service
public class AliyunSmsService {
    
    @Value("${aliyun.sms.accessKeyId}")
    private String accessKeyId;
    
    @Value("${aliyun.sms.accessKeySecret}")
    private String accessKeySecret;
    
    @Value("${aliyun.sms.signName}")
    private String signName;
    
    @Value("${aliyun.sms.templateCode}")
    private String templateCode;
    
    public boolean send(String phone, String code) {
        try {
            DefaultProfile profile = DefaultProfile.getProfile(
                "cn-hangzhou", 
                accessKeyId, 
                accessKeySecret
            );
            
            IAcsClient client = new DefaultAcsClient(profile);
            
            SendSmsRequest request = new SendSmsRequest();
            request.setPhoneNumbers(phone);
            request.setSignName(signName);
            request.setTemplateCode(templateCode);
            request.setTemplateParam("{"code":"" + code + ""}");
            
            SendSmsResponse response = client.getAcsResponse(request);
            
            return "OK".equals(response.getCode());
        } catch (Exception e) {
            log.error("短信发送失败", e);
            return false;
        }
    }
}

🛡️ 验证码防护策略

1️⃣ 防止暴力破解

@Component
public class CaptchaRateLimiter {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 检查错误次数
     */
    public boolean checkErrorCount(String key) {
        String errorKey = "captcha:error:" + key;
        Long count = redisTemplate.opsForValue().increment(errorKey);
        
        if (count == 1) {
            // 首次错误,设置过期时间
            redisTemplate.expire(errorKey, Duration.ofMinutes(10));
        }
        
        // 超过5次错误,锁定10分钟
        if (count > 5) {
            log.warn("验证码错误次数过多:{}", key);
            return false;
        }
        
        return true;
    }
}

2️⃣ 防止重放攻击

/**
 * 验证码只能使用一次
 */
public boolean verifyOnce(String uuid, String code) {
    String key = "captcha:" + uuid;
    
    // 使用Redis的GETDEL命令(原子操作)
    String cachedCode = redisTemplate.opsForValue().getAndDelete(key);
    
    if (cachedCode == null) {
        return false;  // 已使用或不存在
    }
    
    return cachedCode.equals(code);
}

3️⃣ 防止接口被刷

@Component
@Aspect
public class CaptchaRateLimitAspect {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Around("@annotation(rateLimited)")
    public Object around(ProceedingJoinPoint pjp, RateLimited rateLimited) {
        // 获取IP
        String ip = IpUtil.getClientIp();
        String key = "rate_limit:" + ip;
        
        // 检查调用次数(每分钟最多10次)
        Long count = redisTemplate.opsForValue().increment(key);
        if (count == 1) {
            redisTemplate.expire(key, Duration.ofMinutes(1));
        }
        
        if (count > 10) {
            throw new BizException("请求太频繁");
        }
        
        return pjp.proceed();
    }
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
}

🎯 最佳实践

1️⃣ 选型建议

场景推荐方案理由
注册登录图形验证码 + 短信双重验证
支付确认短信验证码安全性高
评论发布滑块验证码用户体验好
批量操作行为验证码无感知
高风险操作短信 + 人脸识别最高安全级别

2️⃣ 用户体验优化

/**
 * 智能验证码(根据风险等级动态选择)
 */
public CaptchaType chooseCaptchaType(UserContext ctx) {
    int riskScore = calculateRiskScore(ctx);
    
    if (riskScore < 30) {
        return CaptchaType.NONE;  // 低风险,无需验证
    } else if (riskScore < 60) {
        return CaptchaType.SLIDER;  // 中风险,滑块验证
    } else {
        return CaptchaType.SMS;  // 高风险,短信验证
    }
}

private int calculateRiskScore(UserContext ctx) {
    int score = 0;
    
    // 新用户
    if (ctx.isNewUser()) score += 20;
    
    // 异地登录
    if (ctx.isAbnormalLocation()) score += 30;
    
    // 频繁操作
    if (ctx.isFrequentOperation()) score += 25;
    
    // 设备指纹异常
    if (ctx.isAbnormalDevice()) score += 25;
    
    return score;
}

📝 总结

验证码技术栈选择

┌──────────────────────────────────────┐
│        验证码方案对比                 │
├──────────────────────────────────────┤
│ 图形验证码                            │
│  优点:成本低、实现简单               │
│  缺点:体验差、容易被破解             │
│  适用:低并发、内部系统              │
├──────────────────────────────────────┤
│ 滑块验证码                            │
│  优点:体验好、安全性高               │
│  缺点:实现复杂                      │
│  适用:大部分场景 ⭐⭐⭐⭐⭐        │
├──────────────────────────────────────┤
│ 短信验证码                            │
│  优点:安全性最高                    │
│  缺点:成本高(0.03-0.05元/条)      │
│  适用:支付、敏感操作                │
├──────────────────────────────────────┤
│ 行为验证码                            │
│  优点:无感验证、体验最佳             │
│  缺点:成本高、技术门槛高             │
│  适用:大厂、高并发场景              │
└──────────────────────────────────────┘

关键要点 🎯

  1. 安全三要素

    • 一次性(验证后删除)
    • 时效性(5分钟过期)
    • 频率限制(防刷)
  2. 防护策略

    • IP限流
    • 错误次数限制
    • 行为分析
  3. 用户体验

    • 智能判断(低风险免验证)
    • 多种方案组合
    • 验证码刷新

让你的系统既安全又好用! 🎉🎉🎉