基于生物行为检测的图形验证码服务(偏后端)

一、项目背景

据个人了解,目前公司提供的图形验证服务,除了第三方的之外,就是图形识别验证了,但是在一些场景中,需要加入一些仿生的安全验证组件,之前项目中,由于涉及到C端挂号场景,为了防止号贩子刷号,尝试过使用滑块验证码的方式,采用前后端交互验证的方式,提高了破解难度;
来更好的筛选出真人与机器,防止不必要的资源流失

二、流程

1、前端向后端获取裁切好的图片,包含底图,裁切后的图片,起始位置坐标等信息

2、前端捕捉用户触屏坐标,采集数据(间隔采样,坐标,时间戳)

3、AES加解密传输

4、后端进行仿生验证(滑动轨迹,速度,采样率,重合度等)

架构图

基于生物行为检测的图形验证码服务(偏后端)_第1张图片

2.1 获取裁切后的图片

首先,每次切割出的图片,阴影层位置,底图等,不能唯一,防止刷子根据图形记忆或者暴力破解的方式来验证,这里需要每次获取的时候,底图随机,阴影位置随机,需要UI伙伴多提供一些底图了哈哈
关键代码如下:
生成图片主流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * 根据模板切图
 *
 * @param templateFile
 * @param targetFile
 * @param templateType
 * @param targetType
 * @return
 * @throws Exception
 */
public static Map pictureTemplatesCut(File templateFile, File targetFile, String templateType, String targetType) throws Exception {
    Map pictureMap = new HashMap<>();
    // 文件类型
    String templateFiletype = templateType;
    String oriFiletype = targetType;
    if (StringUtils.isEmpty(templateFiletype) || StringUtils.isEmpty(oriFiletype)) {
        throw new RuntimeException("file type is empty");
    }
    // 源文件流
    File Orifile = targetFile;
    InputStream oriis = new FileInputStream(Orifile);

    // 模板图
    BufferedImage imageTemplate = ImageIO.read(templateFile);
    WIDTH = imageTemplate.getWidth();
    HEIGHT = imageTemplate.getHeight();
    generateCutoutCoordinates();
    // 最终图像
    BufferedImage newImage = new BufferedImage(WIDTH, HEIGHT, imageTemplate.getType());
    Graphics2D graphics = newImage.createGraphics();
    graphics.setBackground(Color.white);

    int bold = 5;
    // 获取随机的目标区域
    BufferedImage targetImageNoDeal = getTargetArea(X, Y, WIDTH, HEIGHT, oriis, oriFiletype);

    // 根据模板图片抠图
    newImage = DealCutPictureByTemplate(targetImageNoDeal, imageTemplate, newImage);

    // 设置“抗锯齿”的属性
    graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    graphics.setStroke(new BasicStroke(bold, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL));
    graphics.drawImage(newImage, 0, 0, null);
    graphics.dispose();

    ByteArrayOutputStream os = new ByteArrayOutputStream();//新建流。
    ImageIO.write(newImage, "png", os);//利用ImageIO类提供的write方法,将bi以png图片的数据模式写入流。
    byte[] newImages = os.toByteArray();
    pictureMap.put("newImage", newImages);

    // 源图生成遮罩
    BufferedImage oriImage = ImageIO.read(Orifile);
    byte[] oriCopyImages = DealOriPictureByTemplate(oriImage, imageTemplate, X, Y);
    pictureMap.put("oriCopyImage", oriCopyImages);
    return pictureMap;
}

随机区域切图代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * 获取目标区域
 *
 * @param x            随机切图坐标x轴位置
 * @param y            随机切图坐标y轴位置
 * @param targetWidth  切图后目标宽度
 * @param targetHeight 切图后目标高度
 * @param ois          源文件输入流
 * @return
 * @throws Exception
 */
private static BufferedImage getTargetArea(int x, int y, int targetWidth, int targetHeight, InputStream ois,
                                           String filetype) throws Exception {
    Iterator imageReaderList = ImageIO.getImageReadersByFormatName(filetype);
    ImageReader imageReader = imageReaderList.next();
    // 获取图片流
    ImageInputStream iis = ImageIO.createImageInputStream(ois);
    // 输入源中的图像将只按顺序读取
    imageReader.setInput(iis, true);

    ImageReadParam param = imageReader.getDefaultReadParam();
    Rectangle rec = new Rectangle(x, y, targetWidth, targetHeight);
    param.setSourceRegion(rec);
    BufferedImage targetImage = imageReader.read(0, param);
    return targetImage;
}

根据模板图片抠图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * 根据模板图片抠图
 *
 * @param oriImage
 * @param templateImage
 * @return
 */

private static BufferedImage DealCutPictureByTemplate(BufferedImage oriImage, BufferedImage templateImage,
                                                      BufferedImage targetImage) throws Exception {
    // 源文件图像矩阵
    int[][] oriImageData = getData(oriImage);
    // 模板图像矩阵
    int[][] templateImageData = getData(templateImage);
    // 模板图像宽度

    for (int i = 0; i < templateImageData.length; i++) {
        // 模板图片高度
        for (int j = 0; j < templateImageData[0].length; j++) {
            // 如果模板图像当前像素点不是白色 copy源文件信息到目标图片中
            int rgb = templateImageData[i][j];
            if (rgb != 16777215 && rgb <= 0) {
                targetImage.setRGB(i, j, oriImageData[i][j]);
            }
        }
    }
    return targetImage;
}

遮罩层处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
 * 抠图后原图生成
 *
 * @param oriImage
 * @param templateImage
 * @param x
 * @param y
 * @return
 * @throws Exception
 */
private static byte[] DealOriPictureByTemplate(BufferedImage oriImage, BufferedImage templateImage, int x,
                                               int y) throws Exception {
    // 源文件备份图像矩阵 支持alpha通道的rgb图像
    BufferedImage ori_copy_image = new BufferedImage(oriImage.getWidth(), oriImage.getHeight(), BufferedImage.TYPE_4BYTE_ABGR);
    // 源文件图像矩阵
    int[][] oriImageData = getData(oriImage);
    // 模板图像矩阵
    int[][] templateImageData = getData(templateImage);

    //copy 源图做不透明处理
    for (int i = 0; i < oriImageData.length; i++) {
        for (int j = 0; j < oriImageData[0].length; j++) {
            int rgb = oriImage.getRGB(i, j);
            int r = (0xff & rgb);
            int g = (0xff & (rgb >> 8));
            int b = (0xff & (rgb >> 16));
            //无透明处理
            rgb = r + (g << 8) + (b << 16) + (255 << 24);
            ori_copy_image.setRGB(i, j, rgb);
        }
    }

    for (int i = 0; i < templateImageData.length; i++) {
        for (int j = 0; j < templateImageData[0].length - 5; j++) {
            int rgb = templateImage.getRGB(i, j);
            //对源文件备份图像(x+i,y+j)坐标点进行透明处理
            if (rgb != 16777215 && rgb <= 0) {
                int rgb_ori = ori_copy_image.getRGB(x + i, y + j);
                int r = (0xff & rgb_ori);
                int g = (0xff & (rgb_ori >> 8));
                int b = (0xff & (rgb_ori >> 16));
                rgb_ori = r + (g << 8) + (b << 16) + (150 << 24);
                ori_copy_image.setRGB(x + i, y + j, rgb_ori);
            } else {
                //do nothing
            }
        }
    }
    ByteArrayOutputStream os = new ByteArrayOutputStream();//新建流。
    ImageIO.write(ori_copy_image, "png", os);//利用ImageIO类提供的write方法,将bi以png图片的数据模式写入流。
    byte b[] = os.toByteArray();//从流中获取数据数组。
    return b;
}

整体流程就是:

1、获取原图,根据原图大小生成临时图片存储

2、定义随机坐标位置进行抠图

3、抠图

4、图片的抗锯齿处理


此外,还需要在调用方service中记录该次生成图片的随机id,以及坐标位置信息,验证成功区域的可接受范围(一般定义3个像素点左右,由于当时项目用户群里有老年人,所以扩大到了8个像素)等

2.2 前端数据采集

前端需要每间隔50ms采集一次用户的坐标点,时间轴信息,用户一次滑动结束,会获取到一个滑动轨迹的采样数据数组
同时将该数据进行对称加密。将加密后的字符串,以及当前滑块组的验证id传输给后端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// 获取拼图图片url
function getImgUrl() {
  // 判断截流flag
  if (flag) {
    return;
  }
  // 更改截流flag
  flag = true;
  // 拼接url,防止缓存,添加随机请求参数
  var url = 'randomValidateCodeAction!getImageVerify.do?v=' + (Math.random() + '').slice(2);
  // 请求方法
  var ajaxOptions = {
    method: 'post',
    ajaxUrl: url,
    resultDesc: '系统异常,请稍后再试',
    loadingText: '加载中...',
    loadingType: 'block',
    successCb: function successCb(resp) {
      // 还原拼图、滑块以及滑块距离样式和事件绑定
      reset();
      // 更新校验码
      verifyCode = resp.datas.verifyCode;
      // 监听图片加载事件
      var img = new Image();
      img.onload = function () {
        // 替换对应图片路径
        gapImg.attr('src', resp.datas.backUrl);
        afterImgLoaded();
      };
      img.src = resp.datas.backUrl;
      // 替换对应图片路径
      puzzleImg.attr('src', resp.datas.blockUrl);
      // 显示图片加载loading
      ui.showLoading('图片加载中...');
      // 保存拼图距离顶端的距离
      _y = resp.datas.YCoordinate;
      // 绑定滑块事件
      slideBar.on('touchstart', touchStart);
      slideBar.on('touchend', touchEnd);
      slideBar.on('touchmove', function (e) {
        touchMove(e, start, max);
      });
    },
    failCb: function failCb(err) {
      // 获取失败
      // 提示用户获取图片失败
      ui.showTip(err.resultDesc);
      // 还原拼图、滑块以及滑块距离样式和事件绑定
      reset();
    },
    errorCb: function errorCb() {
      // 接口报错
      // 提示用户系统异常
      ui.showTip('系统服务异常,请稍后重试!');
      // 还原拼图、滑块以及滑块距离样式和事件绑定
      reset();
    }
    // 发送请求
  };new AjaxApi(ajaxOptions).run();
};

// 触摸开始
function touchStart() {
  // 隐藏高斯模糊与cover
  imgDom.removeClass('hide');
  cover.removeClass('show');
}

// 移动
function touchMove(e, start, max) {
  // 获取滑块移动距离
  var left = e.touches[0].pageX - start > max ? max : e.touches[0].pageX - start;
  // console.log(e);
  // 移动滑块
  slideBar.css('left', (left > 0 ? left : 0) + 'px');
  // 移动拼图
  puzzleImg.css('left', (left > 0 ? left / max * (imgDom.width() - puzzleImg.width()) : 0) + 'px');
  // 改变滑块滑动距离样式
  slideAfter.css('width', (left > 0 ? left + 20 : 20) + 'px');
  // 记录滑动中坐标信息
  verifyMsg.push({
    XCoordinate: parseFloat(puzzleImg.css('left')) / ratio,
    YCoordinate: e.touches[0].pageY,
    timestamp: new Date().getTime()
  });
};

// 触摸结束
function touchEnd() {
  // 获取移动位置
  // const x = parseFloat($('#slide-verify-panel .img img.puzzle').css('left'));
  // 解绑滑块触摸事件
  slideBar.off();
  // 发送请求验证结果
  var url = 'randomValidateCodeAction!verify.do';
  // 请求配置项
  var options = {
    method: 'post',
    ajaxUrl: url,
    ajaxData: {
      verifyCode: verifyCode,
      verifyMsg: getAesString(JSON.stringify(verifyMsg.slice(0, -1).filter(function (ele, index) {
        return index % 3 === 0;
      }).concat(verifyMsg[verifyMsg.length - 1])))
    },
    resultDesc: '系统异常,请稍后再试',
    loadingText: '验证中...',
    loadingType: 'block',
    successCb: function successCb(resp) {
      // 提示用户
      // 改变slider中文本
      slideText.text('验证通过!');
      // 更改slider滑动距离样式
      slideAfter.removeClass('error');
      slideAfter.addClass('pass');
      // 更改slider 滑块样式
      slideBar.removeClass('icon-close icon-forward');
      slideBar.addClass('icon-duigou');
      // 延迟提示信息
      setTimeout(function () {
        // 验证成功 
        // 跳转到指定页面
        verifyDialog && verifyDialog.fadeOut();
        // 更新验证通过flag
        verifyFlag = true;
        // 执行回调方法
        _callback && _callback();
      }, 500);
    },
    failCb: function failCb(err) {
      // 提示用户验证失败
      // 改变slider中文本
      slideText.text(err.resultDesc);
      // 更改slider滑动距离样式
      slideAfter.removeClass('pass');
      slideAfter.addClass('error');
      // 更改slider 滑块样式
      slideBar.removeClass('icon-duigou icon-forward');
      slideBar.addClass('icon-close');
      // 延迟提示信息
      setTimeout(function () {
        // 验证失败
        // 刷新图片
        getImgUrl();
      }, 500);
    },
    errorCb: function errorCb() {
      // 接口报错
      // 提示用户系统异常
      ui.showTip('系统服务异常,请稍后重试!');
      // 刷新图片
      getImgUrl();
    }

  };
  // 发送请求
  new AjaxApi(options).run();
};

2.3 AES加解密

为了防止黄牛等通过抓包来获取传输数据,需要在接口传输时,将关键数据进行加密处理,这里采用了传统的AES对称加密技术,

AES为分组密码,分组密码也就是把明文分成一组一组的,每组长度相等,每次加密一组数据,直到加密完整个明文。在AES标准规范中,分组长度只能是128位,也就是说,每个分组为16个字节(每个字节8位)。密钥的长度可以使用128位、192位或256位。密钥的长度不同,推荐加密轮数也不同,如下表所示

基于生物行为检测的图形验证码服务(偏后端)_第2张图片

本次加解密采用的是AES-128,即秘钥为16位字符串,加密轮次为10轮,
通常明文分组用字节为单位的正方形矩阵描述,称为状态矩阵。在算法的每一轮中,状态矩阵的内容不断发生变化,最后的结果作为密文输出。该矩阵中字节的排列顺序为从上到下、从左至右依次排列,如下图所示
基于生物行为检测的图形验证码服务(偏后端)_第3张图片
经过上面的加密,可以看到,明文经过AES加密后,已经面目全非了
具体的加密步骤可以参考下图
基于生物行为检测的图形验证码服务(偏后端)_第4张图片
加密的第1轮到第9轮的轮函数一样,包括4个操作:字节代换、行位移、列混合和轮密钥加。最后一轮迭代不执行列混合。另外,在第一轮迭代之前,先将明文和原始密钥进行一次异或加密操作

*第十轮的时候是没有列混合这一步的

2.3.1字节代换

AES的字节代换其实就是一个简单的查表操作。AES定义了一个S盒和一个逆S盒。
AES的S盒:

基于生物行为检测的图形验证码服务(偏后端)_第5张图片

状态矩阵中的元素按照下面的方式映射为一个新的字节:把该字节的高4位作为行值,低4位作为列值,取出S盒或者逆S盒中对应的行的元素作为输出

解密时的字节代换逆操作就是查逆S盒来变换,这里就不做赘述了

基于生物行为检测的图形验证码服务(偏后端)_第6张图片

2.3.2行位移

行移位是一个简单的左循环移位操作。当密钥长度为128比特时,状态矩阵的第0行左移0字节,第1行左移1字节,第2行左移2字节,第3行左移3字节,如下图所示:

基于生物行为检测的图形验证码服务(偏后端)_第7张图片

解密时的行位移逆变换就是将状态矩阵中的每一行执行相反的移位操作,例如AES-128中,状态矩阵的第0行右移0字节,第1行右移1字节,第2行右移2字节,第3行右移3字节。

2.3.3列混合

列混合变换是通过矩阵相乘来实现的,经行移位后的状态矩阵与固定的矩阵相乘,得到混淆后的状态矩阵,如下图的公式所示

基于生物行为检测的图形验证码服务(偏后端)_第8张图片

其中,矩阵元素的乘法和加法都是定义在基于GF(2^8)上的二元运算,并不是通常意义上的乘法和加法。这里涉及到一些信息安全上的数学知识,不过不懂这些知识也行。其实这种二元运算的加法等价于两个字节的异或,乘法则复杂一点。对于一个8位的二进制数来说,使用域上的乘法乘以(00000010)等价于左移1位(低位补0)后,再根据情况同(00011011)进行异或运算
下面举个具体的例子,输入的状态矩阵如下:

基于生物行为检测的图形验证码服务(偏后端)_第9张图片

下面,进行列混合运算:
以第一列的运算为例:

基于生物行为检测的图形验证码服务(偏后端)_第10张图片

其它列的计算就不列举了,列混合后生成的新状态矩阵如下

基于生物行为检测的图形验证码服务(偏后端)_第11张图片

2.3.4轮秘钥加

轮密钥加是将128位轮密钥Ki同状态矩阵中的数据进行逐位异或操作,如下图所示。其中,密钥Ki中每个字W[4i],W[4i+1],W[4i+2],W[4i+3]为32位比特字,包含4个字节,他们的生成算法下面在下面介绍。轮密钥加过程可以看成是字逐位异或的结果,也可以看成字节级别或者位级别的操作。也就是说,可以看成S0 S1 S2 S3 组成的32位字与W[4i]的异或运算。

基于生物行为检测的图形验证码服务(偏后端)_第12张图片

轮密钥加的逆运算同正向的轮密钥加运算完全一致,这是因为异或的逆操作是其自身。轮密钥加非常简单,但却能够影响S数组中的每一位。

2.3.5解密

解密步骤如下

基于生物行为检测的图形验证码服务(偏后端)_第13张图片

上述加密步骤最后都有对逆运算做阐述,在此就不展开讲了;
经过上面一系列的加密操作,我们前端采点记录下的坐标信息,时间戳等数据组就被加密完成,加密后再进行网络传输,这样即使被不法分子抓包,如果他们没有秘钥的话,也是无法解密,无法破解的;
同时因为所有的数据都做了防重放拦截,每一组数据都会绑定滑块组id,这样就能保证数据无法进行伪造,复用,安全性就提高了很多!

2.4 后端数据解析验证

后端接收到数据后,先验证滑块组验证id是否有效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/**
       * 先校验参数是否合法
       * 滑动组件code是否被使用
       * AES解密获取滑动点位置
       * 计算采点数,过少则直接返回
       * 计算两点间的速度,若连续相等,则返回验证失败
       * Y坐标如果一直相等,则也可以认为是非真人操作
       * 终坐标的像素偏差计算
       */
public void verify() {
	UserInfo userInfo = (UserInfo) request.getSession().getAttribute("userInfo");
	String nowOpenId = userInfo.getOpenId();
	logger.info(nowOpenId+"进入滑块验证方法");
	DataResponse dataResponse = new DataResponse();
	try {
		if(StringUtils.isEmpty(verifyCode)||StringUtils.isEmpty(verifyMsg)){
			dataResponse.setResultCode("-1");
			dataResponse.setResultDesc("参数为空!");
			super.printResult(dataResponse);
			return;
		}
		String verifyFlag=SESSION_VERIFYFLAG+this.request.getSession().getId();
		String oldVerifyCode=cachedClient.getCached(verifyFlag);			
		if(StringUtils.isNotEmpty(oldVerifyCode)&&oldVerifyCode.trim().equals(verifyCode.trim())){
			logger.info(verifyFlag+":"+verifyCode+":滑块验证码被重复利用!");
			dataResponse.setResultCode("-1");
			dataResponse.setResultDesc("该验证码已被使用,请勿重复提交!");
			super.printResult(dataResponse);
			return;
		}			
		byte[] content;
		content = new BASE64Decoder().decodeBuffer(verifyMsg);
		String msString = new String(AESSymmetricUtil.AES_CBC_Decrypt(content, AESConstant.VERIFYCODE.getBytes(),
				AESConstant.VERIFYCODE.getBytes()));
		logger.info(nowOpenId+"验证信息解密后的数据:" + msString);
		JSONArray msgJsonArr = JSONArray.fromObject(msString);
		int lastX = 0; // 上一个点的X坐标
		int lastY = 0; // 上一个点的X坐标
		int speed = 0; // 上一个点的速度
		int time = 0; // 上一个点的时间戳
		int times = 0;// 前后两次相等的次数
		boolean YVerify = false;
		if (msgJsonArr.size()<6) {
			logger.info(nowOpenId+"滑块采点过少!");
			dataResponse.setResultCode("-1");
			dataResponse.setResultDesc("验证失败,请重试!");
			super.printResult(dataResponse);
			String failTimesRedis = this.cachedClient.getCached(REDISVERIFY+userInfo.getOpenId());
			int failTimes = StringUtil.isBlank(failTimesRedis)?0:Integer.parseInt(failTimesRedis);
			this.cachedClient.setCached(REDISVERIFY+userInfo.getOpenId(),failTimes+1+"",5*60);
			return;
		}
		for (Object object : msgJsonArr) {
			JSONObject jsonObj = (JSONObject) object;
			if (lastX == 0) {
				lastX = jsonObj.getInt("XCoordinate");
				lastY = jsonObj.getInt("YCoordinate");
				time = jsonObj.getInt("timestamp");
			} else {
				// 计算速度,前后两次不相等,相等则视为电脑
				if (speed == ((jsonObj.getInt("XCoordinate") - lastX) * 100000
						/ (jsonObj.getInt("timestamp") - time))&&speed!=0) {
					times++;
					if (times == 4) {
						// 两个速度节点都相等,则失败
						logger.info(nowOpenId+"滑块验证速度验证不通过!");
						dataResponse.setResultCode("-1");
						dataResponse.setResultDesc("验证失败,请重试!");
						super.printResult(dataResponse);
						String failTimesRedis = this.cachedClient.getCached(REDISVERIFY+userInfo.getOpenId());
						int failTimes = StringUtil.isBlank(failTimesRedis)?0:Integer.parseInt(failTimesRedis);
						this.cachedClient.setCached(REDISVERIFY+userInfo.getOpenId(),failTimes+1+"",5*60);
						return;
					}
				}
				if (jsonObj.getInt("YCoordinate") != lastY) {
					YVerify = true;
				}
				speed = (jsonObj.getInt("XCoordinate") - lastX) * 100000 / (jsonObj.getInt("timestamp") - time);
				time = jsonObj.getInt("timestamp");
				lastX = jsonObj.getInt("XCoordinate");
				lastY = jsonObj.getInt("YCoordinate");
			}
		}
		int x = 0;
		int y = 0;
		int num = (int) this.request.getSession().getAttribute(verifyCode);
		JSONArray imgEnum = JsonUtil.getRandomImgVerifyJson();
		for (Object object : imgEnum) {
			JSONObject enum1 = (JSONObject)object;
			if (num == enum1.getInt("num")) {
				x = enum1.getInt("XCoordinate");
				y = enum1.getInt("YCoordinate");
				break;
			}
		}
		JSONObject jsonObj = (JSONObject) msgJsonArr.get(msgJsonArr.size() - 1);
		// 允许有三个像素的偏差
		logger.debug("X轴坐标差值" + Math.abs((jsonObj.getInt("XCoordinate") - x)));
		logger.debug(YVerify);
		if (Math.abs((jsonObj.getInt("XCoordinate") - x)) < 8 && YVerify) {
			dataResponse.setResultCode("0");
			dataResponse.setResultDesc("验证成功");
			this.request.getSession().setAttribute("verifyCodeCoordinate", StringUtil.generateNumbers());
			this.cachedClient.del(REDISVERIFY+userInfo.getOpenId());
			//存放滑块验证码标识(每个滑块验证码只能验证一次)
			this.cachedClient.setCached(verifyFlag, verifyCode.trim(), 24*60*60);
		} else {
			logger.info(nowOpenId+"滑块验证像素位置准确度验证不通过!");
			dataResponse.setResultCode("-1");
			dataResponse.setResultDesc("验证失败,请重试!");
			String failTimesRedis = this.cachedClient.getCached(REDISVERIFY+userInfo.getOpenId());
			int failTimes = StringUtil.isBlank(failTimesRedis)?0:Integer.parseInt(failTimesRedis);
			this.cachedClient.setCached(REDISVERIFY+userInfo.getOpenId(),failTimes+1+"",5*60);
		}
		super.printResult(dataResponse);
	} catch (Exception e) {
		logger.error("验证出现异常+" + verifyCode + "--" + verifyMsg + "堆栈:", e);
		dataResponse.setResultCode("-1");
		dataResponse.setResultDesc("验证失败,请重试!");
		super.printResult(dataResponse);
	}
	return;
}

基础验证流程为:

  • 先校验参数是否合法
  • 滑动组件code是否被使用
  • AES解密获取滑动点位置
  • 计算采点数,过少则直接返回
  • 计算两点间的速度,若连续相等,则返回验证失败
  • 滑动轨迹计算,如果纯直线,则也可以认为是非真人操作(非必要条件)
  • 终坐标的像素偏差计算

附流程图:

基于生物行为检测的图形验证码服务(偏后端)_第14张图片

思考:

可以加入环境因素验证,比如前置页面原始,用户填写手机号动作等

总结:

图形验证码作为一种常见的防黄牛,防刷子手段,在各个APP等C端产品中都比较常见,前后端的交叉验证能够很大程度上杜绝机器刷子,同时后端加入仿生算法,对用户滑动行为进行分析,能够进一步避免脚本等工具
目前公司提供的滑动验证多为网易/极验提供,自研的只是图形识别数字字母的方式,不知道后续是否会考虑支持滑动验证码哈哈。

希望其他感兴趣的伙伴一起交流讨论,优化验证方式,争取把该组件做成公司内部的开源项目使用

你可能感兴趣的:(spring,java,生物信息学,网络安全,javascript)