前言
圖形驗證碼是 Web 應用防止機器人惡意註冊、暴力破解的第一道防線。本文提供完整的 Java 實作,涵蓋基礎版本、進階抗識別強化、Spring Boot 整合、效能優化等生產環境所需的完整方案。
核心設計原則
| 原則 | 說明 | 實作方式 |
| 隨機性 | 字符、顏色、位置、干擾線全隨機 | SecureRandom + 字符集配置 |
| 不可重放 | 一次性驗證,驗證後即失效 | Session/Redis 儲存 + 刪除機制 |
| 抗 OCR | 干擾線、噪點、扭曲、字體變化 | 多層干擾 + 仿射變換 |
| 高效能 | 低延遲、低記憶體、支援高併發 | 物件池、流式輸出、快取字體 |
| 易擴展 | 支援算術、中文、點選、滑塊等類型 | 策略模式 + SPI 擴展 |
基礎版:標準字符驗證碼
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.security.SecureRandom;
import java.util.Base64;
import javax.imageio.ImageIO;
public class CaptchaGenerator {
// 配置常數
private static final String CHAR_SET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // 排除易混淆字符
private static final int DEFAULT_WIDTH = 130;
private static final int DEFAULT_HEIGHT = 48;
private static final int DEFAULT_LENGTH = 4;
private static final int DEFAULT_LINE_COUNT = 6;
private static final int DEFAULT_NOISE_COUNT = 30;
private final SecureRandom random = new SecureRandom();
private final Font[] fonts = {
new Font("Arial", Font.BOLD | Font.ITALIC, 32),
new Font("Courier New", Font.BOLD, 30),
new Font("Verdana", Font.BOLD | Font.ITALIC, 34),
new Font("Tahoma", Font.BOLD, 32)
};
/**
* 生成驗證碼
* @return CaptchaResult 包含 code、image(Base64)、imageBytes
*/
public CaptchaResult generate() {
return generate(DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_LENGTH, DEFAULT_LINE_COUNT, DEFAULT_NOISE_COUNT);
}
public CaptchaResult generate(int width, int height, int length, int lineCount, int noiseCount) {
// 1. 生成隨機字符
StringBuilder code = new StringBuilder(length);
for (int i = 0; i < length; i++) {
code.append(CHAR_SET.charAt(random.nextInt(CHAR_SET.length())));
}
// 2. 建立圖片緩衝區
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
try {
// 3. 繪製背景
drawBackground(g2d, width, height);
// 4. 繪製干擾線
drawLines(g2d, width, height, lineCount);
// 5. 繪製噪點
drawNoise(g2d, width, height, noiseCount);
// 6. 繪製字符(核心)
drawCharacters(g2d, code.toString(), width, height);
// 7. 套用扭曲變換(進階抗 OCR)
applyShearTransform(g2d, width, height);
// 8. 輸出
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
byte[] imageBytes = baos.toByteArray();
String base64 = Base64.getEncoder().encodeToString(imageBytes);
return new CaptchaResult(code.toString(), base64, imageBytes);
} catch (IOException e) {
throw new RuntimeException("驗證碼生成失敗", e);
} finally {
g2d.dispose();
}
}
// === 繪製細節方法 ===
private void drawBackground(Graphics2D g, int w, int h) {
g.setColor(new Color(245, 245, 250));
g.fillRect(0, 0, w, h);
// 漸層背景(可選)
GradientPaint gp = new GradientPaint(0, 0, new Color(255, 255, 255), w, h, new Color(230, 235, 245));
g.setPaint(gp);
g.fillRect(0, 0, w, h);
}
private void drawLines(Graphics2D g, int w, int h, int count) {
g.setStroke(new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
for (int i = 0; i < count; i++) {
g.setColor(randomColor(160, 220));
int x1 = random.nextInt(w);
int y1 = random.nextInt(h);
int x2 = random.nextInt(w);
int y2 = random.nextInt(h);
g.drawLine(x1, y1, x2, y2);
}
}
private void drawNoise(Graphics2D g, int w, int h, int count) {
for (int i = 0; i < count; i++) {
g.setColor(randomColor(180, 230));
int x = random.nextInt(w);
int y = random.nextInt(h);
int w2 = random.nextInt(3) + 1;
int h2 = random.nextInt(3) + 1;
g.fillOval(x, y, w2, h2);
}
}
private void drawCharacters(Graphics2D g, String code, int w, int h) {
int charWidth = w / code.length();
for (int i = 0; i < code.length(); i++) {
char c = code.charAt(i);
g.setFont(fonts[random.nextInt(fonts.length)]);
g.setColor(randomColor(20, 130));
// 隨機旋轉角度 (-25° ~ +25°)
double theta = (random.nextDouble() - 0.5) * Math.PI / 3.6;
// 字符位置微調
int x = i * charWidth + random.nextInt(charWidth / 4) + 5;
int y = h / 2 + random.nextInt(h / 8) + 10;
// 套用旋轉
AffineTransform original = g.getTransform();
g.rotate(theta, x, y);
g.drawString(String.valueOf(c), x, y);
g.setTransform(original);
}
}
private void applyShearTransform(Graphics2D g, int w, int h) {
// 水平剪切變換,增加辨識難度
double shearX = (random.nextDouble() - 0.5) * 0.3;
AffineTransform transform = AffineTransform.getShearInstance(shearX, 0);
g.transform(transform);
}
private Color randomColor(int min, int max) {
int r = random.nextInt(max - min) + min;
int g = random.nextInt(max - min) + min;
int b = random.nextInt(max - min) + min;
return new Color(r, g, b);
}
// === 結果封裝 ===
public static class CaptchaResult {
private final String code;
private final String base64;
private final byte[] imageBytes;
public CaptchaResult(String code, String base64, byte[] imageBytes) {
this.code = code;
this.base64 = base64;
this.imageBytes = imageBytes;
}
public String getCode() { return code; }
public String getBase64() { return base64; }
public byte[] getImageBytes() { return imageBytes; }
public String getDataUri() { return "data:image/png;base64," + base64; }
}
}
Spring Boot 整合:REST API + Redis 儲存
@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
private final CaptchaGenerator generator = new CaptchaGenerator();
private final StringRedisTemplate redisTemplate;
public CaptchaController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@GetMapping("/generate")
public Result<CaptchaVO> generate() {
// 1. 生成驗證碼
CaptchaGenerator.CaptchaResult result = generator.generate();
// 2. 生成唯一標識
String captchaId = UUID.randomUUID().toString().replace("-", "");
// 3. 存入 Redis,5 分鐘過期
String key = "captcha:" + captchaId;
redisTemplate.opsForValue().set(key, result.getCode().toLowerCase(), Duration.ofMinutes(5));
// 4. 返回前端所需資料
CaptchaVO vo = new CaptchaVO(captchaId, result.getDataUri());
return Result.success(vo);
}
@PostMapping("/verify")
public Result<Boolean> verify(@RequestBody VerifyRequest request) {
String key = "captcha:" + request.getCaptchaId();
String storedCode = redisTemplate.opsForValue().getAndDelete(key); // 驗證即銷毀
if (storedCode == null) {
return Result.error("驗證碼已失效或不存在");
}
boolean match = storedCode.equals(request.getCode().toLowerCase());
return match ? Result.success(true) : Result.error("驗證碼錯誤");
}
static class CaptchaVO {
private final String captchaId;
private final String imageBase64;
public CaptchaVO(String id, String img) { this.captchaId = id; this.imageBase64 = img; }
public String getCaptchaId() { return captchaId; }
public String getImageBase64() { return imageBase64; }
}
static class VerifyRequest {
private String captchaId;
private String code;
public String getCaptchaId() { return captchaId; }
public void setCaptchaId(String captchaId) { this.captchaId = captchaId; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
}
}
前端整合範例
<!-- Vue 3 Composition API 範例 -->
<script setup>
import { ref } from 'vue'
const captchaId = ref('')
const captchaImage = ref('')
const inputCode = ref('')
async function refreshCaptcha() {
const res = await fetch('/api/captcha/generate')
const data = await res.json()
captchaId.value = data.data.captchaId
captchaImage.value = data.data.imageBase64
}
async function verify() {
const res = await fetch('/api/captcha/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captchaId: captchaId.value, code: inputCode.value })
})
const result = await res.json()
if (result.success) {
alert('驗證通過')
} else {
alert(result.message)
refreshCaptcha()
}
}
onMounted(refreshCaptcha)
</script>
<template>
<div class="captcha-container">
<img :src="captchaImage" @click="refreshCaptcha" title="點擊刷新" />
<input v-model="inputCode" placeholder="輸入驗證碼" maxlength="4" />
<button @click="verify">驗證</button>
</div>
</template>
進階強化:抗 AI 識別技術
| 技術 | 原理 | 實作要點 |
| 字符扭曲/彎曲 | 貝塞爾曲線路徑繪製字符 | GeneralPath + 隨機控制點 |
| 波紋/水波變形 | 正弦函數像素位移 | 像素級操作或 Shader |
| 字體隨機化 | 多字體、字重、斜體混合 | 預載 10+ 字體檔案 |
| 動態干擾線 | 曲線、虛線、寬度變化 | QuadCurve2D + BasicStroke |
| 背景紋理 | 噪點、網格、漸層、圖片 | TexturePaint + BufferedImage |
| 字符重疊/連接 | 破壞字符分割算法 | 負間距、隨機偏移 |
效能優化建議
- 字體快取:啟動時載入字體到記憶體,避免重複
new Font()
- 圖片物件池:重用
BufferedImage 和 Graphics2D(需線程安全)
- 流式輸出:直接寫入
ServletOutputStream,避免 Base64 編碼開銷
- 異步生成:高併發下使用
CompletableFuture 預生成緩衝池
- 關閉抗鋸齒:
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF) 可提速 20%+
替代方案對比
| 方案 | 優點 | 缺點 | 適用場景 |
| 自建圖形驗證碼 | 完全可控、無依賴、免費 | 需自行對抗 AI、維護成本高 | 中小型專案、內網系統 |
| Google reCAPTCHA v3 | 無感驗證、Google 維護、安全性高 | 需網路、隱私合規、中國大陸不可用 | 面向國際用戶的公開網站 |
| 騰訊/網易/阿里驗證碼 | 國內可用、滑塊/點選體驗好 | 需申請、有額度限制、vendor lock-in | 國內商業項目 |
| hCaptcha | 隱私友好、全球可用、有收益 | 需網路、體驗稍差 | 重視隱私的專案 |
安全檢查清單
- [ ] 驗證碼一次性使用,驗證後立即刪除
- [ ] 設定合理過期時間(建議 3-5 分鐘)
- [ ] 限制單 IP 請求頻率(如 10 次/分鐘)
- [ ] 失敗次數超限鎖定帳號/IP(如 5 次鎖定 15 分鐘)
- [ ] 使用 HTTPS 傳輸,防止中間人竊取
- [ ] 前端不暴露驗證碼答案(僅傳 ID)
- [ ] 記錄驗證日誌(IP、時間、成功/失敗)供審計