您现在的位置是:首页 > 技术文章 > 详情<<文章列表阅读 来聊一聊这个被淘汰的图片验证码 验证码 Java 图片处理 Canvas wjyuian 2020-06-02 252 0 ### 前言 这里只讨论验证码的实现方法,不对验证码的用户体验进行探讨。 下面就是今天要讨论的验证码样子:  从上图可以看出,这个验证码分为两部分: + 点击区域(包含文字“猎技公网上科络”,其实就是猎上网络科技)。 + 下方提示区域(包含文字“猎上网络”),提示用户要在点击区域按顺序点击哪些文字。 -------- ### 点击区域的原始文字分布 通过`CSS`定位,将点击区域的图片展示给用户,通过`JS`让用户可以在该区域的文字上进行点击和取消点击。 #### 随机定位 该区域文字是通过程序进行了随机定位,目的就是打乱顺序,提高识别词组的难度。 #### 随机缩放 对所有文字进行了随机的缩放,为了肉眼可辨认,缩放值在一定范围内,再次提高文字识别难度。缩放的原则就是人眼可以快速识别,但是干扰了机器识别。 #### 随机旋转 对所有文字在一定角度内进行了旋转,进一步提高识别难度。变形的原则就是,人眼快速识别,但是干扰机器识别。可以进一步对文字进行扭曲等等。 #### 随机颜色 对所有文字进行文字前景色的随机,结合背景色的混淆,进一步提高识别难度。 #### 文字识别的干扰 还可以对文字区域进行背景图片填充,通过增加像素点的颜色干扰提示识别难度。有时候增加背景图之后,背景图会与随机的文字颜色进行融合,不仅提高了机器识别难度,更增加了肉眼识别难度。因此可以在文字的同坐标下,增加一个与之同缩放同旋转的大一号的浅色相同的文字,作为文字描边。这个描边是在目标文字之下的,所以不会是一个完全描边,但是大大降低了肉眼识别难度。 ---- ### 目标文字分布 通过`CSS`定位,将提示区域的图片展示给用户,并在图片前方提示用户“按顺序点击”。 #### 随机颜色 这部分的提示文字也进行了颜色的随机与简单的描边。这里的文字的颜色与大小都与上方点击区域对应文字的颜色与大小不同,因此增加了关联识别的难度。 ---- ### 前后端数据传输 用户对验证码的操作就是,在点击区域,按顺序点击“猎上网络”四个文字。那么点击完成之后,前端提交给后端的数据显然不可能是“猎上网络”这四个文字,否则点击区域就没有存在的必要了。 显然,前端可以告诉后端,用户点击文字坐标。可以将本次交互的`TOKEN`与点击文字的``相对``坐标按顺序提交给后端,那么后端根据`TOKEN`可以获取对应的验证码,以及此次验证码的正确点击坐标列表,将该坐标列表与前端传输的点击坐标列表进行匹配。如果完全匹配上,我们就可以认为本次验证码提交是人工操作了。 ----- ### 破解的成本 这种图形验证码都不是100%无法破解,以现如今的`OCR`技术,几乎可以破解所有图片验证码。所以,我们采用验证码的原则就是:提高验证码破解难度,使破解成功得到的收益难以cover破解成本即可。那么我们来看看上面那种验证码的破解难度在哪。 + 首先,必须知道要点击的目标文字,那么得先对提示区域的文字进行识别。背景色+文字随机色+不完整的描边,都是识别的干扰。 + 其次,对点击区域的所有可能的文字进行识别,或者从中识别出提示区域的对应文字,获得其坐标。 如果机器完成了上述两个步骤,就意味着它拿到了准备提交给后的文字点击坐标列表,那么就完成了破解。 为了再次提高破解难度,我们可以在`相对坐标`上动一些手脚,比如通过`TOKEN`来告诉前端相对坐标的信息,前端通过加密的`JS`对相对坐标进行解密,然后在提交给后端的坐标列表上进行相对坐标的加成与加密。后台接收到数据之后,同时根据`TOKEN`进行相对坐标的解密。 到了这里,我相信通过机器破解此类验证码的成本已经超过了获得的收益,意味着我们也获得了成功。 --- ### Java的实现 下面提供的`Java`实现代码并不是最佳实践,只是博主自己的思考与实现,仅供参考。相信很多读者自己有更优雅的代码。 大概的代码逻辑是这样的: 1. 创建画布并设置背景图片 2. 随机定位坐标,写入文字(点击区域文字),每个文字的熟悉都满足如下条件: 1. 不与其它已有的文字交叉 2. 随机字号 3. 随机旋转 4. 随机颜色 3. 记录随机定位文字的坐标 4. 定位目标文字(提示文字),目标文字随机颜色 #### 代码 ```Java public class IdentifyingCodeUtil { // int值的随机 public static int random(int min, int max) { return RandomUtils.nextInt(min, max); } // 文字是否相交 public static boolean isAcrossAll(List list, TextCode o) { if (list.size() < 1) { return false; } for (TextCode c : list) { if (c.isAcross(o)) { return true; } } return false; } // 随机文字,从指定的一串字符串中随机出指定个数的文字,用于提示 public static String randomString(String line, int size) { Set filter = new HashSet(); int len = line.length(); StringBuilder sb = new StringBuilder(); while (size > 0) { int random = random(0, len); while (filter.contains(random)) { random = random(0, len); } filter.add(random); sb.append(line.charAt(random)); size--; } return sb.toString(); } // 图片剪裁,用于随机从背景图,剪裁出验证码图所需要的大小的干扰背景图 private static BufferedImage cutImage(BufferedImage src, int width, int height) { int maxWidth = src.getWidth(); int maxHeight = src.getHeight(); CropParameter cropParam = new CropParameter(random(0, maxWidth - width), random(0, maxHeight - height), width, height);// 裁切参数 PlanarImage planrImage = ImageCropHelper.crop(new ImageWrapper(src).getAsPlanarImage(), cropParam); return planrImage.getAsBufferedImage(); } // 文字颜色随机 private static Color[] textColors = new Color[] { new Color(61, 87, 132), new Color(116, 172, 50), new Color(61, 51, 51), new Color(195, 87, 68), Color.BLACK, Color.GRAY }; private static Color randomTextColor() { return textColors[random(0, textColors.length)]; } // 验证码图片尺寸 private static final int CODE_WIDTH = 300; private static final int CODE_HEIGHT = 150; // 点击区域文字高度 private static final int CODE_CLICK_HEIGHT = 60; private static final int MAX_WORD_LENGTH = 7; private static BufferedImage CODE_BG_IMAGE = null; // 从网络读取背景图 private static BufferedImage getCodeBg() { if (CODE_BG_IMAGE == null) { try { CODE_BG_IMAGE = ImageIO.read(new URL( "https://www.oomabc.com/img/test.jpg")); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } return CODE_BG_IMAGE; } /** * 创建图片验证码相关参数 * * @param wordLine 原始字符串,用于显示在点击区域的文字 * @param str 提示文字,用于提示用户该点击的文字 * @return 返回提示文字字符串,用于应用层存储和对比;验证码图片的BufferedImage对象;以及原始字符串在图片中的坐标信息 */ public static CodeImgBean createCodeImg(String wordLine, String str) { if (StringUtils.isBlank(wordLine)) { return null; } if (StringUtils.isBlank(str)) { return createCodeImg(wordLine); } if (wordLine.length() > MAX_WORD_LENGTH) { wordLine = wordLine.substring(0, MAX_WORD_LENGTH); } CodeImgBean codeImgBean = new CodeImgBean(); int width = CODE_WIDTH; int height = CODE_HEIGHT; int clickWordHeight = CODE_CLICK_HEIGHT; BufferedImage bi = new BufferedImage(width, height + clickWordHeight, BufferedImage.TYPE_INT_RGB); ImageWrapper w = new ImageWrapper(bi); try { // 定位背景图片 int bgHeight = height + clickWordHeight; ImageWrapper wrapper = ImageUtilsForGroup.placeImage(w, cutImage(getCodeBg(), width, bgHeight), 0, 0, width, bgHeight, false, false); bi = wrapper.getAsBufferedImage(); } catch (IOException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } Graphics2D g2 = (Graphics2D) bi.getGraphics(); // 设置文字的平滑度 g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 保存随机文字的坐标与文字,坐标包括占地的x、y坐标,以及字体的宽度w和高度h List list = new ArrayList(); // 文字字体 String fontFamily = MySqlParameters.PLATFORM_IDENTIFY_CODE_FONT; int clickStringHeightOffset = 45 + height; int clickStringXOffset = 18; // 给画布写入文字:提示文字 for (int i = 0, len = str.length(); i < len; i++) { String tmpTxt = str.charAt(i) + ""; Font font = new Font(fontFamily, Font.PLAIN, 22); g2.setColor(Color.WHITE); g2.setFont(font); g2.drawString(tmpTxt, clickStringXOffset, clickStringHeightOffset); g2.setColor(randomTextColor()); g2.setFont(new Font(fontFamily, Font.PLAIN, 20)); g2.drawString(tmpTxt, clickStringXOffset - 1, clickStringHeightOffset); // 文字的x轴偏移量 clickStringXOffset += 22; } // 点击区域的文字 for (int i = 0, len = wordLine.length(); i < len; i++) { String text = String.valueOf(wordLine.charAt(i)); // 随机大小 int size = random(20, 33); int xmin = size, xmax = width - 20 - size, ymin = size + 5, ymax = height - size + 10; // 随机定位坐标 int x = random(xmin, xmax); int y = random(ymin, ymax); // 创建文字坐标对象 TextCode textCode = new TextCode(x, y, size, text); // 判断新产生的文字与已有的文字是否相交,如果相交,则重新生成坐标 // 判断相交的算法可以自己控制,通过阈值来控制相交程度 while (isAcrossAll(list, textCode)) { // 之类通过死循环来处理随机,性能会有较大影响;wordLine 文字越多,循环次数的增量就越大 // 所以,这里可以有更好的随机算法来优化性能 x = random(xmin, xmax); y = random(ymin, ymax); textCode = new TextCode(x, y, size, text); } // 不相交,则记录 list.add(textCode); // 随机旋转的变形 AffineTransform trans = new AffineTransform(); trans.rotate(Math.PI * (size % 6F / 10), x + size * 0.58, y - size * 0.28); g2.setTransform(trans); // 文字的描边背景色 Font fontBg = new Font(fontFamily, Font.BOLD, size + 2); g2.setColor(Color.WHITE); g2.setFont(fontBg); g2.drawString(text, x, y); // 正常文字随机颜色 g2.setColor(randomTextColor()); g2.setFont(new Font(fontFamily, Font.PLAIN, size)); g2.drawString(text, x, y); } g2.dispose(); codeImgBean.setWordList(list); // 文字坐标列表 codeImgBean.setClickedString(str); // 要点击的文字 codeImgBean.setBufferedImage(bi); // 图片的二进制流 return codeImgBean; } public static CodeImgBean createCodeImg(String wordLine) { String str = randomString(wordLine, 4); return createCodeImg(wordLine, str); } } ``` #### Java端的图片地址 前端通过图片地址`https://www.oomabc.com/login/identifyCode`来请求验证码时: 1. 后台对Request请求设置一个Cookie,比如说随机一个`token` 2. 调用前面提供的验证码生成方法,获得`CodeImgBean`对象 3. 将`CodeImgBean`对象中的文字坐标列表(wordList)和提示文字(clickedString)存储到缓存(不需要缓存`BufferedImage`对象) 4. 将`BufferedImage`对象中的二进制流刷到Response中 #### 前端提交验证码信息 前端将用户点击数据提交给后台: 1、后台根据Cookie中的`token`获取缓存的原始数据 2、将用户的点击数据进行解密,按顺序与缓存数据中的wordList进行匹配,将匹配成功的文字进行拼接 3、最终拼接完成的字符串就是用户实际点击的文字,将之与clickedString进行比较 4、比对一致,表示验证码验证通过,否则失败 ### 前端的使用 我个人,前端是通过`Raphael.js`(raphael-1.5.2.js)来对`Canvas`进行操作的。 这里贴出主要的一个js方法,部分相关代码没有贴出: ```javascript // 存储实际被点击的文字数据 var clickedList = {}; // 这里其实可以通过序号来计算 var indexArr = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; var clickIndex = 0; var clickTemp = []; var codeImg = null; function showImg1() { clickIndex = 0; // 记录用户当前点击文字的序号 clickTemp = []; // 临时记录点击文字的数据 //验证码图片的偏移量,相对于Canvas画布的坐标偏移 var imgLeft = 0, imgTop = 0; var showDiv = $('#codeImgDivShow'); // 画布对应的Dom // 创建Raphael对象,设置点击区域的最终大小 paper = Raphael('codeImgDivShow', 450, 150); // 在画布上刷上验证码图片,对图片进行了剪裁,大小为300X200 codeImg = paper.image('./identyCode?t=' + new Date().getTime(), imgLeft, imgTop, 300, 150); // 定位提示文字 $('#showClickWord').css({ 'background' : 'url(' + codeImg.attr('src') + ')', 'background-position' : '-8px -168px' }); // 点击区域的验证码图片位置,被点击时的事件 codeImg.click(function(e) { var evt = e || window.event; // 点击时的坐标 var clientX = evt.clientX; var clientY = evt.clientY; // codeCheckDiv 整个验证码组件的Dom var outPosition = $('#codeCheckDiv').position(); // 计算被点击位置,相对验证码组件的相对位置;43和61是修正参数,可以根据实际情况进行修改 var x = (clientX - (outPosition.left + 43)) ; var y = (clientY - (outPosition.top + 61)) ; // 在被点击位置插入文字,即序号;表示当前文字是第几次被点击的文字 var temp = paper.text(x , y, indexArr[clickIndex % (indexArr.length - 1)] + ''); // 设置序号的CSS样式 temp.attr({opacity : 1, 'font-size' : 14, cursor : 'default', fill : 'white', 'font-weight' : 'bold'}); // 序号外圈样式图片,这只是美化点击序号样式,加了一个外围边框 var img = paper.image('../js/11.png', x - 10, y - 11, 20, 23); // 被点击的顺序 img.node.clickIndex = clickIndex; img.textNode = temp; // 被点击之后的文字对象,用于后面删除 // 将被点击的对象暂存:点击的序号、序号对象(temp)和外围边框对象(img本身) clickTemp.push(img); // 当前位置遮盖的图片对象,被点击时事件;将会触发删除操作 // 删除当前被点击过的对象以及序号大于它的对象 img.click(function() { var tempArr = []; var thisIndex = this.node.clickIndex; // 遍历所有已经被点击的对象 for(var i in clickTemp) { var o = clickTemp[i]; // 将该序号以及其之后的序号对象全部删除 if(o && o.node.clickIndex >= thisIndex) { clickedList[o.node.clickIndex] = null; // 文字 o.textNode.remove(); // 图片 o.remove(); } else { // 其它的依旧保留 tempArr.push(o); } } // 重置被点击临时对象 clickTemp = tempArr; // clickIndex重置为下一个点击时的序号 clickIndex = thisIndex || 0; }); // 记录点击坐标 clickedList[img.node.clickIndex] = { x : x.toFixed(0), y : y.toFixed(0) }; // 序号后移,初始从0开始 clickIndex ++; }); } ``` 最后,前端只要将`clickedList`对象里面的点击数据提交给后端进行验证即可。 相关文章 为什么会有这个博客以及搭建的简略步骤 搜索引擎进阶——IK扩展之动态加载与同义词 重温Java设计模式——工厂模式 重温Java设计模式——建造者模式 Java网络编程之Netty框架学习(一) Java网络编程之Netty学习(二)—— 简单RPC实现 Java网络编程之Netty学习(三)—— RPC的服务注册、发现、降级 搜索引擎进阶——solr自定义function J2EE开发技术栈相关整理——基础版 JVM——Java类的加载机制以及生命周期 栏目导航 关于我 不止技术 工程化应用(23) 技术学习/探索(32) 自娱自乐(2) 还有生活 随便写写(1) 娱乐/放松(1) 点击排行 SpringBoot2从零开始(二)——多数据源配置 搜索引擎进阶——IK扩展之动态加载与同义词 从零开发参数同步框架(二)—— 前期准备之工具类 Nginx的nginx.conf配置部分解释 springMVC中controller参数拦截问题处理 Maven项目一键打包、上传、重启服务器 微信小程序深入踩坑总结 微信小程序的搜索高亮、自定义导航条等踩坑记录 标签云 Java(19) 搜索引擎(13) Solr(7) 参数同步(6) SpringBoot(4) ES(3) ElasticSearch(3) JVM(3) Netty(3) Spring(3) mongoDB(3) 设计模式(3) Curator(2) Docker(2) Dubbo(2) 大家推荐 魔神重返战场!厄祭战争的巴巴托斯:第四形态 搜索引擎入门——Solr查询参数详解以及如何使用Java完成对接 来聊一聊这个被淘汰的图片验证码 搜索引擎入门——聊聊schema.xml配置 搜索引擎入门——启动第一个Solr应用 君子性非异也,善假于物也——功能强大的Postman 择其善而从之——我为什么开始学习ElasticSearch 实现一个关于队列的伪需求是一种怎样的体验