常見的網站驗證方式有手機短信驗證,圖片字符驗證,滑塊驗證,滑塊圖片驗證.本文主要講解的是滑塊圖片驗證的實現流程.包括后台和前端的實現.
實現效果
使用的API
java.awt.image.BufferedImage
BufferedImage是Java類庫中是一個帶緩沖區圖像類,主要作用是將一幅圖片加載到內存中(BufferedImage生成的圖片在內存里有一個圖像緩沖區,利用這個緩沖區我們可以很方便地操作這個圖片),提供獲得繪圖對象、圖像縮放、選擇圖像平滑度等功能,通常用來做圖片大小變換、圖片變灰、設置透明不透明等。
常見的api有
讀取一張圖片
String imgPath = "/demo.jpg"; BufferedImage image = ImageIO.read(new FileInputStream(imgPath));
保存文件
ImageIO.write(image,"png",new File("xx.png"));
ImageIO提供read()和write()靜態方法,讀寫圖片,比以往的InputStream讀寫更方便。
像素處理
getRGB(int x, int y) setRGB(intx ,inty,int rgb)
獲取Graphics2D對象
Graphics2D g2d = parentImage.createGraphics();
Graphics2D是一個畫圖工具,可以實現對圖片進行畫畫處理,比如花直線,圓,方形等操作.除此之外,還可以創建透明背景的圖片.本方案就是用它來對扣出來的圖透明化處理.
Thumbnails
該類用於對圖片進行壓縮以符合大小要求.
Thumbnails.of(image)
.forceSize(width,height) //.width(width).height(height) .asBufferedImage();
使用forceSize強制大小時,對圖片會有一定的像素損耗.使用width(width).height(height)時圖片的大小不會和設定的一致.
這里用來對網上下載的圖片進行大小處理,當然也可以用其他圖像處理工具,比如PS
依賴
<dependency> <groupId>net.coobird</groupId> <artifactId>thumbnailator</artifactId> <version>0.4.11</version> </dependency>
基礎知識
一張圖片的大小通常用像素數量來表示,比如1320*600(寬*高),對於彩色圖片,每一個像素點有RGB來表示.比如(250,180,0)表示黃色,或者使用十六進制表示#FFB400.
RGB查詢
https://tool.oschina.net/commons?type=3
因此要想把一張圖扣出來一部分,只要確定好摳圖區域,使用摳圖區域的像素值來創建另一張圖,原摳圖區域填充其他像素值.就可以得到兩張分體圖,組合在一起構成一副完整的圖.而對於被扣出來的圖,還要對其進行透明化處理.
處理流程
圖片校驗最需要考慮的是安全性,因此需要將數據發送給后端進行校驗,而不是在前端進行校驗.
1.前端刷新圖片請求給后台
2.后台收到請后后開始進行處理
3.后台隨機從圖片文件夾中取一張圖片
4.對圖片進行大小檢測,圖片偏小則拋出異常,偏大則進行截圖處理.實際生產環境應該使用處理好的圖片,就可以省略這一步
5.確定摳圖區的原點坐標,為了保證效果.將圖片寬度分成四段,最終的原點橫坐標位於2/4-3/4范圍內.坐標示意如下,圖片左上腳是原點(x0,y0)
(x0,y0) (xMax,y0) **************** * * * * * * **************** (x0,yMax) (xMax,yMax)
6.根據摳圖區的原點對摳圖區進行標定.標定區如上圖所示,由正方形和半圓組成,其中一邊的半圓突出,另一邊凹陷.
7.對原圖的被摳區域進行灰度處理,填充其他顏色
8.對摳出來的圖進行背景透明化處理,並截圖(截取圖片范圍內的圖)
9.返回扣出來圖的大小,偏移量,兩張圖的base64數據返回給前端
10.前端渲染圖片
11.移動滑塊,滑塊移動時摳出來的圖也會跟着移動,兩者移動的偏移量是一樣的.鼠標釋放時將偏移量發送給后端進行校驗.
12.后端校驗后將結果返回給前端,前端根據該結果做不同的處理
13.一般是登錄才進行此類的圖片驗證,因此在點擊登錄時,偏移量仍然和帳號密碼再次發送,后端再一次進行校驗
14.完成
后台實現
屬性說明
//圖片的路徑 private String basePathClasspath = "img/"; private String basePathFile = "src/main/resources/img/"; private String basePath = basePathFile; private String basePathOutput = "src/main/resources/img/out/"; //圖片的最大大小 private static int IMAGE_MAX_WIDTH = 300; private static int IMAGE_MAX_HEIGHT = 260; //摳圖上面的半徑 private static int RADIUS = IMAGE_MAX_WIDTH/20; //摳圖區域的高度 private static int CUT_HEIGHT = IMAGE_MAX_WIDTH/5; //摳圖區域的寬度 private static int CUT_WIDTH = IMAGE_MAX_WIDTH/5; //被扣地方填充的顏色 private static int FLAG = 0x778899; //輸出圖片后綴 private static String IMAGE_SUFFIX = "png"; // private int imageOffset = 0; //摳圖部分凸起的方向 private Location location; ImageResult imageResult = new ImageResult(); // private String ORI_IMAGE_KEY = "ORI_IMAGE_KEY"; private String CUT_IMAGE_KEY = "CUT_IMAGE_KEY"; //摳圖區的原點坐標(x0,y0) /* (x0,y0) (xMax,y0) **************** * * * * * * **************** (x0,yMax) (xMax,yMax) */ private int XPOS; private int YPOS;
對外提供的接口
也是任務的流程處理
public ImageResult imageResult(File file) throws IOException { log.info("file = {}",file.getName()); BufferedImage oriBufferedImage = getBufferedImage(file); //檢測圖片大小 oriBufferedImage = checkImage(oriBufferedImage); //初始化方形的原點坐標 createXYPos(oriBufferedImage); //獲取被扣圖像的標志圖 int[][] blockData = getBlockData(oriBufferedImage); //printBlockData(blockData); //計算摳圖區域的信息 createImageMessage(); //獲取扣了圖的原圖和被扣部分的圖 Map<String,BufferedImage> imageMap = cutByTemplate(oriBufferedImage,blockData); //處理完成
//設置返回的數據 imageResult.setOriImage(ImageBase64(imageMap.get(ORI_IMAGE_KEY))); imageResult.setCutImage(ImageBase64(imageMap.get(CUT_IMAGE_KEY))); imageResult.setXpos(imageMessage.getXpos()); imageResult.setYpos(imageMessage.getYpos()); imageResult.setCutImageWidth(imageMessage.getCutImageWidth()); imageResult.setCutImageHeight(imageMessage.getCutImageHeight()); return imageResult; }
確定原點的坐標
這里需要注意兩點
一是摳圖區域不能超過原圖范圍,因此隨機生成范圍需要減去摳圖區的長度和半圓的半徑
二是為了保證用戶體驗,限定橫坐標在圖像的2/4-3/4處,才能保證滑塊有一定的滑程,同時保證摳圖不會超出原圖范圍.
/** *功能描述 獲取摳圖區的坐標原點 * @author lgj * @Description * @date 3/29/20 * @param: * @return: void * */ public void createXYPos(BufferedImage oriImage){ int height = oriImage.getHeight(); int width = oriImage.getWidth(); XPOS = new Random().nextInt(width-CUT_WIDTH-RADIUS); YPOS = new Random().nextInt(height-CUT_HEIGHT-RADIUS); //確保橫坐標位於2/4--3/4 int div = (IMAGE_MAX_WIDTH/4); if(XPOS/div == 0 ){ XPOS = XPOS + div*2; } else if(XPOS/div == 1 ){ XPOS = XPOS + div; } else if(XPOS/div == 3 ){ XPOS = XPOS - div; } }
標記摳圖區域
這里使用一個二維數組locations[width][height]來保存摳圖標記數據,每一個數據表示位置和是否為摳圖區,使用常量FLAG進行標記
這里的摳圖區為一個巨型加上突出的半圓和凹陷的半圓.
對於半圓的處理參考該公式: (x-a)2+(y-b)2=R2,
其中(a,b)為圓中心的坐標,R為圓半徑.(x,y)為任一坐標
(x,y)在圓上: (x-a)2+(y-b)2 == R2
(x,y)在圓內: (x-a)2+(y-b)2 < R2
(x,y) 在圓外: (x-a)2+(y-b)2 > R2
public int[][] getBlockData(BufferedImage oriImage){ int height = oriImage.getHeight(); int width = oriImage.getWidth(); int[][] blockData =new int[width][height]; Location locations[] = {Location.UP,Location.LEFT,Location.DOWN,Location.RIGHT}; //矩形 for(int x = 0; x< width; x++){ for(int y = 0; y < height; y++){ blockData[x][y] = 0; if ( (x > XPOS) && (x < (XPOS+CUT_WIDTH)) && (y > YPOS) && (y < (YPOS+CUT_HEIGHT))){ blockData[x][y] = FLAG; } } } //圓形突出區域 //突出圓形的原點坐標(x,y) int xBulgeCenter=0,yBulgeCenter=0; // int xConcaveCenter=0,yConcaveCenter=0; //位於矩形的哪一邊,0123--上下左右 location = locations[new Random().nextInt(3)]; if(location == Location.UP){ //上 凸起 xBulgeCenter = XPOS + CUT_WIDTH/2; yBulgeCenter = YPOS; //左 凹陷 xConcaveCenter = XPOS ; yConcaveCenter = YPOS + CUT_HEIGHT/2; } else if(location == Location.DOWN){ //下 凸起 xBulgeCenter = XPOS + CUT_WIDTH/2; yBulgeCenter = YPOS + CUT_HEIGHT; //右 凹陷 xConcaveCenter = XPOS + CUT_WIDTH; yConcaveCenter = YPOS + CUT_HEIGHT/2; } else if(location == Location.LEFT){ //左 凸起 xBulgeCenter = XPOS ; yBulgeCenter = YPOS + CUT_HEIGHT/2; //下 凹陷 xConcaveCenter = XPOS + CUT_WIDTH/2; yConcaveCenter = YPOS + CUT_HEIGHT; } else { //Location.RIGHT //右 凸起 xBulgeCenter = XPOS + CUT_WIDTH; yBulgeCenter = YPOS + CUT_HEIGHT/2; //上 凹陷 xConcaveCenter = XPOS + CUT_WIDTH/2; yConcaveCenter = YPOS; } //for test log.info("突出圓形位置:"+location); log.info("XPOS={} YPOS={}",XPOS,YPOS); log.info("xBulgeCenter={} yBulgeCenter={}",xBulgeCenter,yBulgeCenter); log.info("xConcaveCenter={} yConcaveCenter={}",xConcaveCenter,yConcaveCenter); //半徑的平方 int RADIUS_POW2 = RADIUS * RADIUS; //凸起部分 for(int x = xBulgeCenter-RADIUS; x< xBulgeCenter+RADIUS; x++){ for(int y = yBulgeCenter-RADIUS; y < yBulgeCenter+RADIUS; y++){ //(x-a)2+(y-b)2 = r2 if(Math.pow((x-xBulgeCenter),2) + Math.pow((y-yBulgeCenter),2) <= RADIUS_POW2){ blockData[x][y] = FLAG; } } } //凹陷部分 for(int x = xConcaveCenter-RADIUS; x< xConcaveCenter+RADIUS; x++){ for(int y = yConcaveCenter-RADIUS; y < yConcaveCenter+RADIUS; y++){ //(x-a)2+(y-b)2 = r2 if(Math.pow((x-xConcaveCenter),2) + Math.pow((y-yConcaveCenter),2) < RADIUS_POW2){ blockData[x][y] = 0; } } } return blockData; }
獲取摳完圖的原圖和被摳出來的圖
通過遍歷摳圖數據blockData來進行摳圖.原圖被標記的位置使用FLAG進行填充,而摳出來的部分重新構成一張同樣大小的圖
這里的操作是:
1.創建一個與摳圖區域大小(w*h)的圖,並將背景設為透明
2.遍歷摳圖區域,原圖被摳的地方填充其他顏色
3.摳出來的像素點復制到上面創建的透明圖
public Map<String,BufferedImage> cutByTemplate(BufferedImage oriImage, int[][] blockData){ Map<String,BufferedImage> imgMap = new HashMap<>(); //創建一個與摳圖區域大小的圖 BufferedImage cutImage = new BufferedImage(imageMessage.cutImageWidth,imageMessage.cutImageHeight,oriImage.getType()); // 獲取Graphics2D Graphics2D g2d = cutImage.createGraphics(); //透明化整張圖 cutImage = g2d.getDeviceConfiguration() .createCompatibleImage(imageMessage.cutImageWidth,imageMessage.cutImageHeight, Transparency.BITMASK); g2d.dispose(); g2d = cutImage.createGraphics(); // 背景透明代碼結束 log.info("imageMessage = {}",imageMessage); int xmax = imageMessage.xpos + imageMessage.cutImageWidth; int ymax = imageMessage.ypos + imageMessage.cutImageHeight; //只對摳圖區域進行遍歷 for(int x = imageMessage.xpos; x< xmax; x++){ for(int y = imageMessage.ypos; y < ymax; y++){ int oriRgb = oriImage.getRGB(x,y); if(blockData[x][y] == FLAG){
//原圖 oriImage.setRGB(x,y,FLAG); //摳的圖 g2d.setColor(color(oriRgb)); g2d.setStroke(new BasicStroke(1f)); g2d.fillRect(x-imageMessage.xpos, y-imageMessage.ypos, 1, 1); } } } // 釋放對象 g2d.dispose(); imgMap.put(ORI_IMAGE_KEY,oriImage); imgMap.put(CUT_IMAGE_KEY,cutImage); return imgMap; }
圖片原始數據轉換成base64格式數據
由於圖片原始數據很多是不可打印字符,因此需要將其轉換成base64格式,再進行發送
private String ImageBase64(BufferedImage bufferedImage) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); ImageIO.write(bufferedImage, "png", out); //轉成byte數組 byte[] bytes = out.toByteArray(); BASE64Encoder encoder = new BASE64Encoder(); //生成BASE64編碼 return encoder.encode(bytes); }
控制器
在進行校驗時,需要允許一定的誤差.
package slide.picture.verification.demo.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import slide.picture.verification.demo.image.ImageResult; import slide.picture.verification.demo.image.ImgUtil; import slide.picture.verification.demo.ret.RetCode; import slide.picture.verification.demo.ret.WebReturn; import slide.picture.verification.demo.time.TimeUtil; import javax.jws.WebResult; import java.util.concurrent.TimeUnit; @Slf4j @RestController @RequestMapping("/slider") public class SliderController { private int xPosCache = 0; @RequestMapping("/image") public WebReturn image(){ log.info("/slider/image"); ImageResult imageResult = null; try{ TimeUtil.start(1); imageResult = new ImgUtil().imageResult(); TimeUtil.end(1); xPosCache = imageResult.getXpos(); return new WebReturn(RetCode.IMAGE_REQ_SUCCESS,imageResult); } catch(Exception ex){ log.error(ex.getMessage()); ex.printStackTrace(); return new WebReturn(RetCode.IMAGE_REQ_FAIL,null); } } @RequestMapping("/verification") public WebReturn verification(@RequestParam("moveX") int moveX){ log.info("/slider/verification/{}",moveX); int MOVE_CHECK_ERROR = 2; if(( moveX < ( xPosCache + MOVE_CHECK_ERROR)) && ( moveX > (xPosCache - MOVE_CHECK_ERROR))){ log.info("驗證正確"); return new WebReturn(RetCode.VERIFI_REQ_SUCCESS,true); } return new WebReturn(RetCode.VERIFI_REQ_FAIL,false); } }
后台的關鍵代碼就這些,整個處理流程大概耗時40ms(圖片大小320*260).上面的getBlockdata()還可以繼續優化,並不需要全局遍歷.只要摳圖區域遍歷就可以了.
由於對BufferedImage對象的操作是操作其內存中的數據,因此在大並發的情況下需要考慮內存占用狀況.
前端實現
這里需要注意的地方是摳圖和原圖的左邊緣需要對齊,以及縱坐標位置.
鼠標按下滑塊時才會開始計算偏移的距離,滑塊滑動的距離會反映到摳圖的偏移量.當松開鼠標時會將偏移量發送到后端進行校驗.
還有,后端發送過來的數據是base64數據.由於圖片原始數據很多是不可打印字符,因此需要將其轉換成
圖片顯示使用base64時的格式.這里的xxxx是圖片的base64數據.需要注意base64后面的逗號.
<img src="data:image/png;base64,xxxxxxxx">
html代碼
<div id="captchaContainer"> <!-- 標題欄 --> <div class="header"> <span class="headerText">圖片滑動驗證</span> <span class="refreshIcon"/> </div> <!-- 圖片顯示區域 --> <div id="captchaImg"> <img id="oriImg" alt="原圖"/> <img id="cutImg" alt="摳圖"/> </div> <!--滑塊顯示區域--> <div class="sliderContainer"> <div class="sliderMask"> <div class="slider"> <span class="sliderIcon"></span> </div> </div> <span class="sliderText">向右滑動填充拼圖</span> </div>
</div>
JS代碼
<script> //圖片顯示使用base64時的前綴,src=base64PrefixPath + imgBase64Value var base64PrefixPath="data:image/png;base64,"; var IMAGE_WIDTH = 300; //初始化 //滑塊初始偏移量 var sliderInitOffset = 0; //滑塊移動的最值 var MIN_MOVE = 0; var MAX_MOVE = 0; //鼠標按下標志 var mousedownFlag=false; //滑塊移動的距離 var moveX; //滑塊位置檢測允許的誤差,正負2 var MOVE_CHECK_ERROR = 2; //滑塊滑動使能 var moveEnable = true; var ImageMsg = { //摳圖的坐標 xpos: 0, ypos: 0, //摳圖的大小 cutImageWidth: 0, cutImageHeight: 0, //原圖的base64 oriImageSrc: 0, //摳圖的base64 cutImageSrc: 0, } //加載頁面時進行初始化 function init(){ console.log("init") moveEnable = true; mousedownFlag=false; $(".slider").css("left",0+"px"); initClass(); MAX_MOVE = IMAGE_WIDTH - ImageMsg.cutImageWidth; console.log("ImageMsg = " + ImageMsg) $("#cutImg").css("left",0+"px"); $("#oriImg").attr("src",ImageMsg.oriImageSrc) $("#cutImg").attr("src",ImageMsg.cutImageSrc) $("#cutImg").css("width",ImageMsg.cutImageWidth) $("#cutImg").css("height",ImageMsg.cutImageHeight) $("#cutImg").css("top",ImageMsg.ypos) } //加載頁面時 $(function(){ httpRequest.requestImage.request(); }) var httpRequest={ //請求獲取圖片 requestImage:{ path: "slider/image", request:function(){ $.get(httpRequest.requestImage.path,function(data,status){ console.log(data) console.log(data.message); if(data.data != null){ ImageMsg.oriImageSrc = base64PrefixPath + data.data.oriImage; ImageMsg.cutImageSrc = base64PrefixPath + data.data.cutImage; ImageMsg.xpos = data.data.xpos; ImageMsg.ypos = data.data.ypos; ImageMsg.cutImageWidth = data.data.cutImageWidth; ImageMsg.cutImageHeight = data.data.cutImageHeight; init(); } }); }, }, //請求驗證 requestVerification:{ path: "slider/verification", request:function(){ $.get(httpRequest.requestVerification.path,{moveX:(moveX)},function(data,status){ console.log(data) console.log(data.code); console.log(data.message); if(data.data == true){ checkSuccessHandle(); } else{ checkFailHandle(); } }); }, }, } //刷新圖片操作 $(".refreshIcon").on("click",function(){ httpRequest.requestImage.request(); }) //滑塊鼠標按下 $(".slider").mousedown(function(event){ console.log("鼠標按下mousedown:"+event.clientX + " " + event.clientY); sliderInitOffset = event.clientX; mousedownFlag = true; //滑塊綁定鼠標滑動事件 $(".slider").on("mousemove",function(event){ if(mousedownFlag == false){ return; } if(moveEnable == false){ return } moveX = event.clientX - sliderInitOffset; moveX<MIN_MOVE?moveX=MIN_MOVE:moveX=moveX; moveX>MAX_MOVE?moveX=MAX_MOVE:moveX=moveX; $(this).css("left",moveX+"px"); $("#cutImg").css("left",moveX+"px"); }) }) //滑塊鼠標彈起操作 $(".slider").mouseup(function(event){ console.log("mouseup:"+event.clientX + " " + event.clientY); sliderInitOffset = 0; $(this).off("mousemove"); mousedownFlag=false; console.log("moveX = " + moveX) checkLocation(); }) //檢測滑塊 位置是否正確 function checkLocation(){ moveEnable = false; //后端請求檢測滑塊位置 httpRequest.requestVerification.request(); } function checkSuccessHandle(){ $(".sliderContainer").addClass("sliderContainer_success"); $(".slider").addClass("slider_success"); } function checkFailHandle(){ $(".sliderContainer").addClass("sliderContainer_fail"); $(".slider").addClass("slider_success"); } function initClass(){ $(".sliderContainer").removeClass("sliderContainer_success"); $(".slider").removeClass("slider_success"); $(".sliderContainer").removeClass("sliderContainer_fail"); $(".slider").removeClass("slider_fail"); } </script>