說說驗證碼功能的實現

2023-06-07 06:00:42

前言

大家好,我是 god23bin,今天說說驗證碼功能的實現,相信大家都經常接觸到驗證碼的,畢竟平時上網也能遇到各種驗證碼,需要我們輸入驗證碼進行驗證我們是人類,而不是機器人。

驗證碼有多種型別,比如圖片驗證碼、簡訊驗證碼和郵件驗證碼等等,雖說多種型別,圖片也好,簡訊也好,郵件也好,都是承載驗證碼的載體,最主要的核心就是一個驗證碼的生成、儲存和校驗。

本篇文章就從這幾個方面出發說說驗證碼,廢話不多說,下面開始正文。

實現思路

驗證碼驗證的功能,其實現思路還是挺簡單的,不論是圖片驗證碼、簡訊驗證碼還是郵件驗證碼,無非就以下幾點:

  1. 驗證碼本質就是一堆字元的組合(數位也好,英文字母也好),後端生成驗證碼,並儲存到某個位置(比如儲存到 Redis,並設定驗證碼的過期時間)。
  2. 返回驗證碼給前端頁面、傳送簡訊驗證碼給使用者或者傳送郵件驗證碼給使用者。驗證碼可以是以文字顯示或者圖片顯示。
  3. 使用者輸入看到的驗證碼,並提交驗證(驗證也可以忽略大小寫,當然具體看需求)。
  4. 後端將使用者輸入的驗證碼拿過來進行校驗,對比使用者輸入的驗證碼是否和後端生成的一致,一致就驗證成功,否則驗證失敗。

驗證碼的生成

首先,需要知道的就是驗證碼的生成,這就涉及到生成驗證碼的演演算法,可以自己純手寫,也可以使用人家提供的工具,這裡我就介紹下面 4 種生成驗證碼的方式。

1. 純原生手寫生成文字驗證碼

需求:隨機產生一個 n 位的驗證碼,每位可能是數位、大寫字母、小寫字母。

實現:本質就是隨機生成字串,字串可包含數位、大寫字母、小寫字母。

準備一個包含數位、大寫字母、小寫字母的字串,藉助 Random 類,迴圈 n 次隨機獲取字串的下標,就能拼接出一個隨機字元組成的字串了。

package cn.god23bin.demo.util;

import java.util.Random;

public class MyCaptchaUtil {

	/**
     * 生成 n 位驗證碼
     * @param n 位數
     * @return n 位驗證碼
     **/
    public static String generateCode(int n) {
        String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuilder sb = new StringBuilder();
        Random random = new Random();
        for (int i = 0; i < n; i++) {
            int index = random.nextInt(chars.length());
            sb.append(chars.charAt(index));
        }
        return sb.toString();
    }
    
}

2. 純原生手寫生成圖片驗證碼

實現:使用 Java 的 awt 和 swing 庫來生成圖片驗證碼。下面使用 BufferedImage 類建立一個指定大小的圖片,然後隨機生成 n 個字元,將其畫在圖片上,將生成的字元和圖片驗證碼放到雜湊表返回。後續我們就可以拿到驗證碼的文字值,並且可以將圖片驗證碼輸出到指定的輸出流中。

package cn.god23bin.demo.util;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;

public class MyCaptchaUtil {

	/**
     * 生成 n 位的圖片驗證碼
     * @param n 位數
     * @return 雜湊表,code 獲取文字驗證碼,img 獲取 BufferedImage 圖片物件
     **/
    public static Map<String, Object> generateCodeImage(int n) {
        int width = 100, height = 50;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        g.setColor(Color.LIGHT_GRAY);
        g.fillRect(0, 0, width, height);
        String chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            int index = random.nextInt(chars.length());
            char c = chars.charAt(index);
            sb.append(c);
            g.setColor(new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
            g.setFont(new Font("Arial", Font.BOLD, 25));
            g.drawString(Character.toString(c), 20 + i * 15, 25);
        }
        Map<String, Object> res = new HashMap<>();
        res.put("code", sb.toString());
        res.put("img", image);
        return res;
    }
    
}

我們可以寫一個獲取驗證碼的介面,以二進位制流輸出返回給前端,前端可以直接使用 img 標籤來顯示我們返回的圖片,只需在 src 屬性賦值我們的獲取驗證碼介面。

@RequestMapping("/captcha")
@RestController
public class CaptchaController {

    @GetMapping("/code/custom")
    public void getCode(HttpServletResponse response) {
        Map<String, Object> map = MyCaptchaUtil.generateCodeImage(5);
        System.out.println(map.get("code"));
        BufferedImage img = (BufferedImage) map.get("img");

        // 設定響應頭,防止快取
        response.setHeader("Cache-Control", "no-store, no-cache");
        response.setContentType("image/png");
        try {
            ImageIO.write(img, "png", response.getOutputStream());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

3. 使用 Hutool 工具生成圖形驗證碼

引入依賴:可以單獨引入驗證碼模組或者全部模組都引入

<!-- 驗證碼模組 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-captcha</artifactId>
    <version>5.8.15</version>
</dependency>

<!-- 全部模組都引入 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.15</version>
</dependency>
  • 生成線段干擾的驗證碼:
// 設定圖形驗證碼的寬和高,同時生成了驗證碼,可以通過 lineCaptcha.getCode() 獲取文字驗證碼
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(200, 100);
  • 生成圓圈干擾的驗證碼:
// 設定圖形驗證碼的寬、高、驗證碼字元數、干擾元素個數
CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 100, 4, 20);
  • 生成扭曲干擾的驗證碼:
// 定義圖形驗證碼的寬、高、驗證碼字元數、干擾線寬度
ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 100, 4, 4);

獲取驗證碼介面:

@RequestMapping("/captcha")
@RestController
public class CaptchaController {

    @GetMapping("/code/hutool")
    public void getCodeByHutool(HttpServletResponse response) {
        LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(200, 100);
        System.out.println("線段干擾的驗證碼:" + lineCaptcha.getCode());

        // 設定響應頭,防止快取
        response.setHeader("Cache-Control", "no-store, no-cache");
        response.setContentType("image/png");
        try {
            lineCaptcha.write(response.getOutputStream());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

4. 使用 Kaptcha 生成驗證碼

Kaptcha 是谷歌的一個生成驗證碼工具包,我們簡單設定其屬性就可以實現驗證碼的驗證功能。

引入依賴項:它只有一個版本:2.3.2

<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

簡單看看 kaptcha 屬性:

屬性 描述 預設值
kaptcha.border 圖片邊框,合法值:yes , no yes
kaptcha.border.color 邊框顏色,合法值: r,g,b (and optional alpha) 或者 white,black,blue. black
kaptcha.border.thickness 邊框厚度,合法值:>0 1
kaptcha.image.width 圖片寬 200
kaptcha.image.height 圖片高 50
kaptcha.producer.impl 圖片實現類 com.google.code.kaptcha.impl.DefaultKaptcha
kaptcha.textproducer.impl 文字實現類 com.google.code.kaptcha.text.impl.DefaultTextCreator
kaptcha.textproducer.char.string 文字集合,驗證碼值從此集合中獲取 abcde2345678gfynmnpwx
kaptcha.textproducer.char.length 驗證碼長度 5
kaptcha.textproducer.font.names 字型 Arial, Courier
kaptcha.textproducer.font.size 字型大小 40px
kaptcha.textproducer.font.color 字型顏色,合法值: r,g,b 或者 white,black,blue. black
kaptcha.textproducer.char.space 文字間隔 2
kaptcha.noise.impl 干擾實現類 com.google.code.kaptcha.impl.DefaultNoise
kaptcha.noise.color 干擾顏色,合法值: r,g,b 或者 white,black,blue. black
kaptcha.obscurificator.impl 圖片樣式: 水紋com.google.code.kaptcha.impl.WaterRipple 魚眼com.google.code.kaptcha.impl.FishEyeGimpy 陰影com.google.code.kaptcha.impl.ShadowGimpy com.google.code.kaptcha.impl.WaterRipple
kaptcha.background.impl 背景實現類 com.google.code.kaptcha.impl.DefaultBackground
kaptcha.background.clear.from 背景顏色漸變,開始顏色 light grey
kaptcha.background.clear.to 背景顏色漸變,結束顏色 white
kaptcha.word.impl 文字渲染器 com.google.code.kaptcha.text.impl.DefaultWordRenderer
kaptcha.session.key session key KAPTCHA_SESSION_KEY
kaptcha.session.date session date KAPTCHA_SESSION_DATE

簡單設定下 Kaptcha:

package cn.god23bin.demo.config;

import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

@Configuration
public class KaptchaConfig {
    /**
     * 設定生成圖片驗證碼的bean
     * @return
     */
    @Bean(name = "kaptchaProducer")
    public DefaultKaptcha getKaptchaBean() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        properties.setProperty("kaptcha.border", "no");
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        properties.setProperty("kaptcha.textproducer.char.space", "4");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        properties.setProperty("kaptcha.textproducer.char.string", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

也是和 Hutool 一樣,很簡單就能生成驗證碼了。如下:

// 生成文字驗證碼
String text = kaptchaProducer.createText();
// 生成圖片驗證碼
BufferedImage image = kaptchaProducer.createImage(text);

獲取驗證碼介面:

@RequestMapping("/captcha")
@RestController
public class CaptchaController {

    @Autowired
    private Producer kaptchaProducer;

    @GetMapping("/code/kaptcha")
    public void getCodeByKaptcha(HttpServletResponse response) {
        // 生成文字驗證碼
        String text = kaptchaProducer.createText();
        System.out.println("文字驗證碼:" + text);
        // 生成圖片驗證碼
        BufferedImage image = kaptchaProducer.createImage(text);

        // 設定響應頭,防止快取
        response.setHeader("Cache-Control", "no-store, no-cache");
        response.setContentType("image/jpeg");
        try {
            ImageIO.write(image, "jpg", response.getOutputStream());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

驗證碼的儲存與校驗

上面的驗證碼的生成,就僅僅是生成驗證碼,並沒有將驗證碼儲存在後端,所以現在我們需要做的是:將驗證碼儲存起來,便於後續的校驗對比。

那麼儲存到什麼地方呢?如果你沒接觸過 Redis,那麼第一次的想法可能就是儲存到關係型資料庫中,比如 MySQL。想當年,我最開始的想法就是這樣哈哈哈。

不過,目前用得最多的就是將驗證碼儲存到 Redis 中,好處就是減少了資料庫的壓力,加快了驗證碼的讀取效率,還能輕鬆設定驗證碼的過期時間。

簡單設定 Redis

引入 Redis 依賴項:

我們使用 Spring Data Redis,它提供了 RedisTemplateStringRedisTemplate 模板類,簡化了我們使用 Java 進行 Redis 的操作。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

簡單設定下 Redis:

spring:
  redis:
    host: localhost
    port: 6379
    database: 1
    timeout: 5000
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 大多數情況,都是選用<String, Object>
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);

        // 使用JSON的序列化物件,對資料 key 和 value 進行序列化轉換
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        // ObjectMapper 是 Jackson 的一個工作類,作用是將 JSON 轉成 Java 物件,即反序列化。或將 Java 物件轉成 JSON,即序列化
        ObjectMapper mapper = new ObjectMapper();
        // 設定序列化時的可見性,第一個引數是選擇序列化哪些屬性,比如時序列化 setter? 還是 filed? 第二個引數是選擇哪些修飾符許可權的屬性來序列化,比如 private 或者 public,這裡的 any 是指對所有許可權修飾的屬性都可見(可序列化)
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        jackson2JsonRedisSerializer.setObjectMapper(mapper);
        // 設定 RedisTemplate 模板的序列化方式為 jacksonSeial
        template.setDefaultSerializer(jackson2JsonRedisSerializer);
        return template;
    }
    
}

將驗證碼儲存到 Redis

將驗證碼儲存到 Redis 設定 5 分鐘的過期時間,Redis 是 Key Value 這種形式儲存的,所以需要約定好 Key 的命名規則。

命名的時候,為了區分為每個使用者生成的驗證碼,所以需要一個標識,剛好可以通過當前請求的 HttpSession 中的 SessionID 作為唯一標識,拼接到 Key 的名稱中。

當然,也不一定使用 SessionID 作為唯一標識,如果能知道其他的,也可以用其他的作為標識,比如拼接使用者的手機號。

實現:

@RequestMapping("/captcha")
@RestController
public class CaptchaController {

    @Autowired
    private Producer kaptchaProducer;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @GetMapping("/code")
    public void getCode(HttpServletRequest request, HttpServletResponse response) {
        // 生成文字驗證碼
        String text = kaptchaProducer.createText();
        System.out.println("文字驗證碼:" + text);
        // 生成圖片驗證碼
        BufferedImage image = kaptchaProducer.createImage(text);

        // 儲存到 Redis 設定 5 分鐘的過期時間
        // 約定好儲存的 Key 的命名規則,這裡使用 code_sessionId_type_1 表示圖形驗證碼
        // Code_sessionId_Type_1:分為 3 部分,code 表明是驗證碼,sessionId 表明是給哪個使用者的驗證碼,type_n 表明驗證碼型別,n 為 1 表示圖形驗證碼,2 表示簡訊驗證碼,3 表示郵件驗證碼
        String key = "code_" + request.getSession().getId() + "_type_1";
        redisTemplate.opsForValue().set(key, text, 5, TimeUnit.SECONDS);

        response.setHeader("Cache-Control", "no-store, no-cache");
        response.setContentType("image/jpeg");
        try {
            ImageIO.write(image, "jpg", response.getOutputStream());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

上面程式碼中有一個額外的設計就是,由於傳送的驗證碼有多種型別(圖形驗證碼、簡訊驗證碼、郵件驗證碼),所以加多了一個 type_n 來標識當前儲存的驗證碼是什麼型別的,方便以後出現問題快速定位。

實際上,這裡的命名規則,可以根據你的具體需求來客製化,又比如說,登入的時候需要驗證碼、註冊的時候也需要驗證碼、修改使用者密碼的時候也需要驗證碼,為了便於出現問題進行定位,也可以繼續加多一個標識 when_n,n 為 1 表示註冊、n 為 2 表示登入,以此類推。

校驗

我們模擬登入的時候進行驗證碼的校驗,使用一個 LoginDTO 物件來接收前端的登入相關的引數。

package cn.god23bin.demo.model.domain.dto;

import lombok.Data;

@Data
public class LoginDTO {
    private String username;
    private String password;
    /**
     * 驗證碼
     */
    private String code;
}

寫一個登入介面,登入的過程中,校驗使用者輸入的驗證碼。

@RequestMapping("/user")
@RestController
public class UserController {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO, HttpServletRequest request) {
        if (!"root".equals(loginDTO.getUsername()) || !"123456".equals(loginDTO.getPassword())) {
            return Result.fail("登入失敗!賬號或密碼不正確!");
        }
        // 校驗使用者輸入的驗證碼
        String code = loginDTO.getCode();
        String codeInRedis = (String) redisTemplate.opsForValue().get("code_" + request.getSession().getId() + "_type_1");
        if (!code.equals(codeInRedis)) {
            return Result.fail("驗證碼不正確!");
        }
        return Result.ok("登入成功!");
    }
}

至此,便完成了驗證碼功能的實現。

獲取驗證碼的安全設計

驗證碼功能的實現現在是OK的,但還有一點需要注意,那就是防止驗證碼被隨意呼叫獲取,或者被大量呼叫。如果不做限制,那麼誰都能呼叫,就非常大的可能會被攻擊了。

我們上面實現的驗證碼功能是圖形驗證碼,是校驗使用者從圖形驗證碼中看到後輸入的數位字母組合跟後端生成的組合是否是一致的。對於圖形驗證碼,到這裡就可以了,不用限制(當然想限制也可以)。但是對於簡訊驗證碼,就還不可以。我們需要額外考慮一些防刷機制,以保障系統的安全性和可靠性(因為傳簡訊是要錢的啊!)。

對於簡訊來說,一種常見的攻擊方式是「簡訊轟炸」,攻擊者通過自動批次提交手機號碼、模擬IP等手段,對系統進行大規模的簡訊請求,從而消耗資源或干擾正常業務。為了應對這種情況,我們需要設計一些防刷機制。

防刷機制

目前我瞭解到的防刷機制有下面幾種,如果你有別的方法,歡迎評論說出來噢!

  1. 圖形驗證碼或者滑動驗證:傳送簡訊前先使用圖形驗證碼或者滑動進行驗證,驗證成功才能呼叫傳送簡訊驗證碼的介面。
  2. 時間限制:從使用者點選傳送簡訊驗證碼開始,前端進行一個 60 秒的倒數,在這 60 秒之內,使用者無法提交傳送資訊的請求的,這樣就限制了傳送簡訊驗證碼的介面的呼叫次數。不過這種方式,如果被攻擊者知道了傳送簡訊的介面,那也是會被刷的。
  3. 手機號限制:對使用同一個手機號碼進行註冊或者其他傳送簡訊驗證碼的操作的時候,系統可以對這個手機號碼進行限制,例如,一天只能傳送 5 條簡訊驗證碼,超出限制則做出提示(如:系統繁忙,請稍後再試)。然而,這也只能夠避免人工手動刷簡訊而已,對於批次使用不同手機號碼來刷簡訊的機器,同樣是會被刷。
  4. IP地址限制:記錄請求的IP地址,並對同一 IP 地址的請求進行限制,比如限制某個 IP 地址在一定時間內只能傳送特定數量的驗證碼。同樣,也是可以被轟炸的。

至於這些機制的實現,有機會再寫寫,你感興趣的話可以自己去操作試試!

總結

本篇文字就說了驗證碼功能的實現思路和實現,包括驗證碼的生成、儲存、展示和校驗。

  • 生成驗證碼可以手寫也可以藉助工具。

  • 儲存一般是儲存在 Redis 中的,當然你想儲存在 MySQL 中也不是不可以,就是需要自己去實現諸如過期時間的功能。

  • 展示可以通過文字展示或者圖片展示,我們可以返回一個二進位制流給前端,前端通過 img 標籤的 src 屬性去請求我們的介面。

  • 校驗就拿到使用者輸入的驗證碼,和後端生成的驗證碼進行比對,相同就驗證成功,否則失敗。

最後我們也說了驗證碼的防刷機制,這是需要考慮的,這裡的防刷機制對於使用大量不同手機號、不同 IP 地址是沒效果的,依舊可以暴刷。所以這部分內容還是有待研究的。也歡迎大家在評論區說出你的看法!

最後的最後

希望各位螢幕前的靚仔靚女們給個三連!你輕輕地點了個贊,那將在我的心裡世界增添一顆明亮而耀眼的星!

咱們下期再見!