1. 功能描述

拖动滑块至图片缺口,完成验证。 图片及滑块形状可自定义。

 

图 滑动验证码演示

2. 实现步骤

2.1 设计思路

2.1.1 原理

1.将左图通过Java转换成右图

图 图片转换

2.用户在前端完成拖动后,将滑块最终位置信息发送给后端,来判断是否完成拼图。

2.1.2 UML 设计

1. 项目架构图如下所示。核心类为VerificationCode 与 Verifier,功能分别为生产验证码及验证用户拖动结果。

图 项目架构u图

2.拼图验证码核心组成为模版路径提供者及图片工具类。

图 VerificationCode 主要组成

2.2 主要代码

1.根据模版形状抠图

/**
 * 抠图
 *
 * @param mainImage
 *        主图
 * @param templateImage
 *        抠图模版
 * @param x
 *        x轴位置
 * @param y
 *        y轴位置
 * @return 抠图
 */

public static BufferedImage cutoutImage(BufferedImage mainImage, BufferedImage templateImage, int x, int y) {

    Shape imageShape = null;
    try {
        imageShape = getImageShape(templateImage);
    } catch (InterruptedException e) {
        throw new RuntimeException();
    }

    int templateWidth = templateImage.getWidth();
    int templateHeight = templateImage.getHeight();
    BufferedImage image = new BufferedImage(templateWidth, templateHeight, BufferedImage.TYPE_INT_ARGB);
    Graphics2D graphics = image.createGraphics();
    graphics.clip(imageShape);
    graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    graphics.setStroke(new BasicStroke(5, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL));
    graphics.drawImage(mainImage, -x, -y , mainImage.getWidth(), mainImage.getHeight(), null);

    graphics.dispose();
    return image;
}



/**
 * 获取图片形状(从"天爱有情 https://www.tianai.cloud/"处拷贝该方法)
 *
 * @param img
 *         图片
 * @return 图片形状
 */

private static Shape getImageShape(Image img) throws InterruptedException {
    ArrayList<Integer> x = new ArrayList<>();
    ArrayList<Integer> y = new ArrayList<>();
    int width = img.getWidth(null);
    int height = img.getHeight(null);

    // 首先获取图像所有的像素信息
    PixelGrabber pgr = new PixelGrabber(img, 0, 0, -1, -1, true);
    pgr.grabPixels();
    int[] pixels = (int[]) pgr.getPixels();

    // 循环像素
    for (int i = 0; i < pixels.length; i++) {
        // 筛选,将不透明的像素的坐标加入到坐标ArrayList x和y中
        int alpha = (pixels[i] >> 24) & 0xff;
        if (alpha != 0) {
            x.add(i % width > 0 ? i % width - 1 : 0);
            y.add(i % width == 0 ? (i == 0 ? 0 : i / width - 1) : i / width);
        }
    }

    // 建立图像矩阵并初始化(0为透明,1为不透明)
    int[][] matrix = new int[height][width];
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            matrix[i][j] = 0;
        }
    }

    // 导入坐标ArrayList中的不透明坐标信息
    for (int c = 0; c < x.size(); c++) {
        matrix[y.get(c)][x.get(c)] = 1;
    }

    /*
     * 逐一水平"扫描"图像矩阵的每一行,将透明(这里也可以取不透明的)的像素生成为Rectangle,
     * 再将每一行的Rectangle通过Area类的rec对象进行合并, 最后形成一个完整的Shape图形
     */
    Area rec = new Area();
    int temp = 0;
    //生成Shape时是1取透明区域还是取非透明区域的flag
    int flag = 1;
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            if (matrix[i][j] == flag) {
                if (temp == 0) {
                    temp = j;
                }
            } else {
                if (temp != 0) {
                    rec.add(new Area(new Rectangle(temp, i, j - temp, 1)));
                    temp = 0;
                }
            }
        }
        temp = 0;
    }
    return rec;
}

2.将一张图片粘合在另外一张图片上

/**
 * 图片覆盖
 *
 * @param mainImage
 *          主图
 * @param coverImage
 *          覆盖图
 * @param x
 *          x轴位置
 * @param y
 *          y轴位置
 * @return
 */
public static BufferedImage overlayImage(BufferedImage mainImage, BufferedImage coverImage,int x, int y) {
    Graphics2D graphics = mainImage.createGraphics();
    graphics.drawImage(coverImage,x,y,coverImage.getWidth(),coverImage.getHeight(),null);
    graphics.dispose();
    return mainImage;
}

3.代码优化

3.1 自定义图片模版

   支持使用者在resource文件下自主添加模版, 模版要求为:[文件夹名称]: cutout.png, main.jpg); 默认使用自带模版。

/** 图片路径提供者 */

 public class TemplateFolderPathProvider {

    private final String[] templateFileNameList;
    private final Random random;

    private TemplateFolderPathProvider(){
        URL url = Thread.currentThread().getContextClassLoader().getResource(PuzzleParameter.getInstance().getTemplatePath());
        File file = null;
        try {
            file = new File(url.toURI());
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
        templateFileNameList = file.list();
        random = new Random();
    }

    private static TemplateFolderPathProvider instance = new TemplateFolderPathProvider();

    public static TemplateFolderPathProvider getInstance(){
        return instance;
    }

    public String randomTemplateFolderPath() {
        int pos = random.nextInt(templateFileNameList.length);
        return PuzzleParameter.getInstance().getTemplatePath() + templateFileNameList[pos];
    }

}

3.2 图片优化

    使用者自定义的模版在图片尺寸及分辨率上有差异,而我们返回给前端的则要是统一的尺码,同时也需把图片压缩到合适的大小。

图 使用者自定义模版可能带来的问题

   问题解决方案:

   1.统一宽度及高度,进行等比例缩放,对于高度超出的采取截取的方式,高度偏短的则报错;

   2.统一主图宽度与抠图宽度的宽度比,抠图按原长宽比进行缩放。缩放后,若高度超过主图高 度,则报错;

/**
 * 获取主图
 *
 * @param folderName
 *          主图文件夹路径
 * @return  主图
 */
public static BufferedImage getMainImage(String folderName) {
    BufferedImage image = getImageFromResourcePath(folderName + "/" + puzzleParameter.getTemplateMainName());
    int width = puzzleParameter.getMainWidth();
    int height = puzzleParameter.getMainHeight();
    if((height / width) > (image.getHeight() / image.getWidth())) {
        throw new RuntimeException("主图高宽比小于:" + height /  width);
    }
    return optimizeImage(image,width,height);
}

/**
 * 获取抠图
 *
 * @param folderName
 *          抠图文件夹路径
 * @return 抠图
 */
public static BufferedImage getCutoutImage(String folderName) {
    BufferedImage image = getImageFromResourcePath(folderName + "/" + puzzleParameter.getTemplateCutoutName());
    double width = puzzleParameter.getMainWidth() * puzzleParameter.getMainWithCutoutRate();
    double height = width * (image.getHeight() / image.getWidth());
    if(height > puzzleParameter.getMainHeight()) {
        throw new RuntimeException("抠图模版高宽比大于:" +  puzzleParameter.getMainHeight() / (puzzleParameter.getMainHeight() * puzzleParameter.getMainWithCutoutRate()));
    }
    return optimizeImage(image,width,height);
}


/**
 * 优化图片
 *
 * @param image
 *         待优化图片
 * @param width
 *          图片宽度
 * @param height
 *          图片高度
 *
 * @return 优化后的图片
 */
private static BufferedImage optimizeImage(BufferedImage image, double width, double height) {
    BufferedImage newImage = new BufferedImage((int)width, (int)height, BufferedImage.TYPE_INT_ARGB);
    Graphics graphics = newImage.getGraphics();
    int tempHeight = (int)(width * image.getHeight() / image.getWidth());
    graphics.drawImage(image.getScaledInstance((int)width, tempHeight, Image.SCALE_SMOOTH), 0, 0, null);
    graphics.dispose();
    return newImage;
}

3.3 信息加密

    在对滑动结果进行验证的时候,核心参数为: 抠图在主图X轴的百分比。在我们生成验证码时,这个参数可以存储在服务器上,也可以直接发送给前端,前端提交验证的时候再携带这个参数。 如果采取这个方案 ,则必须加密这个参数。

     加密要求:1,不可篡改 ;2 不可在除服务器外的其他地方被解密;对比安全性、实现复杂难度、性能要求等方面后,我们选择的方案是AES对称加密。

public class Aes {

    private final String ALGORITHM_NAME = "AES";

    public static final Charset CHARSET_UTF_8 = StandardCharsets.UTF_8;

    private final Lock lock = new ReentrantLock();

    /**
     * 密钥
     */
    private final SecretKey key;

    /**
     * 加密/解码器
     */
    private final Cipher cipher;

    private Aes() {
        this.key = generateKey();
        try {
            cipher = Cipher.getInstance(ALGORITHM_NAME);
        } catch (Exception e) {
            throw new RuntimeException("加密/解码器 初始化失败");
        }
    }

    private static Aes instance = new Aes();

    public static Aes getInstance() {
        return instance;
    }

    /**
     * 生成密钥
     *
     * @return 密钥
     */
    private SecretKey generateKey() {
        KeyGenerator keyGenerator = null;
        try {
            SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
            random.setSeed(SecureRandom.getSeed(16));
            keyGenerator = KeyGenerator.getInstance(ALGORITHM_NAME);
            keyGenerator.init(128,random);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("没有该加密算法");
        }
        return keyGenerator.generateKey();
    }

    /**
     * 加密
     *
     * @param content
     *          待加密内容
     * @return 加密内容 base64
     */
    public String encrypt(String content) {
        lock.lock();
        try {
            cipher.init(Cipher.ENCRYPT_MODE,key);
            byte[] bytes = cipher.doFinal(content.getBytes());
            return java.util.Base64.getEncoder().encodeToString(bytes);
        } catch (Exception e) {
            throw new RuntimeException("加密失败");
        }finally {
            lock.unlock();
        }
        //解锁
    }

    /**
     * 解密
     *
     * @param content
     *          待解密内容 Base64表示的字符串
     * @return 解密内容 字符串
     */
    public String decrypt(String content) {
        lock.lock();
        //base64解码
        byte[] contentBytes = content.getBytes(CHARSET_UTF_8);
        try {
            cipher.init(Cipher.DECRYPT_MODE,key);
            byte[] bytes = cipher.doFinal(Base64.getDecoder().decode(contentBytes));
            return new String(bytes,CHARSET_UTF_8);
        } catch (Exception e) {
            System.out.println(e);
            throw new RuntimeException("解密失败");
        } finally {
            lock.unlock();
        }
        //解锁
    }

}

3.4 缓存

    模版中的主图及抠图模版频繁的创建与压缩会给系统带来较大的压力,对于这些图片我们可以使用Map把它们保存起来。

private static Map<String, BufferedImage> images = new HashMap<>();

/**
 * 获取主图
 *
 * @param folderName
 *          主图文件夹路径
 * @return  主图
 */
public static BufferedImage getMainImage(String folderName) {
    String key = folderName + "main";
    if(images.get(key) == null) {
        BufferedImage image = getImageFromResourcePath(folderName + "/" + puzzleParameter.getTemplateMainName());
        int width = puzzleParameter.getMainWidth();
        int height = puzzleParameter.getMainHeight();
        if((height / width) > (image.getHeight() / image.getWidth())) {
            throw new RuntimeException("主图高宽比小于:" + height /  width);
        }
        images.put(key,optimizeImage(image,width,height));
    }
    return images.get(key);
}

3.5 自定义参数

    使用者可自主 设置主图高度、宽度 及主图于抠图模版的比例等参数。

public class PuzzleParameter {

    /**
     * 默认模版在主图的开始位置:x坐标
     */
    private final int DEFAULT_START_X = 5;

    /**
     * 默认模版在主图的开始位置:y坐标
     */
    private final int DEFAULT_START_Y = 5;

    /**
     * 默认模版路径
     */
    private final String DEFAULT_TEMPLATE_PATH = "puzzleVerificationCode/";

    /**
     * 默认主图模版名称
     */
    private final String DEFAULT_TEMPLATE_MAIN_NAME = "main.jpg";

    /**
     * 默认抠图模版名称
     */
    private final String DEFAULT_TEMPLATE_CUTOUT_NAME = "cutout.png";

    /**
     * 主图对抠图的比例
     */
    private final double DEFAULT_MAIN_WITH_CUTOUT_RATE = 0.15;

    /**
     * 主图高度
     */
    private final int DEFAULT_MAIN_HEIGHT = 300;

    /**
     * 主图宽度
     */
    private final int DEFAULT_MAIN_WIDTH = 500;

    /**
     * 误差范围  + - 百分比
     */
    private final double TOLERANCE_SCOPE = 0.01;

    private PuzzleParameter() {}

    private static volatile PuzzleParameter instance = null;

    public static synchronized PuzzleParameter getInstance() {
        if ( instance == null ) {
            instance = new PuzzleParameter();
        }
        return instance;
    }

    /**
     * 模版在主图的开始位置:x坐标
     */
    private int startX = DEFAULT_START_X;

    /**
     * 模版在主图的开始位置:y坐标
     */
    private int startY = DEFAULT_START_Y;

    /**
     * 模版路径 主图名称:main.png,抠图名称:cutout.png
     */
    private String templatePath = DEFAULT_TEMPLATE_PATH;

    /** 误差范围  + - 百分比 */
    private double toleranceScope = TOLERANCE_SCOPE;

    /**
     * 主图模版名称
     */
    private String templateMainName = DEFAULT_TEMPLATE_MAIN_NAME;

    /**
     * 抠图模版名称
     */
    private String templateCutoutName = DEFAULT_TEMPLATE_CUTOUT_NAME;

    /**
     * 主图高度
     */
    private int mainHeight = DEFAULT_MAIN_HEIGHT;

    /**
     * 主图宽度
     */
    private int mainWidth = DEFAULT_MAIN_WIDTH;

    /**
     * 主图对抠图的比例
     */
    private double mainWithCutoutRate = DEFAULT_MAIN_WITH_CUTOUT_RATE;  
}

3.6 启动检查

在组件使用时,对组件的自定义参数、模版图片等进行自动检查。只执行一次检查。

static {
    Inspector inspector = new Inspector();
    //组件的自定义参数、模版图片等进行自动检查。只执行一次检查。
    inspector.doCheck();
}

3.7 工厂模式

为了以后能扩展其他验证码形式,设计模式采用抽象工厂模式。

图 抽象工厂模式

public interface VerificationCodeFactory {

    VerificationCode createVerificationCode();

    Verifier createVerifier();

}

4 展望

4.1.轨迹检测

    大厂的滑动验证码都具有轨迹检测的功能,通过机器学习来对滑动轨迹进行检测。

/**
 * 运动轨迹校验 还未实现
 *
 * @param trackList
 *          运动轨迹
 * @return 检查结果
 */
private boolean checkTrack(List<PuzzleVerifyInfo.TrackItem> trackList) {
    return true;
}

4.2 为 springboot 配置

    使它成为springboot 工具包。通过配置文件来自定义参数,在springboot启动时,自动执行检查工作。

4.3.验证码扩展

    后续将实现汉字排序 、物品辨别等验证码。

 

Logo

前往低代码交流专区

更多推荐