Spring Security技術棧學習筆記(九)開發圖形驗證碼接口

在設計登錄模塊的時候,圖形驗證碼基本上都是標配,本篇博客重點介紹開發可重用的圖形驗證碼接口,該接口支持用戶自定義配置,比如驗證碼的長度、驗證碼圖形的寬度和高度等信息。

本文的目標是開發一個圖形驗證碼接口,該驗證碼支持用戶自定義長度,以及生成圖片後圖片的寬度和高度、驗證碼的過期時間等。接下來按照整個設計思路介紹開發流程。

一、開發圖形驗證碼實體類及屬性類

1)圖形驗證碼實體類

圖形驗證碼一般都需要一個實體類來進行承載,在這裏我設置了三個屬性,分別是BufferedImage類型的屬性、String類型的驗證碼以及LocalDateTime類型的時間參數。具體的代碼如下:

package com.lemon.security.core.validate.code;

import lombok.Data;

import java.awt.image.BufferedImage;
import java.time.LocalDateTime;

/**
 * 圖片驗證碼
 *
 * @author lemon
 * @date 2018/4/6 下午4:34
 */
@Data
public class ImageCode {

    private BufferedImage image;

    private String code;

    private LocalDateTime expireTime;

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        this.image = image;
        this.code = code;
        this.expireTime = expireTime;
    }

    public ImageCode(BufferedImage image, String code, int expireIn) {
        this.image = image;
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expireTime);
    }
}

這裏設置了兩個有參構造方法,常用的第二個有參構造方法的最後一個參數指定了驗證碼的過期時間,也就是在多少秒後失效。具體的判斷方法由LocalDateTime.now().isAfter(expireTime)來進行判斷的。

2)圖形驗證碼屬性類

圖形驗證碼的實體類是承載驗證碼的具體信息,而屬性類是爲了定義圖形驗證碼的長度、圖片的寬度高度以及驗證碼的過期時間等基本屬性。這些屬性支持用戶在YAML配置文件中進行配置的,當然也具備了默認值。具體代碼如下:

package com.lemon.security.core.properties;

import lombok.Data;

/**
 * 圖形驗證碼的默認配置
 *
 * @author lemon
 * @date 2018/4/6 下午9:42
 */
@Data
public class ImageCodeProperties {

    /**
     * 驗證碼寬度
     */
    private int width = 67;
    /**
     * 驗證碼高度
     */
    private int height = 23;
    /**
     * 驗證碼長度
     */
    private int length = 4;
    /**
     * 驗證碼過期時間
     */
    private int expireIn = 60;

    /**
     * 需要驗證碼的url字符串,用英文逗號隔開
     */
    private String url;
    
}

爲了保持和之前的瀏覽器的基本設置保持一致,這裏包裝一層配置,代碼如下:

package com.lemon.security.core.properties;

import lombok.Data;

/**
 * 封裝多個配置的類
 *
 * @author lemon
 * @date 2018/4/6 下午9:45
 */
@Data
public class ValidateCodeProperties {

    private ImageCodeProperties image = new ImageCodeProperties();
}

再將這個類包裝到SecurityProperties類中,代碼如下:

package com.lemon.security.core.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author lemon
 * @date 2018/4/5 下午3:08
 */
@Data
@ConfigurationProperties(prefix = "com.lemon.security")
public class SecurityProperties {

    private BrowserProperties browser = new BrowserProperties();

    private ValidateCodeProperties code = new ValidateCodeProperties();
}

那麼在配置文件中配置的方法如下:

# 配置圖形驗證碼
com:
  lemon:
    security:
      code:
        image:
          length: 6
          url: /user,/user/*

這個配置相當於用戶自定義了驗證碼的長度爲6,以及需要驗證碼的URI/user/uset/*,在默認的情況下,長度爲4。這幾個類基本完成了圖形驗證碼的自定義功能。

二、編寫圖形驗證碼生成接口和實現類

圖形驗證碼其實是完全不需要編寫接口的,這裏編寫接口是爲了方便用戶可以自定義接口的實現類,這樣就可以自己寫生成驗證碼的邏輯,而不是使用系統默認的生成方式。具體的接口如下:

package com.lemon.security.core.validate.code;

import org.springframework.web.context.request.ServletWebRequest;

/**
 * @author lemon
 * @date 2018/4/7 上午11:06
 */
public interface ValidateCodeGenerator {

    /**
     * 生成圖片驗證碼
     *
     * @param request 請求
     * @return ImageCode實例對象
     */
    ImageCode generate(ServletWebRequest request);
}

這裏爲什麼會傳入一個ServletWebRequest類型的參數,是因爲這個有許多對請求中參數操作的方法,十分方便,請看後面的實現類:

package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import lombok.Data;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

/**
 * @author lemon
 * @date 2018/4/7 上午11:09
 */
@Data
public class ImageCodeGenerator implements ValidateCodeGenerator {

    private static final String IMAGE_WIDTH_NAME = "width";
    private static final String IMAGE_HEIGHT_NAME = "height";
    private static final Integer MAX_COLOR_VALUE = 255;

    private SecurityProperties securityProperties;

    @Override
    public ImageCode generate(ServletWebRequest request) {
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME, securityProperties.getCode().getImage().getHeight());
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics g = image.getGraphics();

        Random random = new Random();

		// 生成畫布
        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < 155; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

		// 生成數字驗證碼
        StringBuilder sRand = new StringBuilder();
        for (int i = 0; i < securityProperties.getCode().getImage().getLength(); i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand.append(rand);
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }

        g.dispose();

        return new ImageCode(image, sRand.toString(), securityProperties.getCode().getImage().getExpireIn());
    }

    /**
     * 生成隨機背景條紋
     *
     * @param fc 前景色
     * @param bc 背景色
     * @return RGB顏色
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > MAX_COLOR_VALUE) {
            fc = MAX_COLOR_VALUE;
        }
        if (bc > MAX_COLOR_VALUE) {
            bc = MAX_COLOR_VALUE;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc - fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r, g, b);
    }
}

這裏提供了生成圖片驗證碼的具體實現,其中兩行代碼:

int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME, securityProperties.getCode().getImage().getHeight());

這個是由Spring提供的工具類來獲取請求中的參數,第一個參數是HttpServletRequest請求,第二個是參數名字,第三個是默認值,如果沒有獲取到指定名稱的參數的值,那麼就使用這個默認值。從這兩行代碼中可知,請求參數的寬度和高度的優先級將大於YAML配置文件中的參數,更加大於默認參數。本來這個類是可以使用@Component註解來標記爲SpringBean的,但是沒有這麼做,這是因爲這個實現類是本項目默認的,不一定完全符合用戶的需求,所以可以將其進行配置,而不是一定成爲SpringBean。具體的配置如下代碼:

package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author lemon
 * @date 2018/4/7 上午11:22
 */
@Configuration
public class ValidateCodeBeanConfig {

    private final SecurityProperties securityProperties;

    @Autowired
    public ValidateCodeBeanConfig(SecurityProperties securityProperties) {
        this.securityProperties = securityProperties;
    }

    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ValidateCodeGenerator imageCodeGenerator() {
        ImageCodeGenerator imageCodeGenerator = new ImageCodeGenerator();
        imageCodeGenerator.setSecurityProperties(securityProperties);
        return imageCodeGenerator;
    }
}

其實這個配置和在ImageCodeGenerator類上使用@Component註解效果是一致的,都會被標記爲SpringBean,但是在這裏,在配置的過程中使用了一個條件:@ConditionalOnMissingBean(name = "imageCodeGenerator"),也就是說上下文環境中如果沒有名稱爲imageCodeGeneratorSpring Bean的話,那麼就配置項目默認的Bean,否則將不配置這個Bean,這也就是說,如果用戶自定義了一個類實現了ValidateCodeGenerator接口,並且實現類的在Spring容器中Bean的名字爲imageCodeGenerator,那麼將使用用戶的實現類來生成圖形驗證碼。到現在這一步,基本完成了圖形驗證碼的核心需求。

三、編寫圖形驗證碼生成接口

圖形驗證碼接口將生成一個JPEG的圖片,那麼在前端就可以寫一個img標籤,src屬性指向接口。具體的Controller方法如下所示:

package com.lemon.security.core.validate.code;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author lemon
 * @date 2018/4/6 下午4:41
 */
@RestController
public class ValidateCodeController {

    static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private static final String FORMAT_NAME = "JPEG";

    private final ValidateCodeGenerator imageCodeGenerator;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    public ValidateCodeController(ValidateCodeGenerator imageCodeGenerator) {
        this.imageCodeGenerator = imageCodeGenerator;
    }

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 第一步:根據請求生成一個圖形驗證碼對象
        ImageCode imageCode = imageCodeGenerator.generate(new ServletWebRequest(request));
        // 第二步:將圖形驗證碼對象存到session中,第一個參數可以從傳入的請求中獲取session
        sessionStrategy.setAttribute(new ServletRequestAttributes(request), SESSION_KEY, imageCode);
        // 第三步:將生成的圖片寫到接口的響應中
        ImageIO.write(imageCode.getImage(), FORMAT_NAME, response.getOutputStream());
    }
}

這裏使用imageCodeGenerator對象的generate方法生成了圖形驗證碼,並將驗證碼存入到了session中,最後將圖片寫回到輸出流中。

四、編寫驗證碼的校驗邏輯

驗證碼生成以後自動寫回到了瀏覽器頁面上,並以圖片的形式進行了展示,與此同時,生成的圖形驗證碼被設置了過期時間,並存入到session中,當用戶登錄的時候,正確的邏輯是將登錄的驗證碼參數取出來和session中的驗證碼進行對比,如果驗證碼對比通過後纔開始驗證用戶名和密碼,由於用戶名和密碼的驗證用的是UsernamePasswordAuthenticationFilter來進行驗證的,所以這裏也需要寫一個過濾器,並且將這個過濾器放在UsernamePasswordAuthenticationFilter之前。先來編寫過濾器:

package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

/**
 * @author lemon
 * @date 2018/4/6 下午8:23
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

    private static final String SUBMIT_FORM_DATA_PATH = "/authentication/form";

    private AuthenticationFailureHandler authenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    private Set<String> urls = new HashSet<>();

    private SecurityProperties securityProperties;

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getImage().getUrl(), ",");
        urls.addAll(Arrays.asList(configUrls));
        // 登錄的鏈接是必須要進行驗證碼驗證的
        urls.add(SUBMIT_FORM_DATA_PATH);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        for (String url : urls) {
            // 如果實際訪問的URL可以與用戶在YML配置文件中配置的相同,那麼就進行驗證碼校驗
            if (antPathMatcher.match(url, request.getRequestURI())) {
                action = true;
            }
        }
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    /**
     * 驗證碼校驗邏輯
     *
     * @param request 請求
     * @throws ServletRequestBindingException 請求異常
     */
    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        // 從session中獲取圖片驗證碼
        ImageCode imageCodeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        // 從請求中獲取用戶填寫的驗證碼
        String imageCodeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
        if (StringUtils.isBlank(imageCodeInRequest)) {
            throw new ValidateCodeException("驗證碼不能爲空");
        }
        if (null == imageCodeInSession) {
            throw new ValidateCodeException("驗證碼不存在");
        }
        if (imageCodeInSession.isExpired()) {
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("驗證碼已過期");
        }
        if (!StringUtils.equalsIgnoreCase(imageCodeInRequest, imageCodeInSession.getCode())) {
            throw new ValidateCodeException("驗證碼不匹配");
        }
        // 驗證成功,刪除session中的驗證碼
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
    }
}

這個過濾器繼承了OncePerRequestFilter,這就保證了一次請求僅僅會運行一次過濾器,不會重複運行。而實現InitializingBean是爲了當前類作爲Spring Bean進行實例化完成(成員屬性全部初始化完成)的時候,會自動調用這個接口的afterPropertiesSet方法,當然,如果這個類沒有被Spring進行實例化,那麼就需要手動調用這個方法,這裏就是使用的手動調用afterPropertiesSet方法。這裏afterPropertiesSet方法是將用戶配置的需要對驗證碼進行校驗的連接進行裝配,將以英文逗號隔開的連接裝配到字符串數組中。在後面的doFilterInternal方法中,將遍歷這個字符串數組,如果當前訪問的鏈接包含在這個數組中,將進行校驗操作,否則該過濾器直接放行。具體的校驗邏輯請看上面的代碼,很簡單。前面已經說了,需要將該過濾器加入到UsernamePasswordAuthenticationFilter之前,具體的做法就是使用addFilterBefore方法,具體的代碼如下:

package com.lemon.security.browser;

import com.lemon.security.core.properties.SecurityProperties;
import com.lemon.security.core.validate.code.ValidateCodeFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * 瀏覽器安全驗證的配置類
 *
 * @author lemon
 * @date 2018/4/3 下午7:35
 */
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {

    private final SecurityProperties securityProperties;
    private final AuthenticationSuccessHandler lemonAuthenticationSuccessHandler;
    private final AuthenticationFailureHandler lemonAuthenticationFailureHandler;

    @Autowired
    public BrowserSecurityConfig(SecurityProperties securityProperties, AuthenticationSuccessHandler lemonAuthenticationSuccessHandler, AuthenticationFailureHandler lemonAuthenticationFailureHandler) {
        this.securityProperties = securityProperties;
        this.lemonAuthenticationSuccessHandler = lemonAuthenticationSuccessHandler;
        this.lemonAuthenticationFailureHandler = lemonAuthenticationFailureHandler;
    }

    /**
     * 配置了這個Bean以後,從前端傳遞過來的密碼將被加密
     *
     * @return PasswordEncoder實現類對象
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(lemonAuthenticationFailureHandler);
        validateCodeFilter.setSecurityProperties(securityProperties);
        validateCodeFilter.afterPropertiesSet();

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .loginPage("/authentication/require")
                .loginProcessingUrl("/authentication/form")
                .successHandler(lemonAuthenticationSuccessHandler)
                .failureHandler(lemonAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage(), "/code/image").permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf().disable();
    }
}

這就完成了全部的需求和功能,這時候啓動項目,訪問登陸界面,就可以看到如下圖片所示的情景:
這裏寫圖片描述
對於簡單的需求,生成驗證碼的邏輯很簡單,直接使用一個Controller即可,但是這裏爲什麼使用繞這麼多的邏輯,這是因爲這樣設計有框架設計的思想,給予了用戶更多的自定義條件,而不是一味的寫死。代碼很簡單,思想很重要!

Spring Security技術棧開發企業級認證與授權系列文章列表:

Spring Security技術棧學習筆記(一)環境搭建
Spring Security技術棧學習筆記(二)RESTful API詳解
Spring Security技術棧學習筆記(三)表單校驗以及自定義校驗註解開發
Spring Security技術棧學習筆記(四)RESTful API服務異常處理
Spring Security技術棧學習筆記(五)使用Filter、Interceptor和AOP攔截REST服務
Spring Security技術棧學習筆記(六)使用REST方式處理文件服務
Spring Security技術棧學習筆記(七)使用Swagger自動生成API文檔
Spring Security技術棧學習筆記(八)Spring Security的基本運行原理與個性化登錄實現
Spring Security技術棧學習筆記(九)開發圖形驗證碼接口
Spring Security技術棧學習筆記(十)開發記住我功能
Spring Security技術棧學習筆記(十一)開發短信驗證碼登錄
Spring Security技術棧學習筆記(十二)將短信驗證碼驗證方式集成到Spring Security
Spring Security技術棧學習筆記(十三)Spring Social集成第三方登錄驗證開發流程介紹
Spring Security技術棧學習筆記(十四)使用Spring Social集成QQ登錄驗證方式
Spring Security技術棧學習筆記(十五)解決Spring Social集成QQ登錄後的註冊問題
Spring Security技術棧學習筆記(十六)使用Spring Social集成微信登錄驗證方式

示例代碼下載地址:

項目已經上傳到碼雲,歡迎下載,內容所在文件夾爲chapter009

更多幹貨分享,歡迎關注我的微信公衆號:爪哇論劍(微信號:itlemon)
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章