首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 程式設計入門

仿 12306 圖形驗證碼全棧實戰:圖片拼接、前端點選、座標驗證與刷新機制

     編輯:Java 程式設計入門

前言

傳統字符驗證碼易被 OCR 破解,現代驗證碼轉向**語義識別、圖片點選、行為分析**。本文完整復刻 12306 風格「九宮格點選」驗證碼,涵蓋**後端圖片拼接與語義分類、前端 Canvas/絕對定位點選、座標映射驗證、Blob 流刷新、模糊化防 AI 識別**全棧實現,並給出生產級安全加固建議。

一、整體架構與數據流


[用戶請求驗證碼]
       │
       ▼
[Controller] ──► [ImageSelectHelper.getEightImages()] ──► 從分類目錄隨機抽 8 張不重複圖片
       │                    │
       │                    └─► [ImageSelectHelper.getLocation(tips)] ──► 計算目標圖片位置 (1-based)
       │                                                         │
       │                                                         ▼
       │                                                [Session] 存儲 targetTip + targetPositions
       │                                                         │
       ▼                                                         ▼
[ImageMerge.mergeImage()] ◄────────────────────────────── 8 張圖片 File[]
       │
       ▼
[HttpServletResponse OutputStream] ──► 合成大圖 (JPG) 返回瀏覽器
       │
       ▼
[前端 onclick] ──► 記錄點擊座標 (相對大圖左上角) ──► 隱藏域/JS 陣列
       │
       ▼
[提交驗證] ──► [ImageVerifyHelper.isPass(coords)] ──► 座標映射至 1~8 位置 ──► 與 Session 比對
       │
       ▼
[驗證通過/失敗] ──► 返回業務結果

二、後端核心模塊實現

1. 圖片資源結構


resources/
└── captcha/
    ├── dog/          # 分類目錄 = 語義標籤
    │   ├── dog1.jpg
    │   └── dog2.jpg
    ├── beer/
    │   └── ...
    └── cat/
        └── ...

2. 隨機抽取 8 張不重複圖片


public class ImageSelectHelper {
    private static final File ROOT_DIR = new File("classpath:captcha");
    private static final Random RANDOM = new Random();

    /** 返回 [File[8], String[8] tips] */
    public static Object[] getEightImages() {
        File[] categories = ROOT_DIR.listFiles(File::isDirectory);
        if (categories == null || categories.length < 8)
            throw new IllegalStateException("分類目錄不足 8 個");

        List selected = new ArrayList<>(8);
        String[] tips = new String[8];
        Set usedPaths = new HashSet<>();

        for (int i = 0; i < 8; i++) {
            File cat;
            File img;
            do {
                cat = categories[RANDOM.nextInt(categories.length)];
                File[] imgs = cat.listFiles(f -> f.getName().matches("(?i).+\\.(jpg|png|jpeg)"));
                if (imgs == null || imgs.length == 0) continue;
                img = imgs[RANDOM.nextInt(imgs.length)];
            } while (usedPaths.contains(img.getAbsolutePath())); // 去重

            usedPaths.add(img.getAbsolutePath());
            selected.add(img);
            tips[i] = cat.getName(); // 語義標籤
        }
        return new Object[]{selected.toArray(new File[0]), tips};
    }

    /** 計算目標圖片位置 (1-based) */
    public static List getTargetPositions(String[] tips) {
        String targetTip = tips[RANDOM.nextInt(tips.length)]; // 隨機選一個作為目標
        List positions = new ArrayList<>();
        for (int i = 0; i < tips.length; i++) {
            if (targetTip.equals(tips[i])) positions.add(i + 1); // 1-based
        }
        return positions; // 可能多張同類圖片
    }
}

3. 高效圖片拼接 (BufferedImage + 零拷貝)


public class ImageMerge {
    private static final int TILE_W = 200, TILE_H = 200;
    private static final int COLS = 4, ROWS = 2; // 4x2 九宮格 (最後一格可留白)

    public static void merge(File[] images, HttpServletResponse resp) throws IOException {
        BufferedImage canvas = new BufferedImage(COLS * TILE_W, ROWS * TILE_H, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = canvas.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);

        for (int i = 0; i < images.length; i++) {
            BufferedImage tile = ImageIO.read(images[i]);
            // 縮放填滿 200x200
            BufferedImage scaled = new BufferedImage(TILE_W, TILE_H, BufferedImage.TYPE_INT_RGB);
            scaled.getGraphics().drawImage(tile, 0, 0, TILE_W, TILE_H, null);

            int x = (i % COLS) * TILE_W;
            int y = (i / COLS) * TILE_H;
            g.drawImage(scaled, x, y, null);
        }
        g.dispose();

        // 模糊化防 AI 識別 (可選)
        // applyGaussianBlur(canvas);

        resp.setContentType("image/jpeg");
        resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        ImageIO.write(canvas, "JPEG", resp.getOutputStream());
    }
}

4. 座標驗證邏輯


public class ImageVerifyHelper {
    private static final int TILE_W = 200, TILE_H = 200;
    private static final int COLS = 4;

    /** 前端傳格式: "x1|y1,x2|y2,..." (相對大圖左上角) */
    public static boolean verify(String coordsParam, HttpSession session) {
        List targetPositions = (List) session.getAttribute("captcha_target_positions");
        if (targetPositions == null || targetPositions.isEmpty()) return false;

        String[] pairs = coordsParam.split(",");
        Set clickedPositions = new HashSet<>();

        for (String pair : pairs) {
            String[] xy = pair.split("\\|");
            if (xy.length != 2) continue;
            int x = Integer.parseInt(xy[0]);
            int y = Integer.parseInt(xy[1]);

            // 邊界檢查
            if (x < 0 || x >= COLS * TILE_W || y < 0 || y >= ROWS * TILE_H) continue;

            int col = x / TILE_W;      // 0-based
            int row = y / TILE_H;      // 0-based
            int pos = row * COLS + col + 1; // 1-based
            clickedPositions.add(pos);
        }

        // 核心判定:點選集合必須完全包含所有目標位置,且不包含非目標位置 (嚴格模式)
        // 寬鬆模式:只要包含所有目標位置即可
        return clickedPositions.containsAll(targetPositions); // 寬鬆模式
        // return clickedPositions.equals(new HashSet<>(targetPositions)); // 嚴格模式
    }
}

三、前端交互實現 (原生 JS + Canvas 方案)

1. HTML 結構


請點選所有

2. 核心 JS 邏輯


const canvas = document.getElementById('captchaCanvas');
const ctx = canvas.getContext('2d');
const markLayer = document.getElementById('markLayer');
const coordsInput = document.getElementById('captchaCoords');
const tipEl = document.getElementById('targetTip');
let clickCount = 0;
const MAX_CLICKS = 4; // 防暴力點選

async function loadCaptcha() {
    const resp = await fetch('/captcha/image', {credentials: 'include'});
    const blob = await resp.blob();
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
        URL.revokeObjectURL(url);
    };
    img.src = url;

    // 獲取提示語 (需單獨接口或隨圖片返回 Header)
    const tipResp = await fetch('/captcha/tip', {credentials: 'include'});
    tipEl.textContent = await tipResp.text();
    markLayer.innerHTML = '';
    clickCount = 0;
    coordsInput.value = '';
}

canvas.addEventListener('click', (e) => {
    if (clickCount >= MAX_CLICKS) return;
    const rect = canvas.getBoundingClientRect();
    const x = Math.floor(e.clientX - rect.left);
    const y = Math.floor(e.clientY - rect.top);

    // 畫標記
    const mark = document.createElement('div');
    mark.className = 'captcha-mark';
    mark.style.left = (x - 10) + 'px';
    mark.style.top = (y - 10) + 'px';
    mark.textContent = ++clickCount;
    markLayer.appendChild(mark);

    // 記錄座標
    const prev = coordsInput.value;
    coordsInput.value = prev + (prev ? ',' : '') + x + '|' + y;
});

document.getElementById('refreshBtn').addEventListener('click', loadCaptcha);

// 頁面載入初始化
loadCaptcha();

3. CSS 樣式 (關鍵:標記絕對定位)


.captcha-mark {
    position: absolute;
    width: 20px; height: 20px;
    background: rgba(255,0,0,0.8);
    color: white;
    border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    font-size: 12px; font-weight: bold;
    pointer-events: none;
    transform: translate(-50%, -50%); /* 以中心點對齊點擊座標 */
    z-index: 10;
}

四、Spring MVC Controller 整合


@Controller
@RequestMapping("/captcha")
public class CaptchaController {

    @GetMapping("/image")
    public void image(HttpServletResponse resp, HttpSession session) throws IOException {
        Object[] result = ImageSelectHelper.getEightImages();
        File[] images = (File[]) result[0];
        String[] tips = (String[]) result[1];

        List targetPositions = ImageSelectHelper.getTargetPositions(tips);
        String targetTip = tips[targetPositions.get(0) - 1]; // 取第一個目標的提示

        session.setAttribute("captcha_target_positions", targetPositions);
        session.setAttribute("captcha_target_tip", targetTip);

        ImageMerge.merge(images, resp);
    }

    @GetMapping("/tip")
    @ResponseBody
    public String tip(HttpSession session) {
        return (String) session.getAttribute("captcha_target_tip");
    }

    @PostMapping("/verify")
    @ResponseBody
    public Map verify(@RequestParam String coords, HttpSession session) {
        boolean pass = ImageVerifyHelper.verify(coords, session);
        Map res = new HashMap<>();
        res.put("success", pass);
        res.put("message", pass ? "驗證通過" : "驗證失敗,請重試");
        if (!pass) {
            // 失敗自動刷新
            try { image(resp, session); } catch (IOException ignored) {}
        }
        return res;
    }
}

五、安全加固與生產級優化

| 風險 | 對策 | |------|------| | **圖片語義被圖像識別 API 破解** | 1. 圖片加噪點、干擾線、隨機旋轉 ±15°
2. 使用**私有圖庫**、定期更新
3. 採用**行為驗證** (滑動軌跡、點擊間隔、鼠標軌跡熵) 融合判斷 | | **座標映射被逆向工程** | 1. 前端座標**加密上傳** (AES-GCM + 隨機 IV)
2. 後端解密後驗證,防止重放攻擊 | | **暴力枚舉 / 機器腳本刷接口** | 1. **限流**:IP + Session 維度 (如 5 次/分鐘)
2. **設備指紋**:Canvas 指紋、User-Agent 一致性檢查
3. **行為分析**:點擊速度、間隔、軌跡曲率判斷人機 | | **Session 洩漏 / CSRF** | 1. 驗證碼接口**無狀態化**:JWT 簽名攜帶目標位置 (加密)、時間戳、隨機數
2. 關鍵操作雙因子驗證 (短信/郵件/APP 推送) | | **圖片載入失敗 / 跨域** | 1. 圖片服務獨立域名 + CDN 加速
2. `crossorigin="anonymous"` + CORS 配置 |

六、擴展:無狀態 JWT 方案 (去 Session 化)


// 生成 Token (含目標位置、過期時間、隨機數)
String token = Jwts.builder()
    .claim("targets", targetPositions)
    .claim("tip", targetTip)
    .setExpiration(Date.from(Instant.now().plusSeconds(120)))
    .setId(UUID.randomUUID().toString())
    .signWith(SignatureAlgorithm.HS256, secretKey)
    .compact();

// 返回給前端:圖片流 + Header: X-Captcha-Token: 
// 前端驗證時攜帶 Token + 座標,後端解析 Token 驗證,無需 Session

💡 核心心法:驗證碼不是銀彈,而是「提高攻擊成本」的層級防禦之一。圖片點選提高 OCR 成本,行為分析提高腳本模擬成本,限流/設備指紋提高分發成本,**多層疊加才能達到商業級安全**。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved