SpringBoot + Spring Security 學習筆記(三)實現圖片驗證碼認證

木鯨魚發表於2019-04-14

整體實現邏輯

  1. 前端在登入頁面時,自動從後臺獲取最新的驗證碼圖片
  2. 伺服器接收穫取生成驗證碼請求,生成驗證碼和對應的圖片,圖片響應回前端,驗證碼儲存一份到伺服器的 session 中
  3. 前端使用者登入時攜帶當前驗證碼
  4. 伺服器校驗驗證碼是否合法(驗證碼存在並未過期),繼續後續的使用者名稱和密碼校驗邏輯

通過一個時序圖來表述如下圖,圖中細化了一下各個控制器和過濾器之間的功能職責,還不是很正規,只為了更好表達上述的流程描述,所以讀者們將就一下:

SpringBoot + Spring Security 學習筆記(三)實現圖片驗證碼認證

時序圖 plantUML 程式碼

plantUML 的使用教程請移步至之前釋出過的文章:PlantUML 語法之時序圖

@startuml
hide footbox
skinparam sequenceMessageAlign center
skinparam sequenceArrowFontSize 11
skinparam noteFontSize 11
skinparam monochrome true
skinparam lifelinestrategy solid 

autonumber "<b>[000]"
participant browser as ui
participant imageCodeController as ic
participant UserController as uc
database sessionStorage as session
participant CodeAuthenticationFilter as cf
participant "UsernamePasswordAuthenticationFilter" as uf

ui -> ic: 登入請求
ic -> ic: 生成驗證碼
ic -> session: 儲存驗證碼資訊
ic --> ui: 驗證碼圖片

...

autonumber "<b>[000]"
ui -> cf: 帳號登入
cf -> session: 獲取驗證碼
session --> cf: 驗證碼
cf -> cf: 校驗請求驗證碼合法性
cf -> uf: 使用者認證的後續操作

uf --> uc: 返回認證結果
uc --> ui: 使用者登入成功
@enduml
複製程式碼

將上述的邏輯進行任務拆分:隨機驗證碼和圖片生成,生成驗證碼請求Controller,session儲存器就臨時使用spring-social-web包中的SessionStrategy來儲存,驗證碼過濾器,配置過濾器到spring scuerity

過濾器中的使用者密碼驗證過濾器之前。

前端控制器+生成驗證碼

伺服器 session 容器

採用小步快走的開發模式,前端控制器和生成驗證碼的程式碼都寫在一起,後期再進行程式碼重構, 這裡主要引用了spring-social-web依賴:

<dependency>
    <groupId>org.springframework.social</groupId>
    <artifactId>spring-social-web</artifactId>
    <version>1.1.4.RELEASE</version>
</dependency>
複製程式碼

這個包裡面有個很小巧的session管理工具:SessionStrategy

考慮到這個 session 在驗證碼過濾器中還得使用,所以自定義了一個配置,直接注入到了spring中:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;

@Configuration
public class AppConfig {

    @Bean("sessionStrategy")
    public SessionStrategy registBean() {
        SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
        return sessionStrategy;
    }
}
複製程式碼

這樣,在 Controller 層直接通過@Autowired引用即可。

圖片驗證碼容器

圖片驗證碼物件需要至少三個屬性:圖片,驗證碼,過期時間。

後期可能還有其他的驗證形式,但是其中公共的部分:驗證碼和過期時間是可以抽象出來,這裡為了演示不做重構。

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

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ImageCode {
	
    private BufferedImage image;
    private String code;
    private LocalDateTime expireTime;
    
    public ImageCode(BufferedImage image, String code, int expireIn) {
        this.code = code;
        this.image = image;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }
    
    public boolean isExpried() {
        return LocalDateTime.now().isAfter(getExpireTime());
    }
}
複製程式碼

公共常量

在圖片生成程式碼中,圖片的尺寸,驗證碼的隨機隨機數長度和過期時間,都設計在了靜態常量類中,當然也可以做成配置檔案。驗證碼的 session 的唯一標識也做成了公共的,以便在驗證碼過濾器中進行校驗時使用:

public class MyConstants {
    public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    // 圖片寬度
    public static final int WIDTH = 90;

    // 圖片高度
    public static final int HEIGHT = 20;

    // 驗證碼的位數
    public static final int RANDOM_SIZE = 4;

    // 驗證碼過期秒數	
    public static final int EXPIRE_SECOND = 30;
}
複製程式碼

生成驗證碼請求Controller原始碼:

import static org.woodwhale.king.commons.MyConstants.EXPIRE_SECOND;
import static org.woodwhale.king.commons.MyConstants.HEIGHT;
import static org.woodwhale.king.commons.MyConstants.RANDOM_SIZE;
import static org.woodwhale.king.commons.MyConstants.SESSION_KEY;
import static org.woodwhale.king.commons.MyConstants.WIDTH;

import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;
import org.woodwhale.king.model.ImageCode;

@RestController
public class ValidateCodeController {

    @Autowired
    private SessionStrategy sessionStrategy;

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ImageCode imageCode = generate(new ServletWebRequest(request));
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode);
        ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }

    /**
     * 生成圖形驗證碼
     * @param request
     * @return
     */
    private ImageCode generate(ServletWebRequest request) {
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), "width", WIDTH);
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), "height", HEIGHT);
        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);
        }

        String sRand = "";
        int length = ServletRequestUtils.getIntParameter(request.getRequest(), "length", RANDOM_SIZE);
        for (int i = 0; i < length; i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += 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, EXPIRE_SECOND);
    }

    /**
     * 生成隨機背景條紋
     *
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        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);
    }
}
複製程式碼

為了提升程式碼的可擴充套件性,隨機驗證碼的生成方法generate()的方法最好是抽成介面,後期可能還有簡訊驗證碼,三方登入的驗證碼,這裡僅做演示。上述驗證碼圖片效果:

SpringBoot + Spring Security 學習筆記(三)實現圖片驗證碼認證

驗證碼過濾器

SpringSecurity是通過過濾器鏈來進行校驗的,我們想要驗證圖形驗證碼,所以可以在認證流程之前,也就是UsernamePasswordAuthenticationFilter之前進行校驗。

那麼自定義的驗證碼過濾器也需要實現j2EE的過濾器介面,同時驗證方法validate()只做了內部方法抽象,後期可以做成可擴充套件的抽象介面,這個void方法可能會丟擲異常,這裡的異常設計成了spring security框架的AuthenticationException高階抽象異常的子類,為了就是保證和安全認證的異常同步,後期使用同一個失敗處理器抓取AuthenticationException型別的異常即可:

import org.springframework.security.core.AuthenticationException;

public class MyException extends AuthenticationException {

    private static final long serialVersionUID = 1L;

    public MyException(String msg) {
        super(msg);
    }

}
複製程式碼

而上述異常的接收者就是springboot + spring security 學習筆記(一)自定義基本使用及個性化登入配置裡提到的自定義認證失敗處理器。

import static org.woodwhale.king.commons.MyConstants.SESSION_KEY;

import java.io.IOException;
import java.util.Objects;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Component;
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 org.woodwhale.king.MyException;
import org.woodwhale.king.model.ImageCode;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component("validateCodeFilter")
public class ValidateCodeFilter extends OncePerRequestFilter implements Filter {

    /**
     * 驗證碼校驗失敗處理器
     */
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    private SessionStrategy sessionStrategy;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
        // 必須是登入的post請求才能進行驗證,其他的直接放行
        if(StringUtils.equals("/user/login", request.getRequestURI()) && StringUtils.equalsIgnoreCase(request.getMethod(), "post")) {
            log.info("request : {}", request.getRequestURI());
            try {
                // 1. 進行驗證碼的校驗
                validate(new ServletWebRequest(request));
            } catch (AuthenticationException e) {
                // 2. 捕獲步驟1中校驗出現異常,交給失敗處理類進行進行處理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        
        // 3. 校驗通過,就放行
        filterChain.doFilter(request, response);
        
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        // 1. 獲取請求中的驗證碼
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
        // 2. 校驗空值情況
        if(StringUtils.isEmpty(codeInRequest)) {
            throw new MyException("驗證碼不能為空");
        }
        
        // 3. 獲取伺服器session池中的驗證碼
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, SESSION_KEY);
        if(Objects.isNull(codeInSession)) {
            throw new MyException("驗證碼不存在");
        }

        // 4. 校驗伺服器session池中的驗證碼是否過期
        if(codeInSession.isExpried()) {
            sessionStrategy.removeAttribute(request, SESSION_KEY);
            throw new MyException("驗證碼過期了");
        }
        
        // 5. 請求驗證碼校驗
        if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
            throw new MyException("驗證碼不匹配");
        }
        
        // 6. 移除已完成校驗的驗證碼
        sessionStrategy.removeAttribute(request, SESSION_KEY);
    }  
}
複製程式碼

細節注意:這個過濾器繼承了OncePerRequestFilter,目的在於接受 spring 的管理,它能保證我們的過濾器在一次請求中只被呼叫一次。

驗證碼過濾器配置

驗證碼的過濾應該在使用者認證過濾之前,所以需要配置在UsernamePasswordAuthenticationFilter過濾器之前,自定義的ValidateCodeFilter過濾器由於配置了@Component("validateCodeFilter"),所以已經注入到了 spring 中,安全認證配置中直接@Autowired引用即可。

**注意:**因為在驗證碼Controller 中設定了這個/code/image請求路徑,因此要做不需驗證配置,將其加入到.antMatchers()中。

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private ValidateCodeFilter validateCodeFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 將自定義的驗證碼過濾器放置在 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) 
            .formLogin()
            .loginPage("/login")	 					// 設定登入頁面
            .loginProcessingUrl("/user/login") 			// 自定義的登入介面
            .successHandler(myAuthenctiationSuccessHandler)
            .failureHandler(myAuthenctiationFailureHandler)
            .defaultSuccessUrl("/home").permitAll()		// 登入成功之後,預設跳轉的頁面
            .and().authorizeRequests()					// 定義哪些URL需要被保護、哪些不需要被保護
            .antMatchers("/", "/index", "/user/login", "/code/image").permitAll() // 設定所有人都可以訪問登入頁面
            .anyRequest().authenticated() 				// 任何請求,登入後可以訪問
            .and().csrf().disable(); 					// 關閉csrf防護
            
    }
}
複製程式碼

到此,整個圖片驗證碼的安全認證流程設計就結束了,可以再回頭看看筆者最開始畫的時序圖,感覺還是非常不專業規範的,這裡輔助說明的草稿,如筆者有設計更好的時序圖,歡迎交流。

個人部落格:woodwhale's blog

部落格園:木鯨魚的部落格

相關文章