近期參加的項目,主管丟給我一個任務,說要支持滑動驗證碼。我身為50歲的軟件攻城師,當時正背著雙手,好像一個受訓的保安似的,中規中矩地參加每日站會,心想滑動驗證碼在今時今日已經是標配了,司空見慣,想必網上一搜一大把,豈非手到擒來。so easy,媽媽再也不用擔心我的工作與學習。
孰料在網上尋尋覓覓點點擊擊,結果就是凄凄慘慘戚戚。好像提的最多的就是AJ-Captcha,但居然貌似下線了,文檔打不開,demo也不見。還有一個聲稱可能是最好的滑動驗證碼,但好像很復雜,并且日本少女漫畫風,跟我有代溝。有一個貌似跟Ant Design有點關聯的組件,叫Wetrial的,好像還比較符合我的要求。但它只有前端,沒有給出后端實現,并且它的前端好像也用不了。
但是,這個Wetrial.SliderCaptcha闡述了從后端獲得的數據,仿佛制訂了一個滑動驗證碼的接口標準。加上我在搜索過程中,看到的一些具體提示,有了一些思路。考慮到這個滑動驗證,不僅要給自己的web端使用,還要開放給開發手機APP的外包人員調用,因此需要可控、便利、清晰,決定自己搞一個。
一、思路
1、背景圖片和拼圖圖片都從后端,以base64的方式返回給前端
2、一起返回給前端的是一個json對象,包括背景和拼圖內容、尺寸、token。token的作用是驗證時即銷毀,避免重放攻擊,即每張背景圖只驗證一次
3、準備多張相同尺寸,不同內容的背景圖,每次隨機選一張
4、拼圖從背景圖中摳,摳后的坑填上白色,然后采集背景圖的顏色,生成噪點加入這個坑。為的是避免機器容易識別這個白坑。
在chapGPT的指導下,歷時一天,終于搞了個demo。效果如下
滑動驗證
二、后端
后端就2個接口,一個供數據下載,一個供驗證。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.*;import javax.annotation.PostConstruct;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.concurrent.TimeUnit;@RestController
public class CaptchaController {@Autowiredprivate StringRedisTemplate redisTemplate;private String[] images;int puzzlePieceWidth = 40;int puzzlePieceHeight = 40;@PostConstructpublic void init() throws IOException {PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();Resource[] resources = resolver.getResources("classpath:/images/*.jpg"); // 修改為 *.jpgimages = new String[resources.length];for (int i = 0; i < resources.length; i++) {images[i] = resources[i].getURI().toString();}}@GetMapping("/slideCaptcha")public Map<String, Object> getCaptcha() throws IOException {Map<String, Object> response = new HashMap<>();// 生成唯一的 tokenString token = UUID.randomUUID().toString();// 隨機選擇背景圖像BufferedImage backgroundImage = getBgImg();// 生成拼圖塊的隨機位置int puzzlePieceLeft = (int) (Math.random() * (backgroundImage.getWidth() - puzzlePieceWidth));int puzzlePieceTop = (int) (Math.random() * (backgroundImage.getHeight() - puzzlePieceHeight));// 創建拼圖塊BufferedImage puzzlePieceImage = new BufferedImage(puzzlePieceWidth, puzzlePieceHeight, BufferedImage.TYPE_INT_ARGB);Graphics2D puzzleG = puzzlePieceImage.createGraphics();puzzleG.drawImage(backgroundImage, 0, 0, puzzlePieceWidth, puzzlePieceHeight, puzzlePieceLeft, puzzlePieceTop, puzzlePieceLeft + puzzlePieceWidth, puzzlePieceTop + puzzlePieceHeight, null);puzzleG.dispose();// 在背景圖像上掩蓋拼圖塊setMask(backgroundImage, puzzlePieceLeft, puzzlePieceTop);// 將圖像轉換為 Base64ByteArrayOutputStream baos = new ByteArrayOutputStream();ImageIO.write(backgroundImage, "jpg", baos); // 保持為 "jpg"String backgroundImageBase64 = Base64.getEncoder().encodeToString(baos.toByteArray());baos.reset();ImageIO.write(puzzlePieceImage, "png", baos); // 保持為 "png" 以支持透明度String puzzlePieceBase64 = Base64.getEncoder().encodeToString(baos.toByteArray());// 緩存 token 和位置ValueOperations<String, String> ops = redisTemplate.opsForValue();ops.set(token, String.valueOf(puzzlePieceLeft), 5, TimeUnit.MINUTES);response.put("backgroundImage", backgroundImageBase64);response.put("puzzlePiece", puzzlePieceBase64);response.put("token", token);//response.put("puzzlePieceLeft", puzzlePieceLeft);//response.put("puzzlePieceTop", puzzlePieceTop);response.put("backgroundWidth", backgroundImage.getWidth());response.put("backgroundHeight", backgroundImage.getHeight());response.put("puzzlePieceWidth", puzzlePieceWidth);response.put("puzzlePieceHeight", puzzlePieceHeight);return response;}@PostMapping("/slideVerify")public Map<String, Object> verifyCaptcha(HttpServletRequest request, @RequestBody Map<String, Object> map) {Map<String, Object> response = new HashMap<>();String token = (String) map.get("token");int position = (Integer) map.get("position");ValueOperations<String, String> ops = redisTemplate.opsForValue();String correctPositionStr = ops.get(token);if (correctPositionStr != null) {int correctPosition = Integer.parseInt(correctPositionStr);if (Math.abs(position - correctPosition) < 10) {response.put("success", true);} else {response.put("success", false);}redisTemplate.delete(token);} else {response.put("success", false);}return response;}private BufferedImage getBgImg() throws IOException {String selectedImage = images[(int) (Math.random() * images.length)];PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();Resource resource = resolver.getResource(selectedImage);InputStream inputStream = resource.getInputStream();return ImageIO.read(inputStream);}private void setMask(BufferedImage backgroundImage, int puzzlePieceLeft, int puzzlePieceTop) {Graphics2D g = backgroundImage.createGraphics();g.setComposite(AlphaComposite.Src);g.setColor(Color.WHITE); // 使用白色填充g.fillRect(puzzlePieceLeft, puzzlePieceTop, puzzlePieceWidth, puzzlePieceHeight);// 從整幅背景圖像采集顏色Color[][] sampledColors = new Color[backgroundImage.getWidth()][backgroundImage.getHeight()];for (int x = 0; x < backgroundImage.getWidth(); x++) {for (int y = 0; y < backgroundImage.getHeight(); y++) {sampledColors[x][y] = new Color(backgroundImage.getRGB(x, y));}}for (int i = puzzlePieceLeft; i < puzzlePieceLeft + puzzlePieceWidth; i++) {for (int j = puzzlePieceTop; j < puzzlePieceTop + puzzlePieceHeight; j++) {// 獲取背景區域的顏色Color noiseColor = sampledColors[(int) (Math.random() * i)][(int) (Math.random() * j)];// 繪制擾亂元素g.setColor(noiseColor);g.fillRect(i, j, 1, 1); // 繪制單個像素點,覆蓋原始的白色矩形}}g.dispose();}
}
三、前端
demo使用經典的html + js + css來編寫。注意請求后臺的接口路徑采用了nginx進行轉發,避免瀏覽器的跨域限制.
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Captcha Verification</title><style>.captcha-container {position: relative;width: 367px;height: 267px;margin: 50px auto;border: 1px solid #ddd;background-color: #f3f3f3;}.background-image {position: absolute;top: 0;left: 0;width: 100%;height: 100%;}.puzzle-piece {position: absolute;width: 40px;height: 40px;cursor: move;box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); /* 添加陰影效果 */}.slider-container {width: 400px;margin: 20px auto;text-align: center;display: flex;align-items: center;justify-content: center;}.slider {width: 100%;-webkit-appearance: none; /* 去除默認樣式 */appearance: none;height: 10px; /* 設置滑道高度 */background: #ddd; /* 滑道背景色 */border-radius: 5px; /* 圓角 */outline: none; /* 去除聚焦時的外邊框 */transition: background .2s; /* 過渡效果 */}.slider::-webkit-slider-thumb {-webkit-appearance: none; /* 去除默認樣式 */appearance: none;width: 20px; /* 滑塊寬度 */height: 20px; /* 滑塊高度 */background: #4CAF50; /* 滑塊背景色 */border-radius: 50%; /* 圓形 */cursor: pointer; /* 光標樣式 */box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* 滑塊陰影效果 */}.refresh-btn {margin-left: 10px;padding: 8px 16px;cursor: pointer;background-color: #4CAF50;color: white;border: none;border-radius: 4px;font-size: 14px;}</style><!-- Font Awesome CSS --><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
</head>
<body><div class="captcha-container"><img id="backgroundImage" class="background-image" src="" alt="Background Image"><div id="puzzlePiece" class="puzzle-piece"></div></div><div class="slider-container"><input type="range" min="0" max="327" value="0" class="slider" id="slider"><button class="refresh-btn" id="refreshBtn"><i class="fas fa-sync-alt"></i></button></div><script>document.addEventListener('DOMContentLoaded', function() {let slider = document.getElementById('slider');let puzzlePiece = document.getElementById('puzzlePiece');let token = '';function loadCaptcha() {fetch('/api/slideCaptcha') // 替換為你的后端接口地址.then(response => response.json()).then(data => {document.getElementById('backgroundImage').src = 'data:image/jpeg;base64,' + data.backgroundImage;puzzlePiece.style.backgroundImage = 'url(data:image/jpeg;base64,' + data.puzzlePiece + ')';puzzlePiece.style.top = data.puzzlePieceTop + 'px';puzzlePiece.style.left = '0px';token = data.token;slider.value = 0;}).catch(error => console.error('Error fetching captcha:', error));}let refreshBtn = document.getElementById('refreshBtn');refreshBtn.addEventListener('click', function() {loadCaptcha();});slider.addEventListener('input', function() {puzzlePiece.style.left = slider.value + 'px';});slider.addEventListener('change', function() {fetch('/api/slideVerify', { // 替換為你的后端驗證接口地址method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify({token: token,position: parseInt(slider.value)}),}).then(response => response.json()).then(data => {if (data.success) {alert(':-) 驗證成功!');} else {alert('驗證失敗,請重試!');}loadCaptcha();}).catch(error => console.error('Error verifying captcha:', error));});loadCaptcha();});</script>
</body>
</html>
四、小結
俄國10月革命一聲炮響,送來了美國的chatGPT。chatGPT吧,已經成了我的老師和工人。上面那些代碼,都是我提要求,然后chatGPT生成的,甚至包括注釋。我只修改了極少的地方。功能的確強大。但它其實又還不夠智能,一些算法我一下子能看出問題,需要重重復復地提要求,每次它都說:明白了。它輸入了海量的資料,知識淵博,各種編程語法更是精通,提交代碼給它審查找問題,最是合適不過。它一般也能按要求給出初始代碼,但有時總是差那么點意思。最討厭的,是問它一些社科歷史類的問題,經常一本正經地胡說八道。
這不是我想要的生活。
參考文章:
SlideCaptcha - 滑動驗證碼
滑塊驗證 - 使用AJ-Captcha插件【超簡單.jpg】
TIANAI-CAPTCHA