現在的驗證碼真是越來越高級了,12306 的找圖驗證碼,極驗的拖動式驗證碼,還有國外的一些黑科技,能智能判斷你是不是機器人的驗證碼。
驗證碼的更新迭代讓我突然對傳統驗證碼一下子不滿足了,出於挑戰自我和對自己技能的修煉,我用了一周的時間寫了一個簡單的 demo ,然后又花了一周時間將其優化成插件的形式,於是 Clicaptcha 就誕生了。
簡單介紹下 Clicaptcha ,它是由 click 和 captcha 這兩個單子合並而成,顧名思義,這是一個點擊驗證碼,那怎么個點擊驗證呢?整個操作流程只需根據提示文字信息,點擊圖中文字所在位置,即可完成驗證,效果圖下圖:
具體的功能實現這里就不一步步給大家回顧了,感興趣的可以直接上 oschina 或者 github ,搜索 Clicaptcha 就可以看到這個項目。
下面我主要是記錄一下在開發過程中幾個難點,以及我的解決思路,如果你有更好的,希望你能和我交流交流。
難點一:文字隨機布局
首先我們要做一些准備工作:
- 背景圖片
- 中文字體
- 隨機文字
- 字體所占范圍(因為是 php 生成,所以借助 GD 庫里的 imagettfbbox 方法)
准備好這些后,就可以開始考慮我們的隨機布局算法了,其實並不復雜,如果有看過我之前寫的《絕對定位的層判斷是否有相互覆蓋的解決算法》,其實思路是差不多的。

可以看下上面這張圖,假設中間帶背景色的區域是已經固定的一個區域,當第2個區域要進行隨機生成的時候,大概會有4種情況,也就是圖中的這4種,我們只需依次判斷以下4種條件,只要有一項符合,則這個隨機生成的x,y坐標就可以使用。
x2 + w2 < x1
x1 + w1 < x2
y2 + h2 < y1
y1 + h1 < y2
難點二:字體大小有偏差
其實這倒不算個難點,就是個小細節。
GD 庫里的 imagefttext 方法中,設置字體大小並不是以像素(px)為單位的,而是以磅(point)為單位。所以在具體使用的時候,需要進行轉換,也就是乘以 0.75 ,比如你需要在圖片上展示 50px 大小的字體,則需要 50px * 0.75 = 37.5point 。至於為什么是乘以 0.75 ,可以見下表:
八號 = 5磅(7px) = (5/72)*96 = 6.67 = 6px
七號 = 5.5磅 = (5.5/72)*96 = 7.3 = 7px
小六 = 6.5磅 = (6.5/72)*96 = 8.67 = 8px
六號 = 7.5磅 = (7.5/72)*96 = 10px
小五 = 9磅 = (9/72)*96 = 12px
五號 = 10.5磅 = (10.5/72)*96 = 14px
小四 = 12磅 = (12/72)*96 = 16px
四號 = 14磅 = (14/72)*96 = 18.67 = 18px
小三 = 15磅 = (15/72)*96 = 20px
三號 = 16磅 = (16/72)*96 = 21.3 = 21px
小二 = 18磅 = (18/72)*96 = 24px
二號 = 22磅 = (22/72)*96 = 29.3 = 29px
小一 = 24磅 = (24/72)*96 = 32px
一號 = 26磅 = (26/72)*96 = 34.67 = 34px
小初 = 36磅 = (36/72)*96 = 48px
初號 = 42磅 = (42/72)*96 = 56px
難點三:如何將圖片和文字同時輸出
我們都知道,在 PHP 中,通過設置 header 的參數,可以輸出各種文件類型,但一次只能輸出一種數據格式到客戶端。
在我這個項目中,除了圖片需要輸出,同時還需要將提示文字也輸出,不然用戶就不知道依次點哪些文字進行驗證了。
解決這個問題我想到有兩種解決方案:
- 將圖片保存,把圖片地址和提示文字一並輸出到前端
- 只輸出圖片到前端,同時將提示文件放入 cookie 中,前端調取 cookie 顯示提示文字
最終我是選擇了第二套方案,因為這是個驗證碼插件,如果每次生成的驗證圖片都保存下來,對服務器硬盤資源占用將是個大問題。
難點四:如何保證驗證信息的安全
在我將后端代碼全部開發完成,前端也封裝好了一個 jQuery 插件后,發現了一個大問題,就是如果用戶通過特殊手段跳過驗證碼驗證,直接提交表單或者相關業務操作怎么辦?
因為驗證碼是以插件的形式存在,所以在調用的參數里有一個 callback 參數,用於驗證成功后執行網站本身業務邏輯的代碼。這樣就可能會有個問題,我用 chrome 按 F12 打開開發者工具,直接在任務台里輸入了提交表單的代碼並回車執行,然后表單順利提交了,完完全全跳過了驗證。
解決這個問題也不復雜,我思考了傳統驗證碼的驗證流程,核心一點就是它是隨表單一起提交並做驗證的,但由於我這個驗證碼的特殊性,所以只能增加一個后端二次驗證,也就是前端初步驗證后,將驗證信息隨表單提交到后端進行二次驗證即可,同時,后端的二次驗證成功后,將 session 清除,避免重復刷新提交表單造成能跳過二次驗證的問題。
以上就是我對這個項目的難點總結,如果你看到這了,希望對感興趣的你有點啟發,這個項目我同時放在的 OSchina 和 Github 上,在線演示,有興趣的可以關注下。
以下是針對前兩個難點寫的一個小demo,如果對完整的源碼一時半會難理解的話,可以 copy 以下代碼到本地,替換下字體和圖片,然后運行一下看看效果。
header("Content-type: text/html; charset=utf-8");
error_reporting(E_ERROR | E_WARNING | E_PARSE);
$imagePath = 'bg.jpg';
$fontPath = 'msyh.ttc';
//為什么要乘0.75?因為 imagefttext 方法里的 size 參數使用磅(point)做為單位的,所以需要進行轉換,轉換為像素
$fontSize = 50 * 0.75;
//以“博客園”三個字舉例,將文字、尺寸等信息存入數組
foreach(array('博', '客', '園') as $v){
$fontarea = imagettfbbox($fontSize, 0, $fontPath, $v);
$textWidth = $fontarea[2] - $fontarea[0];
$textHeight = $fontarea[1] - $fontarea[7];
$tmp['text'] = $v;
$tmp['size'] = $fontSize;
$tmp['width'] = $textWidth;
$tmp['height'] = $textHeight;
$textArr[] = $tmp;
}
//獲取背景底圖寬高和類型信息
list($imageWidth, $imageHeight, $imageType) = getimagesize($imagePath);
//隨機生成漢字位置,並附加存入數組
foreach($textArr as &$v){
list($x, $y) = randPosition($textArr, $imageWidth, $imageHeight, $v['width'], $v['height']);
$v['x'] = $x;
$v['y'] = $y;
}
unset($v);
//創建圖片的實例
$image = imagecreatefromstring(file_get_contents($imagePath));
//字體顏色
$color = imagecolorallocate($image, 0, 0, 0);
//繪畫文字
foreach($textArr as $v){
imagefttext($image, $v['size'], 0, $v['x'], $v['y'], $color, $fontPath, $v['text']);
}
//生成圖片
switch($imageType){
case 1://GIF
header('Content-Type: image/gif');
imagegif($image);
break;
case 2://JPG
header('Content-Type: image/jpeg');
imagejpeg($image);
break;
case 3://PNG
header('Content-Type: image/png');
imagepng($image);
break;
default:
break;
}
imagedestroy($image);
//隨機生成位置布局
function randPosition($textArr, $imgW, $imgH, $fontW, $fontH){
$return = array();
$x = rand(0, $imgW - $fontW);
$y = rand($fontH, $imgH);
if(!checkPosition($textArr, $x, $y, $fontW, $fontH)){
$return = randPosition($textArr, $imgW, $imgH, $fontW, $fontH);
}else{
$return = array($x, $y);
}
return $return;
}
function checkPosition($textArr, $x, $y, $w, $h){
$flag = true;
foreach($textArr as $v){
if(isset($v['x']) && isset($v['y'])){
//分別判斷X和Y是否都有交集,如果都有交集,則判斷為覆蓋
$flagX = true;
if($v['x'] > $x){
if($x + $w > $v['x']){
$flagX = false;
}
}else if($x > $v['x']){
if($v['x'] + $v['width'] > $x){
$flagX = false;
}
}else{
$flagX = false;
}
$flagY = true;
if($v['y'] > $y){
if($y + $h > $v['y']){
$flagY = false;
}
}else if($y > $v['y']){
if($v['y'] + $v['height'] > $y){
$flagY = false;
}
}else{
$flagY = false;
}
if(!$flagX && !$flagY){
$flag = false;
}
}
}
return $flag;
}
參考資料:

