關(guān)于登錄驗(yàn)證碼的開(kāi)發(fā)思路——超詳細(xì)

前言

我們?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ì)算等等...

image.png

講完驗(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)證碼
image.png

image.png
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)擊。

最終效果圖如下:
最終效果圖.gif

結(jié)束語(yǔ):

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容