拼图验证码实现详解

前言

拼图验证码是当前较为流行的行为验证方式,用户通过拖动滑块完成拼图即可验证身份。本文将详细介绍一种基于Java的拼图验证码实现方案。

效果展示

拼图验证码效果图

实现原理

拼图验证码的核心原理是:

  1. 生成一个带有缺口的背景图
  2. 生成一个与缺口吻合的拼图块
  3. 用户拖动拼图块,系统验证拖动位置与预设缺口位置的吻合度

核心代码实现

1. 参数配置

/**
 * 拼图验证码允许偏差
 **/
private static Integer ALLOW_DEVIATION = 3;

/**
 * 网络图片地址
 **/
private final static String IMG_URL = "https://api.qjqq.cn/api/Img";

/**
 * 本地图片地址
 **/
private final static String IMG_PATH = "";

2. 参数校验与默认值设置

public static void checkCaptcha(Captcha captcha) {
    //设置画布宽度默认值
    if (captcha.getCanvasWidth() == null) {
        captcha.setCanvasWidth(320);
    }
    //设置画布高度默认值
    if (captcha.getCanvasHeight() == null) {
        captcha.setCanvasHeight(155);
    }
    //设置阻塞块宽度默认值
    if (captcha.getBlockWidth() == null) {
        captcha.setBlockWidth(65);
    }
    //设置阻塞块高度默认值
    if (captcha.getBlockHeight() == null) {
        captcha.setBlockHeight(55);
    }
    //设置阻塞块凹凸半径默认值
    if (captcha.getBlockRadius() == null) {
        captcha.setBlockRadius(9);
    }
    //设置图片来源默认值
    if (captcha.getPlace() == null) {
        captcha.setPlace(0);
    }
}

3. 获取验证码图片资源

public static BufferedImage getBufferedImage(Integer place) {
    try {
        //随机图片
        int nonce = getNonceByRange(0, 1000);
        //获取网络资源图片
        if (0 == place) {
            String imgUrl = IMG_URL;
            URL url = new URL(imgUrl);
            return ImageIO.read(url.openStream());
        }
        //获取本地图片
        else {
            String imgPath = String.format(IMG_PATH, nonce);
            File file = new File(imgPath);
            return ImageIO.read(file);
        }
    } catch (Exception e) {
        System.out.println("获取拼图资源失败");
        return null;
    }
}

4. 生成拼图和缺口

生成拼图块的核心在于创建一个具有凹凸形状的拼图轮廓:

private static int[][] getBlockData(int blockWidth, int blockHeight, int blockRadius) {
    int[][] data = new int[blockWidth][blockHeight];
    double po = Math.pow(blockRadius, 2);
    //随机生成两个圆的坐标,在4个方向上 随机找到2个方向添加凸/凹
    //凸/凹1
    int face1 = RandomUtils.nextInt(0, 4);
    //凸/凹2
    int face2;
    //保证两个凸/凹不在同一位置
    do {
        face2 = RandomUtils.nextInt(0, 4);
    } while (face1 == face2);
    //获取凸/凹起位置坐标
    int[] circle1 = getCircleCoords(face1, blockWidth, blockHeight, blockRadius);
    int[] circle2 = getCircleCoords(face2, blockWidth, blockHeight, blockRadius);
    //随机凸/凹类型
    int shape = getNonceByRange(0, 1);
    
    //圆的标准方程 (x-a)²+(y-b)²=r²,标识圆心(a,b),半径为r的圆
    for (int i = 0; i < blockWidth; i++) {
        for (int j = 0; j < blockHeight; j++) {
            data[i][j] = 0;
            //创建中间的方形区域
            if ((i >= blockRadius && i <= blockWidth - blockRadius && j >= blockRadius && j <= blockHeight - blockRadius)) {
                data[i][j] = 1;
            }
            double d1 = Math.pow(i - Objects.requireNonNull(circle1)[0], 2) + Math.pow(j - circle1[1], 2);
            double d2 = Math.pow(i - Objects.requireNonNull(circle2)[0], 2) + Math.pow(j - circle2[1], 2);
            //创建两个凸/凹
            if (d1 <= po || d2 <= po) {
                data[i][j] = shape;
            }
        }
    }
    return data;
}

5. 抠图实现

public static void cutByTemplate(BufferedImage canvasImage, BufferedImage blockImage, int blockWidth, int blockHeight, int blockRadius, int blockX, int blockY) {
    BufferedImage waterImage = new BufferedImage(blockWidth, blockHeight, BufferedImage.TYPE_4BYTE_ABGR);
    //阻塞块的轮廓图
    int[][] blockData = getBlockData(blockWidth, blockHeight, blockRadius);
    //创建阻塞块具体形状
    for (int i = 0; i < blockWidth; i++) {
        for (int j = 0; j < blockHeight; j++) {
            try {
                //原图中对应位置变色处理
                if (blockData[i][j] == 1) {
                    //背景设置为黑色
                    waterImage.setRGB(i, j, Color.BLACK.getRGB());
                    blockImage.setRGB(i, j, canvasImage.getRGB(blockX + i, blockY + j));
                    //轮廓设置为白色,取带像素和无像素的界点,判断该点是不是临界轮廓点
                    if (blockData[i + 1][j] == 0 || blockData[i][j + 1] == 0 || blockData[i - 1][j] == 0 || blockData[i][j - 1] == 0) {
                        blockImage.setRGB(i, j, Color.WHITE.getRGB());
                        waterImage.setRGB(i, j, Color.WHITE.getRGB());
                    }
                }
                //这里把背景设为透明
                else {
                    blockImage.setRGB(i, j, Color.TRANSLUCENT);
                    waterImage.setRGB(i, j, Color.TRANSLUCENT);
                }
            } catch (ArrayIndexOutOfBoundsException e) {
                //防止数组下标越界异常
            }
        }
    }
    //在画布上添加阻塞块水印
    addBlockWatermark(canvasImage, waterImage, blockX, blockY);
}

6. 生成验证码

public Object getCaptcha(Captcha captcha) {
    //参数校验
    CaptchaUtils.checkCaptcha(captcha);
    //获取画布的宽高
    int canvasWidth = captcha.getCanvasWidth();
    int canvasHeight = captcha.getCanvasHeight();
    //获取阻塞块的宽高/半径
    int blockWidth = captcha.getBlockWidth();
    int blockHeight = captcha.getBlockHeight();
    int blockRadius = captcha.getBlockRadius();
    //获取资源图
    BufferedImage canvasImage = CaptchaUtils.getBufferedImage(captcha.getPlace());
    //调整原图到指定大小
    canvasImage = CaptchaUtils.imageResize(canvasImage, canvasWidth, canvasHeight);
    //随机生成阻塞块坐标
    int blockX = CaptchaUtils.getNonceByRange(blockWidth, canvasWidth - blockWidth - 10);
    int blockY = CaptchaUtils.getNonceByRange(10, canvasHeight - blockHeight + 1);
    //阻塞块
    BufferedImage blockImage = new BufferedImage(blockWidth, blockHeight, BufferedImage.TYPE_4BYTE_ABGR);
    //新建的图像根据轮廓图颜色赋值,原图生成遮罩
    CaptchaUtils.cutByTemplate(canvasImage, blockImage, blockWidth, blockHeight, blockRadius, blockX, blockY);
    String nonceStr = UUID.randomUUID().toString().replaceAll("-", "");
    // 缓存,有效期两分钟
    redisUtil.set(IMAGE_CODE + nonceStr, String.valueOf(blockX), 2, TimeUnit.MINUTES);
    //设置返回参数
    captcha.setNonceStr(nonceStr);
    captcha.setBlockY(blockY);
    captcha.setBlockSrc(CaptchaUtils.toBase64(blockImage, "png"));
    captcha.setCanvasSrc(CaptchaUtils.toBase64(canvasImage, "png"));
    return captcha;
}

7. 验证码校验

public String checkImageCode(String imageKey, String imageCode) {
    String text = (String) redisUtil.get(IMAGE_CODE + imageKey);
    if (StringUtils.isBlank(text)) {
        return "0";
    }
    // 根据移动距离判断验证是否成功
    if (Math.abs(Integer.parseInt(text) - Integer.parseInt(imageCode)) > ALLOW_DEVIATION) {
        return "0";
    }
    return "1";
}

技术要点解析

1. 拼图轮廓生成算法

拼图块的轮廓使用数学方程来生成。通过在拼图块的四个方向上随机选择两个方向添加凸起或凹陷,使用圆的方程 (x-a)²+(y-b)²=r² 来计算边界点,从而生成特定形状的拼图块。

拼图轮廓示意图

2. 图像处理技术

  • 图像抠图: 通过设置像素的ARGB值实现图像的抠取和透明效果
  • 图像缩放: 使用Java AWT的Graphics2D功能调整图像大小
  • 水印添加: 在背景图上添加半透明水印显示拼图块阴影

3. Redis缓存验证

验证码生成后,将拼图块的正确X坐标保存在Redis中,设置过期时间为2分钟:

redisUtil.set(IMAGE_CODE + nonceStr, String.valueOf(blockX), 2, TimeUnit.MINUTES);

验证时,取出保存的坐标值与用户提交的坐标进行比对,允许有±3像素的误差:

if (Math.abs(Integer.parseInt(text) - Integer.parseInt(imageCode)) > ALLOW_DEVIATION) {
    return "0";
}

前端实现思路

前端实现主要包括以下步骤:

  1. 请求后端获取验证码图片(背景图和拼图块)
  2. 显示背景图和拼图块
  3. 监听鼠标事件,实现拖动功能
  4. 拖动完成后,将拖动距离发送给后端进行验证
  5. 根据验证结果显示成功或失败状态

前端代码示例(仅供参考):

// 拖动实现
function drag(e) {
    if (!isMouseDown) return;
    
    const moveX = e.pageX - originX;
    if (moveX < 0) {
        return;
    }
    if (moveX > maxMoveWidth) {
        return;
    }
    
    // 移动滑块
    slideBlock.style.left = moveX + 'px';
    // 移动拼图
    jigsawBlock.style.left = moveX + 'px';
}

// 验证
function verify(moveX) {
    const params = {
        imageKey: captchaData.nonceStr,
        imageCode: moveX
    };
    
    axios.post('/api/verify-captcha', params).then(res => {
        if (res.data === '1') {
            // 验证成功
            showSuccess();
        } else {
            // 验证失败
            showFail();
            resetCaptcha();
        }
    });
}

总结

本文介绍的拼图验证码实现具有以下特点:

  1. 安全性: 通过后端生成拼图块和验证,前端只负责展示和交互,提高了安全性
  2. 用户体验: 使用自然的拖动交互,比传统的字符验证码更友好
  3. 灵活配置: 支持自定义拼图大小、形状和图片来源
  4. 防机器人: 有效防止自动化程序的暴力破解

这种实现方案适用于各类需要防止机器人自动注册、登录的Web应用,能够有效提升系统安全性。

参考资料

  1. Java AWT图像处理: Oracle Java文档
  2. Redis数据缓存: Redis官方文档
  3. 前端拖拽实现: MDN文档 - 拖放API