驗證碼不是一個功能性的需求,他並不能帶來業務的提升,也不能帶來任何價值。驗證碼只是為了解決機器問題才誕生的。在設計和驗證碼演化的過程中,必須同時考慮安全性和體驗。
設計要點
- 圖片驗證一次性
- 超時未驗證失效
- 支援4位驗證碼,字元以英文和數字組成
- 支援簡單干擾
儲存選擇
首先我想到了快取。目前比較流行的快取服務是redis。操作速度是傳統關係型資料庫查詢的幾十甚至上百倍,效能上面沒問題。我們知道,驗證碼是有時效的,可以利用他的快取時效性
介面設計
[GET]/captchas 獲取圖片驗證碼 { "signature":"xx", //驗證碼簽名 "payload":"xxxxx" //圖片的base64資料 }
實現
-
調介面生成一個驗證碼signature,和驗證碼圖片資料
其中驗證碼signature=base64(timestamp:random:sign(timestamp+random+code+secretKey))
其中secretKey是服務端私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret,就可以自己生成驗證碼憑證了。
-
驗證時需帶上驗證碼signature和驗證碼newcode
--拿到signature中timestamp,根據設定的驗證碼有效期判斷驗證碼是否過期
--判斷sign(timestamp+random+code+secretKey)和sign(timestamp+random+newcode+secretKey)是否相等,不相等,可能是簽名被篡改了或者驗證碼輸入錯誤
--判斷該簽名是否已在黑名單中,如果已在黑命名單說明已經被驗證過了
--驗證通過,加入黑名單。即將該signature存入redis,有效期設定為5分鐘(注意此有效期要大於驗證碼的有效期,避免多次驗證)
小結
- 驗證碼signature生成時參照jwt的思路實現的
- 生成驗證碼signature,和驗證碼圖片資料時是根據演算法實時生成,只有驗證通過之後才加入黑名單儲存在redis中。相比較於獲取驗證碼時就放入redis儲存並設定有效期而言,可以很好的防止使用者暴力獲取驗證碼而不驗證,大大避免了多餘的儲存。
附錄
生成signature
/**
* 生成簽名
* @param time 時間戳,單位毫秒
* @param random 隨機數
* @param secretKey 服務端私鑰
* @return
* @throws Exception
*/
public static String signature(long time, String random, String secretKey) throws Exception {
String signature = String.format("%s:%s:%s", time, random, getSign(time, random, secretKey));
return Base64Utils.encodeStr(signature.getBytes());
}
public static String getSign(long time, String random, String secretKey) throws Exception{
StringBuilder sign = new StringBuilder();
sign.append(time);
sign.append("\n");
sign.append(random);
sign.append("\n");
sign.append(secretKey);
sign.append("\n");
return EncryptUtil.encryptSHA256(sign.toString());
}
複製程式碼
驗證signature
/**
* 驗證簽名
* @param signature 待驗證簽名
* @param random 隨機數
* @param secretKey 服務端私鑰
* @param expire 過期時間,單位毫秒
* @return
*/
public static Boolean validateSign(String signature, String random, String secretKey, int expire) {
String sign = Base64Utils.decodeStr(signature);
String[] signs = sign.split(":");
if (signs.length < 3) {
return false;
}
long curTimestamp = System.currentTimeMillis();
long signTimestamp = Long.valueOf(signs[0]);
if ((curTimestamp - signTimestamp) > expire) {
return false;
}
if (!random.equals(signs[1])) {
return false;
}
String newSign = null;
try {
newSign = getSign(Long.valueOf(signs[0]), signs[1], secretKey);
} catch (Exception e) {
// TODO:記錄日誌
return false;
}
if (!signs[2].equals(newSign)) {
return false;
}
return true;
}複製程式碼