前言
我們?cè)谑褂镁W(wǎng)站的注冊(cè)/登錄功能時(shí),常常會(huì)看到除了賬號(hào)密碼外,還會(huì)有一個(gè)驗(yàn)證碼的輸入框。那么從技術(shù)層面來(lái)說(shuō),驗(yàn)證碼這個(gè)功能應(yīng)該如何實(shí)現(xiàn)呢?本篇文章將從SpringBoot + Vue為例,講解一下開(kāi)發(fā)的思路。如果只看開(kāi)發(fā)步驟,可以直接跳至步驟分解開(kāi)始閱讀。
在進(jìn)行開(kāi)發(fā)之前,我們可以先想一想,驗(yàn)證碼的作用是什么?
驗(yàn)證碼設(shè)計(jì)之初是用于攔截爬蟲(chóng)和機(jī)器的大量暴力訪(fǎng)問(wèn),也就是說(shuō)它的目標(biāo)對(duì)象不是人,而是機(jī)器。
我們仔細(xì)想想會(huì)發(fā)現(xiàn)驗(yàn)證碼對(duì)于正常的用戶(hù)而言,其實(shí)并沒(méi)有起到任何優(yōu)化作用,甚至在一定程度上會(huì)延長(zhǎng)用戶(hù)登錄/注冊(cè)操作的流程,降低用戶(hù)體驗(yàn)。但是對(duì)于機(jī)器或者爬蟲(chóng)來(lái)說(shuō),驗(yàn)證碼的作用就體現(xiàn)出來(lái)了,試想如果只需要賬號(hào)+密碼,那么惡意用戶(hù)就可以通過(guò)爬蟲(chóng)輕而易舉的爬取網(wǎng)站的數(shù)據(jù),或者在沒(méi)有其他風(fēng)控系統(tǒng)的條件下,甚至可以通過(guò)多次嘗試來(lái)暴力破解密碼。
當(dāng)然了,如今簡(jiǎn)單的字符驗(yàn)證碼已經(jīng)很容易被破解了,也自然地推出了更難破解、更加智能化的驗(yàn)證機(jī)制。諸如手機(jī)短信驗(yàn)證碼、滑塊驗(yàn)證機(jī)制、數(shù)值計(jì)算等等...

講完驗(yàn)證碼的作用后,再說(shuō)說(shuō)代碼設(shè)計(jì)
我們已經(jīng)知道,驗(yàn)證碼的主要作用是為了防止非人類(lèi)手動(dòng)操作的請(qǐng)求,那么對(duì)于驗(yàn)證碼功能應(yīng)該放在前端還是后端校驗(yàn)這個(gè)問(wèn)題,答案就不言而喻了,需要放在后端校驗(yàn)。原因是,驗(yàn)證碼功能一旦只單純放在前端進(jìn)行校驗(yàn),對(duì)于惡意破壞者可以輕而易舉地繞過(guò)你的前端校驗(yàn),直接朝后臺(tái)發(fā)起POST請(qǐng)求。
這里解釋一下繞過(guò)前端校驗(yàn)的可行思路
對(duì)于客戶(hù)端而言,我們的前端代碼是完全公開(kāi)透明的
惡意用戶(hù)完全可以將我們的前端資源保存在自己本地后,刪去驗(yàn)證碼校驗(yàn),直接發(fā)起請(qǐng)求。
此時(shí),我們的服務(wù)器就不得不面對(duì)大量的惡意請(qǐng)求直接打在我們的服務(wù)器上面,后果不堪設(shè)想。
所以,我們的驗(yàn)證碼必須要放在后臺(tái)進(jìn)行二次校驗(yàn),這樣才能保障驗(yàn)證碼機(jī)制的有效性。同時(shí),前端代碼可以對(duì)用戶(hù)輸入字符長(zhǎng)度、是否有非法字符等格式進(jìn)行校驗(yàn),從而降低過(guò)多無(wú)效的請(qǐng)求直接落在我們的服務(wù)器校驗(yàn)上,降低壓力。
步驟分解:
前端:
1)進(jìn)入登錄/注冊(cè)頁(yè)面時(shí),獲取驗(yàn)證碼圖片
2)對(duì)用戶(hù)輸入的驗(yàn)證碼進(jìn)行簡(jiǎn)單的規(guī)則校驗(yàn)
3)返回登錄結(jié)果
4)提供刷新驗(yàn)證碼的動(dòng)作,防止出現(xiàn)用戶(hù)難以辨識(shí)的識(shí)別碼
后端:
1)隨機(jī)生成四位數(shù)字的驗(yàn)證碼圖片和數(shù)字
2)結(jié)合隨機(jī)生成的UUID作為Key,4位數(shù)字驗(yàn)證碼作為Value保存驗(yàn)證碼到Redis中
3)將Key和驗(yàn)證碼響應(yīng)給用戶(hù),等用戶(hù)提交后驗(yàn)證校驗(yàn)碼是否有效
后端代碼:
圖片工具類(lèi),用于生成驗(yàn)證碼圖片
package com.qiqv.music.utils;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Random;
/**
* 驗(yàn)證碼生成器
*/
public class VerifyCodeUtils {
private int width = 100;// 生成驗(yàn)證碼圖片的寬度
private int height = 30;// 生成驗(yàn)證碼圖片的高度
private String[] fontNames = { "宋體", "楷體", "隸書(shū)", "微軟雅黑" };
private Color bgColor = new Color(255, 255, 255);// 定義驗(yàn)證碼圖片的背景顏色為白色
private Random random = new Random();
// 從下面的字符串中挑選字符放入驗(yàn)證碼集中,去掉1、l、L等容易混淆的字符
private String codes = "023456789abcdefghijkmnopqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ";
private String text;// 記錄隨機(jī)字符串
/**
* 獲取一個(gè)隨意顏色
*
* @return
*/
private Color randomColor() {
int red = random.nextInt(150);
int green = random.nextInt(150);
int blue = random.nextInt(150);
return new Color(red, green, blue);
}
/**
* 獲取一個(gè)隨機(jī)字體
*
* @return
*/
private Font randomFont() {
String name = fontNames[random.nextInt(fontNames.length)];
int style = random.nextInt(4);
int size = random.nextInt(5) + 24;
return new Font(name, style, size);
}
/**
* 獲取一個(gè)隨機(jī)字符
*
* @return
*/
private char randomChar() {
return codes.charAt(random.nextInt(codes.length()));
}
/**
* 創(chuàng)建一個(gè)空白的BufferedImage對(duì)象
*
* @return
*/
private BufferedImage createImage() {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2 = (Graphics2D) image.getGraphics();
g2.setColor(bgColor);// 設(shè)置驗(yàn)證碼圖片的背景顏色
g2.fillRect(0, 0, width, height);
return image;
}
public BufferedImage getImage() {
BufferedImage image = createImage();
Graphics2D g2 = (Graphics2D) image.getGraphics();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 4; i++) {
String s = randomChar() + "";
sb.append(s);
g2.setColor(randomColor());
g2.setFont(randomFont());
float x = i * width * 1.0f / 4;
g2.drawString(s, x, height - 8);
}
this.text = sb.toString();
drawLine(image);
return image;
}
/**
* 繪制干擾線(xiàn)
*
* @param image
*/
private void drawLine(BufferedImage image) {
Graphics2D g2 = (Graphics2D) image.getGraphics();
int num = 5;
for (int i = 0; i < num; i++) {
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);
int x2 = random.nextInt(width);
int y2 = random.nextInt(height);
g2.setColor(randomColor());
g2.setStroke(new BasicStroke(1.5f));
g2.drawLine(x1, y1, x2, y2);
}
}
public String getText() {
return text;
}
public static void output(BufferedImage image, OutputStream out) throws IOException {
ImageIO.write(image, "JPEG", out);
}
}
- Controller生成驗(yàn)證碼
/**
* 用戶(hù)登錄/注冊(cè)校驗(yàn)碼生成
* 生成驗(yàn)證碼后,將本次生成驗(yàn)證碼操作存入redis中,有效期為3分鐘
* 鍵值規(guī)則為 USER_VERIFYCODE_SESSION + UUID : 4位數(shù)字驗(yàn)證碼
* @param request
* @param response
* @return
*/
@RequestMapping(path = "/getVerifyCodePic",method = RequestMethod.GET)
public QiqvJSONResult getVerifyCodePic(HttpServletRequest request, HttpServletResponse response) throws IOException {
Map<String, String> result = new HashMap<>();
VerifyCodeUtils code = new VerifyCodeUtils();
// 生成驗(yàn)證碼圖片
BufferedImage image = code.getImage();
// 獲取驗(yàn)證碼四位數(shù)字
String text = code.getText();
// 驗(yàn)證碼-鍵值對(duì)存入分別存入redis
String verifyCode_key = USER_VERIFYCODE_SESSION+UUID.randomUUID().toString();
redisOperator.setValue(verifyCode_key,text,60*3);
//進(jìn)行base64編碼
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try{
ImageIO.write(image, "png", bos);
String string = Base64Utils.encodeToString(bos.toByteArray());
result.put("key", verifyCode_key);
result.put("image", string);
return QiqvJSONResult.ok(result);
}catch (IOException e){
System.out.println(e);
}finally {
bos.close();
}
return QiqvJSONResult.errorMsg("生成驗(yàn)證碼失敗");
}
這里要注意,用戶(hù)的請(qǐng)求時(shí)無(wú)狀態(tài)的,我們生成驗(yàn)證碼后,怎么將當(dāng)前發(fā)起驗(yàn)證碼請(qǐng)求的用戶(hù)和提交驗(yàn)證碼的用戶(hù)關(guān)聯(lián)起來(lái),確認(rèn)是同一名用戶(hù)呢?
這里選擇的方案是:
后臺(tái)生成一個(gè)隨機(jī)的憑證號(hào)連同驗(yàn)證碼一起同時(shí)發(fā)給用戶(hù)并保存到Redis中。后續(xù)通過(guò)這個(gè)憑證來(lái)作為用戶(hù)標(biāo)識(shí)。
(這里也可以采用session來(lái)做,具體可以百度找相關(guān)案例)
- Controller 校驗(yàn)驗(yàn)證碼是否合法
/**
* 驗(yàn)證碼校驗(yàn)
* 將用戶(hù)寫(xiě)入的驗(yàn)證碼和保存到redis的驗(yàn)證碼比對(duì)
* @param verifyCode
* @return
*/
private String verifyCodeCheck(String verifyCodeKey,String verifyCode){
if(StringUtils.isBlank(verifyCode) || StringUtils.isBlank(verifyCodeKey)){
return "驗(yàn)證碼不能為空";
}
String value = redisOperator.getValue(verifyCodeKey);
// 驗(yàn)證碼已過(guò)期
if(null == value){
return "驗(yàn)證碼已過(guò)期,請(qǐng)刷新后重試";
//說(shuō)明是用戶(hù)亂填或者有緩存
}else if(!verifyCode.equalsIgnoreCase(value)){
return "無(wú)效的驗(yàn)證碼,請(qǐng)刷新后重試";
}
return null;
}
前端代碼
1、發(fā)起獲取驗(yàn)證碼請(qǐng)求
getVerifyCodePic(){
var that = this;
getVerifyCode().then(res => {
if(res.code == 200 && res.data){
that.loginForm.verifyKey = res.data.key;
that.verifyCodePicUrl = "data:image/png;base64," + res.data.image;
}else if(res.code ==500 && res.msg){
that.$message.error(res.msg);
}else{
that.$message.error('獲取驗(yàn)證碼失敗');
}
}).catch(err => {
console.log(err);
})
},
這里要注意兩點(diǎn):
- 對(duì)后端傳來(lái)的圖片信息進(jìn)行轉(zhuǎn)碼
- getVerifyCode()方法是封裝了一個(gè)get請(qǐng)求,大家參考回調(diào)函數(shù)就行
2、驗(yàn)證碼的規(guī)則校驗(yàn)
// 校驗(yàn)驗(yàn)證碼格式是否正確
checkVerifyCode(verifyCode){
var pattern = /[0-9A-Za-z]{4}/g;
console.log(verifyCode)
if(!verifyCode || verifyCode == ''){
this.$message.error('請(qǐng)輸入驗(yàn)證碼');
return false;
}else if(verifyCode.length < 4){
this.$message.error('驗(yàn)證碼不得小于4位');
return false;
}else if(!pattern.exec(verifyCode)){
this.$message.error('驗(yàn)證碼不合法');
return false;
}else{
return true;
}
}
3、頁(yè)面顯示驗(yàn)證碼


4、刷新驗(yàn)證碼
// 重新生成驗(yàn)證碼
resetVerifyCode(){
this.isDisable=true;
this.getVerifyCodePic();
setTimeout(() => {
this.isDisable=false;
},1500)
}
這里需要注意,為了防止用戶(hù)瘋狂點(diǎn)擊驗(yàn)證碼給后臺(tái)帶去無(wú)謂的流量請(qǐng)求,所以前臺(tái)做了一下限制,每次點(diǎn)擊后要1.5s后才可以繼續(xù)點(diǎn)擊。
最終效果圖如下:

結(jié)束語(yǔ):
到這里,對(duì)于驗(yàn)證碼功能已經(jīng)講解完畢了,如果需要整個(gè)項(xiàng)目的源碼,可以去我的GitHub項(xiàng)目中下載:https://github.com/moutory/QiQvCloud-Music
如果有不懂或者文章有誤的內(nèi)容,歡迎交流。