SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

木鯨魚發表於2019-04-24

在 Spring Security 中基於表單的認證模式,預設就是密碼帳號登入認證,那麼對於簡訊驗證碼+登入的方式,Spring Security 沒有現成的介面可以使用,所以需要自己的封裝一個類似的認證過濾器和認證處理器實現簡訊認證。

簡訊驗證碼認證

驗證碼物件類設計

和圖片驗證碼一樣,需要自己封裝一個驗證碼物件,用來生成手機驗證碼併傳送給手機。因為圖片驗證碼和手機驗證碼物件的區別就在於前者多了個圖片物件,所以兩者共同部分抽象出來可以設計成一個ValidateCode類,這個類裡面只存放驗證碼和過期時間,簡訊驗證碼直接使用這個類即可:

import java.time.LocalDateTime;

import lombok.Data;

@Data
public class ValidateCode {

    private String code;

    private LocalDateTime expireTime;

    public ValidateCode(String code, int expireIn){
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }
    
    public boolean isExpried() {
        return LocalDateTime.now().isAfter(getExpireTime());
    }

	public ValidateCode(String code, LocalDateTime expireTime) {
		super();
		this.code = code;
		this.expireTime = expireTime;
	}
}
複製程式碼

圖片驗證碼承繼此類:

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

import org.woodwhales.king.validate.code.ValidateCode;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper=false)
public class ImageCode extends ValidateCode {

    private BufferedImage image;

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

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

}
複製程式碼

驗證碼生成類設計

由於圖片和簡訊類均可以生成相應的驗證碼,所以直接設計一個驗證碼生成介面,具體實現類根據業務進行實現:

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

public interface ValidateCodeGenerator {

	ValidateCode generate(ServletWebRequest request);

}
複製程式碼

這裡的傳參設計成了ServletWebRequest是能夠根據前端請求中的引數進行不同的業務實現

目前實現累只有圖片生成器和驗證碼生成器:

// 圖片驗證碼生成器
@Component("imageCodeGenerator")
public class ImageCodeGenerator implements ValidateCodeGenerator {

    /**
     * 生成圖形驗證碼
     * @param request
     * @return
     */
	@Override
    public ValidateCode generate(ServletWebRequest request) {
        
        ……

        return new ImageCode(image, sRand, SecurityConstants.EXPIRE_SECOND);

    }
}

// 簡訊驗證碼生成器
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {

	@Override
	public ValidateCode generate(ServletWebRequest request) {
		String code = RandomStringUtils.randomNumeric(SecurityConstants.SMS_RANDOM_SIZE);
        return new ValidateCode(code, SecurityConstants.SMS_EXPIRE_SECOND);
	}

}
複製程式碼

簡訊驗證碼傳送介面設計

簡訊驗證碼生成之後,需要設計介面依賴簡訊服務提供商進行驗證碼傳送,因此至少設計一個統一的介面,供簡訊服務提供商生成傳送簡訊服務:

public interface SmsCodeSender {
	// 至少需要手機號和驗證碼
	void send(String mobile, String code);

}
複製程式碼

為了演示,設計一個虛擬的預設簡訊傳送器,只在日誌檔案中列印一行log:

import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;

/**
 * 簡訊傳送模擬
 * @author Administrator
 *
 */
@Slf4j
@Service
public class DefaultSmsCodeSender implements SmsCodeSender {

    @Override
    public void send(String mobile, String code) {
    	log.debug("send to mobile :{}, code : {}", mobile, code);
    }
}
複製程式碼

簡訊驗證碼請求Controller

所有驗證碼的請求都在統一的ValidateCodeController裡,這裡注入了兩個驗證碼生成器ValidateCodeGenerator,後期可以利用 spring 的依賴查詢/搜尋技巧來重構程式碼,另外所有的請求也是可以做成動態配置,這裡臨時全部 hardCode 在程式碼裡:

import java.io.IOException;

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.ServletRequestBindingException;
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.woodwhales.king.core.commons.SecurityConstants;
import org.woodwhales.king.validate.code.ValidateCode;
import org.woodwhales.king.validate.code.ValidateCodeGenerator;
import org.woodwhales.king.validate.code.image.ImageCode;
import org.woodwhales.king.validate.code.sms.DefaultSmsCodeSender;

@RestController
public class ValidateCodeController {
	
	@Autowired
	private SessionStrategy sessionStrategy;

	@Autowired
	private ValidateCodeGenerator imageCodeGenerator;
	
	@Autowired
	private ValidateCodeGenerator smsCodeGenerator;
	
	@Autowired
	private DefaultSmsCodeSender defaultSmsCodeSender;
	
	@GetMapping("code/image")
    public void createImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
		ImageCode imageCode = (ImageCode)imageCodeGenerator.generate(new ServletWebRequest(request));
		sessionStrategy.setAttribute(new ServletWebRequest(request), SecurityConstants.SESSION_KEY, imageCode);
		ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
    }
	
	@GetMapping("code/sms")
    public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
		ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
		sessionStrategy.setAttribute(new ServletWebRequest(request), SecurityConstants.SESSION_KEY, smsCode);
		String mobile = ServletRequestUtils.getStringParameter(request, "mobile");
		defaultSmsCodeSender.send(mobile, smsCode.getCode());
    }
}
複製程式碼

從上述程式碼中可以看出圖片驗證碼和簡訊驗證碼的生成請求邏輯是相似的:首先呼叫驗證碼生成介面生成驗證碼,然後將驗證碼放入 session 中,最後將驗證碼返回給前端或者使用者。因此這個套路流程可以抽象成一個模板方法,以增強程式碼的可維護性和可擴充套件性。

用一張圖來表述重構後的程式碼結構:

SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

隨機驗證碼過濾器設計

由於圖片和手機都會產生驗證碼,後期還可以通過郵件傳送隨機驗證碼的方式進行隨機驗證碼登入驗證,因此將隨機驗證碼的認證可以獨立封裝在一個隨機驗證碼過濾器中,並且這個過濾器在整個 spring security 過濾器鏈的最前端(它是第一道認證牆)。

隨機驗證碼過濾器只要繼承 spring 框架中的OncePerRequestFilter即可保證這個過濾器在請求來的時候只被呼叫一次,具體程式碼實現參見文末原始碼。

這裡重點解釋一下如何將隨機驗證碼過濾器配置到 spring security 過濾器認證最前端,需要重寫SecurityConfigurerAdapterconfigure()方法,並將自定義的過濾器放到AbstractPreAuthenticatedProcessingFilter過濾器之前即可:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
import org.springframework.stereotype.Component;

import javax.servlet.Filter;


@Component
public class ValidateCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private Filter validateCodeFilter;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class);
    }
}
複製程式碼

簡訊驗證碼認證

在自定義簡訊登入認證流程之前,建議可以移步到之前的文章:SpringBoot + Spring Security 學習筆記(二)安全認證流程原始碼詳解,瞭解清除使用者密碼的認證流程才能更容易理解下面這張經典的流程圖:

SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

左側是使用者+密碼的認證流程,整體的流程就是經過使用者名稱+密碼認證過濾器認證,將請求封裝成 token 並注入到 AutheticationMananger 中,之後由預設的認證校驗器進行校驗,在校驗的過程中會呼叫 UserDetailsService 介面進行 token 校驗,當校驗成功之後,就會將已經認證的 token 放到 SecurityContextHolder 中。

同理,由於簡訊登入方式只需要使用隨機驗證碼進行校驗而不需要密碼登入功能,當校驗成功之後就認為使用者認證成功了,因此需要仿造左側的流程開發自定義的簡訊登入認證 token,這個 token 只需要存放手機號即可,在token 校驗的過程中,不能使用預設的校驗器了,需要自己開發校驗當前自定義 token 的校驗器,最後將自定義的過濾器和校驗器配置到 spring security 框架中即可。

注意:簡訊隨機驗證碼的驗證過程是在 SmsCodeAuthticationFIlter 之前就已經完成。

簡訊登入認證Token

仿造UsernamePasswordAuthenticationToken設計一個屬於簡訊驗證的認證 token 物件,為什麼要自定義一個簡訊驗證的 token,spring security 框架不只提供了使用者名稱+密碼的驗證方式,使用者認證是否成功,最終看的就是SecurityContextHolder物件中是否有對應的AuthenticationToken,因此要設計一個認證物件,當認證成功之後,將其設定到SecurityContextHolder即可。

import java.util.Collection;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final Object principal;

	public SmsCodeAuthenticationToken(Object mobile) {
		super(null);
		this.principal = mobile;
		setAuthenticated(false);
	}

	public SmsCodeAuthenticationToken(Object mobile, Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = mobile;
		super.setAuthenticated(true); // must use super, as we override
	}

	public Object getPrincipal() {
		return this.principal;
	}
	
	public Object getCredentials() {
		return null;
	}

	public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
		if (isAuthenticated) {
			throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
		}

		super.setAuthenticated(false);
	}

	@Override
	public void eraseCredentials() {
		super.eraseCredentials();
	}

}
複製程式碼

AuthenticationToken介面可以看到,現在框架中有我們自己定義簡訊登入的 token 了:

SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

簡訊登入認證過濾器

簡訊驗證碼的過濾器設計思路同理,仿造UsernamePasswordAuthenticationFilter過濾器,這裡再次提醒,簡訊隨機驗證碼

import java.util.Objects;

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

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import org.woodwhales.core.constants.SecurityConstants;

public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	/**
     * 請求中的引數
     */
	private String mobileParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE;
	
	private boolean postOnly = true;

	public SmsCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, "POST"));
    }

	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		if (postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}

		// 獲取請求中的引數值
		String mobile = obtainMobile(request);

		if (Objects.isNull(mobile)) {
			mobile = "";
		}

		mobile = mobile.trim();
		
		SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);

		return this.getAuthenticationManager().authenticate(authRequest);
	}

	/**
     * 獲取手機號
     */
	protected String obtainMobile(HttpServletRequest request) {
		return request.getParameter(mobileParameter);
	}

	protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
		authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
	}

	public void setMobileParameter(String mobileParameter) {
		Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
		this.mobileParameter = mobileParameter;
	}

	public void setPostOnly(boolean postOnly) {
		this.postOnly = postOnly;
	}

	public final String getMobileParameter() {
		return mobileParameter;
	}

}
複製程式碼

簡訊驗證碼過濾器也成為了AbstractAuthenticationProcessingFilter其中一個子類,後期需要註冊到安全配置中,讓它成為安全認證過濾鏈中的一環:

SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

簡訊登入認證校驗器

簡訊登入認證校驗器的作用就是呼叫UserDetailsServiceloadUserByUsername()方法對 authenticationToken 進行校驗,所有校驗器的根介面為:AuthenticationProvider,因此自定義的簡訊登入認證校驗器實現這個介面,重寫authenticate()即可:

import java.util.Objects;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import lombok.Data;

@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

	private UserDetailsService userDetailsService;
	
	@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        /**
         * 呼叫 {@link UserDetailsService}
         */
        UserDetails user = userDetailsService.loadUserByUsername((String)authenticationToken.getPrincipal());

        if (Objects.isNull(user)) {
            throw new InternalAuthenticationServiceException("無法獲取使用者資訊");
        }

        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

	@Override
	public boolean supports(Class<?> authentication) {
		return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
	}

}
複製程式碼

注意,這裡使用@Data註解生成 setter 和 getter 方法。

SpringBoot + Spring Security 學習筆記(五)實現簡訊驗證碼+登入功能

簡訊登入認證安全配置設計

設計一個封裝好的簡訊登入認證配置類,以供外部呼叫者直接呼叫:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler myAuthenticationFailureHandler;

    @Autowired
    private UserDetailsService userDetailsService;

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

        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);

        // 獲取驗證碼提供者
        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        // 將簡訊驗證碼校驗器註冊到 HttpSecurity, 並將簡訊驗證碼過濾器新增在 UsernamePasswordAuthenticationFilter 之前
        http.authenticationProvider(smsCodeAuthenticationProvider).addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    }
}
複製程式碼

當外部想要引用這個封裝好的配置,只需要在自定義的AbstractChannelSecurityConfig安全認證配置中新增進去即可,注意這個配置物件使用了@Component註解,註冊到了spring 中,所以可以直接通過@Autowired引用,如:

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.stereotype.Component;
import org.woodwhales.core.authentication.sms.AbstractChannelSecurityConfig;
import org.woodwhales.core.authentication.sms.SmsCodeAuthenticationSecurityConfig;
import org.woodwhales.core.validate.code.config.ValidateCodeSecurityConfig;

@Component
public class BrowserSecurityConfig extends AbstractChannelSecurityConfig {

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Autowired
    private ValidateCodeSecurityConfig validateCodeSecurityConfig;
    
    @Autowired
    protected AuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    protected AuthenticationFailureHandler authenticationFailureHandler;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private DataSource dataSource;

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

        http.formLogin()
            .loginPage("/authentication/require") // 登入頁面回撥
            .successHandler(authenticationSuccessHandler)// 認證成功回撥
            .failureHandler(authenticationFailureHandler)
        
	        // 以下驗證碼的校驗配置
	        .and()
	        .apply(validateCodeSecurityConfig) 
	
	        // 以下簡訊登入認證的配置
	        .and()
	        .apply(smsCodeAuthenticationSecurityConfig)
	            
	        // 記住我的配置
	        .and()
	        .rememberMe()
	        .tokenRepository(persistentTokenRepository())
	        .tokenValiditySeconds(3600) // 設定記住我的過期時間
	        .userDetailsService(userDetailsService)
	        
	        .and()
	        // 請求做授權配置
	        .authorizeRequests() 
	        // 以下請求路徑不需要認證
	        .antMatchers("/authentication/require",
	                "/authentication/mobile",
	                "/login",
	                "/code/*",
	                "/")
	        .permitAll() 
	        .anyRequest() // 任何請求
	        .authenticated() // 都需要身份認證
            
            // 暫時將防護跨站請求偽造的功能置為不可用
            .and()
            .csrf().disable();
    }
    
    /**
     * 配置TokenRepository
     * @return
     */
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        // 初始化記住我的資料庫表,建議通過看原始碼直接建立出來
		// jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }

}
複製程式碼

這裡的配置中有些程式碼出現了冗餘配置,可以全部封裝成抽象模板,完成一些基礎的配置。

專案原始碼:github.com/woodwhales/…

參考原始碼:github.com/imooc-java/…

相關文章