在 Spring Security 中基於表單的認證模式,默認就是密碼賬號登陸認證,那麼對於短信驗證碼+登陸的方式,Spring Security 沒有現成的接口可使用,因此須要本身的封裝一個相似的認證過濾器和認證處理器實現短信認證。前端
和圖片驗證碼同樣,須要本身封裝一個驗證碼對象,用來生成手機驗證碼併發送給手機。由於圖片驗證碼和手機驗證碼對象的區別就在於前者多了個圖片對象,因此二者共同部分抽象出來能夠設計成一個ValidateCode
類,這個類裏面只存放驗證碼和過時時間,短信驗證碼直接使用這個類便可:java
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; } }
圖片驗證碼承繼此類:git
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; } }
因爲圖片和短信類都可以生成相應的驗證碼,因此直接設計一個驗證碼生成接口,具體實現類根據業務進行實現:github
import org.springframework.web.context.request.ServletWebRequest; public interface ValidateCodeGenerator { ValidateCode generate(ServletWebRequest request); }
這裏的傳參設計成了
ServletWebRequest
是可以根據前端請求中的參數進行不一樣的業務實現web
目前實現累只有圖片生成器和驗證碼生成器:spring
// 圖片驗證碼生成器 @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); } }
短信驗證碼生成以後,須要設計接口依賴短信服務提供商進行驗證碼發送,所以至少設計一個統一的接口,供短信服務提供商生成發送短信服務:sql
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); } }
全部驗證碼的請求都在統一的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 中,最後將驗證碼返回給前端或者用戶。所以這個套路流程能夠抽象成一個模板方法,以加強代碼的可維護性和可擴展性。session
用一張圖來表述重構後的代碼結構:
因爲圖片和手機都會產生驗證碼,後期還能夠經過郵件發送隨機驗證碼的方式進行隨機驗證碼登陸驗證,所以將隨機驗證碼的認證能夠獨立封裝在一個隨機驗證碼過濾器中,而且這個過濾器在整個 spring security 過濾器鏈的最前端(它是第一道認證牆)。
隨機驗證碼過濾器只要繼承 spring 框架中的OncePerRequestFilter
便可保證這個過濾器在請求來的時候只被調用一次,具體代碼實現參見文末源碼。
這裏重點解釋一下如何將隨機驗證碼過濾器配置到 spring security 過濾器認證最前端,須要重寫SecurityConfigurerAdapter
的configure()
方法,並將自定義的過濾器放到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 學習筆記(二)安全認證流程源碼詳解,瞭解清除用戶密碼的認證流程才能更容易理解下面這張經典的流程圖:
左側是用戶+密碼的認證流程,總體的流程就是通過用戶名+密碼認證過濾器認證,將請求封裝成 token 並注入到 AutheticationMananger 中,以後由默認的認證校驗器進行校驗,在校驗的過程當中會調用 UserDetailsService 接口進行 token 校驗,當校驗成功以後,就會將已經認證的 token 放到 SecurityContextHolder 中。
同理,因爲短信登陸方式只須要使用隨機驗證碼進行校驗而不須要密碼登陸功能,當校驗成功以後就認爲用戶認證成功了,所以須要仿造左側的流程開發自定義的短信登陸認證 token,這個 token 只須要存放手機號便可,在token 校驗的過程當中,不能使用默認的校驗器了,須要本身開發校驗當前自定義 token 的校驗器,最後將自定義的過濾器和校驗器配置到 spring security 框架中便可。
注意:短信隨機驗證碼的驗證過程是在 SmsCodeAuthticationFIlter 以前就已經完成。
仿造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 了:
短信驗證碼的過濾器設計思路同理,仿造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
其中一個子類,後期須要註冊到安全配置中,讓它成爲安全認證過濾鏈中的一環:
短信登陸認證校驗器的做用就是調用UserDetailsService
的loadUserByUsername()
方法對 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 方法。
設計一個封裝好的短信登陸認證配置類,以供外部調用者直接調用:
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; } }
這裏的配置中有些代碼出現了冗餘配置,能夠所有封裝成抽象模板,完成一些基礎的配置。