前面關於Spring Security寫了兩篇文章,一篇是介紹UsernamePasswordAuthenticationFilter,另外一篇是介紹 AuthenticationManager。不少同窗表示沒法理解這兩個東西有什麼用,能解決哪些實際問題?因此今天就對這兩篇理論進行實戰運用,咱們從零寫一個短信驗證碼登陸並適配到Spring Security體系中。若是你在閱讀中有什麼疑問能夠回頭看看這兩篇文章,能解決不少疑惑。html
固然你能夠修改爲郵箱或者其它通信設備的驗證碼登陸。
驗證碼存在有效期,通常5分鐘。 通常邏輯是用戶輸入手機號後去獲取驗證碼,服務端對驗證碼進行緩存。在最大有效期內用戶只能使用驗證碼驗證成功一次(避免驗證碼浪費);超過最大時間後失效。
驗證碼的緩存生命週期:java
public interface CaptchaCacheStorage { /** * 驗證碼放入緩存. * * @param phone the phone * @return the string */ String put(String phone); /** * 從緩存取驗證碼. * * @param phone the phone * @return the string */ String get(String phone); /** * 驗證碼手動過時. * * @param phone the phone */ void expire(String phone); }
咱們通常會藉助於緩存中間件,好比Redis、Ehcache、Memcached等等來作這個事情。爲了方便收看該教程的同窗們所使用的不一樣的中間件。這裏我結合Spring Cache特地抽象了驗證碼的緩存處理。web
private static final String SMS_CAPTCHA_CACHE = "captcha"; @Bean CaptchaCacheStorage captchaCacheStorage() { return new CaptchaCacheStorage() { @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public String put(String phone) { return RandomUtil.randomNumbers(5); } @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public String get(String phone) { return null; } @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public void expire(String phone) { } }; }
務必保證緩存的可靠性,這與用戶的體驗息息相關。
接着咱們就來編寫驗證碼服務了,驗證碼服務的核心功能有兩個:發送驗證碼和驗證碼校驗。其它的諸如統計、黑名單、歷史記錄可根據實際業務定製。這裏只實現核心功能。spring
/** * 驗證碼服務. * 兩個功能: 發送和校驗. * * @param captchaCacheStorage the captcha cache storage * @return the captcha service */ @Bean public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) { return new CaptchaService() { @Override public boolean sendCaptcha(String phone) { String existed = captchaCacheStorage.get(phone); if (StringUtils.hasText(existed)) { // 節約成本的話若是緩存中有可用的驗證碼 再也不發新的驗證碼 log.warn("captcha code 【 {} 】 is available now", existed); return false; } // 生成驗證碼並放入緩存 String captchaCode = captchaCacheStorage.put(phone); log.info("captcha: {}", captchaCode); //todo 這裏自行完善調用第三方短信服務發送驗證碼 return true; } @Override public boolean verifyCaptcha(String phone, String code) { String cacheCode = captchaCacheStorage.get(phone); if (Objects.equals(cacheCode, code)) { // 驗證經過手動過時 captchaCacheStorage.expire(phone); return true; } return false; } }; }
接下來就能夠根據CaptchaService
編寫短信發送接口/captcha/{phone}
了。數據庫
@RestController @RequestMapping("/captcha") public class CaptchaController { @Resource CaptchaService captchaService; /** * 模擬手機號發送驗證碼. * * @param phone the mobile * @return the rest */ @GetMapping("/{phone}") public Rest<?> captchaByMobile(@PathVariable String phone) { //todo 手機號 正則自行驗證 if (captchaService.sendCaptcha(phone)){ return RestBody.ok("驗證碼發送成功"); } return RestBody.failure(-999,"驗證碼發送失敗"); } }
下面的教程就必須用到前兩篇介紹的知識了。咱們要實現驗證碼登陸就必須定義一個Servlet Filter進行處理。它的做用這裏再重複一下:緩存
Authentication
憑據。AuthenticationManager
認證。咱們須要先定製Authentication
和AuthenticationManager
app
Authentication
在我看來就是一個載體,在未獲得認證以前它用來攜帶登陸的關鍵參數,好比用戶名和密碼、驗證碼;在認證成功後它攜帶用戶的信息和角色集。因此模仿UsernamePasswordAuthenticationToken
來實現一個CaptchaAuthenticationToken
,去掉沒必要要的功能,抄就完事兒了:dom
package cn.felord.spring.security.captcha; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; import java.util.Collection; /** * 驗證碼認證憑據. * @author felord.cn */ public class CaptchaAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private final Object principal; private String captcha; /** * 此構造函數用來初始化未授信憑據. * * @param principal the principal * @param captcha the captcha * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String, Collection) */ public CaptchaAuthenticationToken(Object principal, String captcha) { super(null); this.principal = principal; this.captcha = captcha; setAuthenticated(false); } /** * 此構造函數用來初始化授信憑據. * * @param principal the principal * @param captcha the captcha * @param authorities the authorities * @see CaptchaAuthenticationToken#CaptchaAuthenticationToken(Object, String) */ public CaptchaAuthenticationToken(Object principal, String captcha, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.captcha = captcha; super.setAuthenticated(true); // must use super, as we override } public Object getCredentials() { return this.captcha; } public Object getPrincipal() { return this.principal; } 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(); captcha = null; }
咱們還須要定製一個AuthenticationManager
來對上面定義的憑據CaptchaAuthenticationToken
進行認證處理。下面這張圖有必要再拿出來看一下:ide
要定義AuthenticationManager
只須要定義其實現ProviderManager
。而ProviderManager
又須要依賴AuthenticationProvider
。因此咱們要實現一個專門處理CaptchaAuthenticationToken
的AuthenticationProvider
。AuthenticationProvider
的流程是:函數
CaptchaAuthenticationToken
拿到手機號、驗證碼。UserDetailsService
接口根據這個流程實現以下:
package cn.felord.spring.security.captcha; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.MessageSource; import org.springframework.context.MessageSourceAware; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityMessageSource; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.util.Assert; import java.util.Collection; import java.util.Objects; /** * 驗證碼認證器. * @author felord.cn */ @Slf4j public class CaptchaAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware { private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); private final UserDetailsService userDetailsService; private final CaptchaService captchaService; private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); /** * Instantiates a new Captcha authentication provider. * * @param userDetailsService the user details service * @param captchaService the captcha service */ public CaptchaAuthenticationProvider(UserDetailsService userDetailsService, CaptchaService captchaService) { this.userDetailsService = userDetailsService; this.captchaService = captchaService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(CaptchaAuthenticationToken.class, authentication, () -> messages.getMessage( "CaptchaAuthenticationProvider.onlySupports", "Only CaptchaAuthenticationToken is supported")); CaptchaAuthenticationToken unAuthenticationToken = (CaptchaAuthenticationToken) authentication; String phone = unAuthenticationToken.getName(); String rawCode = (String) unAuthenticationToken.getCredentials(); UserDetails userDetails = userDetailsService.loadUserByUsername(phone); // 此處省略對UserDetails 的可用性 是否過時 是否鎖定 是否失效的檢驗 建議根據實際狀況添加 或者在 UserDetailsService 的實現中處理 if (Objects.isNull(userDetails)) { throw new BadCredentialsException("Bad credentials"); } // 驗證碼校驗 if (captchaService.verifyCaptcha(phone, rawCode)) { return createSuccessAuthentication(authentication, userDetails); } else { throw new BadCredentialsException("captcha is not matched"); } } @Override public boolean supports(Class<?> authentication) { return CaptchaAuthenticationToken.class.isAssignableFrom(authentication); } @Override public void afterPropertiesSet() throws Exception { Assert.notNull(userDetailsService, "userDetailsService must not be null"); Assert.notNull(captchaService, "captchaService must not be null"); } @Override public void setMessageSource(MessageSource messageSource) { this.messages = new MessageSourceAccessor(messageSource); } /** * 認證成功將非授信憑據轉爲授信憑據. * 封裝用戶信息 角色信息。 * * @param authentication the authentication * @param user the user * @return the authentication */ protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) { Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities()); CaptchaAuthenticationToken authenticationToken = new CaptchaAuthenticationToken(user, null, authorities); authenticationToken.setDetails(authentication.getDetails()); return authenticationToken; } }
而後就能夠組裝ProviderManager
了:
ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));
通過3.1和3.2的準備,咱們的準備工做就完成了。
定製好驗證碼憑據和驗證碼認證管理器後咱們就能夠定義驗證碼認證過濾器了。修改一下UsernamePasswordAuthenticationFilter就能知足需求:
package cn.felord.spring.security.captcha; import org.springframework.lang.Nullable; 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 javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class CaptchaAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone"; public static final String SPRING_SECURITY_FORM_CAPTCHA_KEY = "captcha"; public CaptchaAuthenticationFilter() { super(new AntPathRequestMatcher("/clogin", "POST")); } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String phone = obtainPhone(request); String captcha = obtainCaptcha(request); if (phone == null) { phone = ""; } if (captcha == null) { captcha = ""; } phone = phone.trim(); CaptchaAuthenticationToken authRequest = new CaptchaAuthenticationToken( phone, captcha); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } @Nullable protected String obtainCaptcha(HttpServletRequest request) { return request.getParameter(SPRING_SECURITY_FORM_CAPTCHA_KEY); } @Nullable protected String obtainPhone(HttpServletRequest request) { return request.getParameter(SPRING_SECURITY_FORM_PHONE_KEY); } protected void setDetails(HttpServletRequest request, CaptchaAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } }
這裏咱們指定了攔截驗證碼登錄的請求爲:
POST /clogin?phone=手機號&captcha=驗證碼 HTTP/1.1 Host: localhost:8082
接下來就是配置了。
我把全部的驗證碼認證的相關配置集中了起來,並加上了註釋。
package cn.felord.spring.security.captcha; import cn.hutool.core.util.RandomUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.util.StringUtils; import java.util.Collections; import java.util.Objects; /** * 驗證碼認證配置. * * @author felord.cn * @since 13 :23 */ @Slf4j @Configuration public class CaptchaAuthenticationConfiguration { private static final String SMS_CAPTCHA_CACHE = "captcha"; /** * spring cache 管理驗證碼的生命週期. * * @return the captcha cache storage */ @Bean CaptchaCacheStorage captchaCacheStorage() { return new CaptchaCacheStorage() { @CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public String put(String phone) { return RandomUtil.randomNumbers(5); } @Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public String get(String phone) { return null; } @CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone") @Override public void expire(String phone) { } }; } /** * 驗證碼服務. * 兩個功能: 發送和校驗. * * @param captchaCacheStorage the captcha cache storage * @return the captcha service */ @Bean public CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) { return new CaptchaService() { @Override public boolean sendCaptcha(String phone) { String existed = captchaCacheStorage.get(phone); if (StringUtils.hasText(existed)) { // 節約成本的話若是緩存存在可用的驗證碼 再也不發新的驗證碼 log.warn("captcha code 【 {} 】 is available now", existed); return false; } // 生成驗證碼並放入緩存 String captchaCode = captchaCacheStorage.put(phone); log.info("captcha: {}", captchaCode); //todo 這裏自行完善調用第三方短信服務 return true; } @Override public boolean verifyCaptcha(String phone, String code) { String cacheCode = captchaCacheStorage.get(phone); if (Objects.equals(cacheCode, code)) { // 驗證經過手動過時 captchaCacheStorage.expire(phone); return true; } return false; } }; } /** * 自行實現根據手機號查詢可用的用戶,這裏簡單舉例. * 注意該接口可能出現多態。因此最好加上註解@Qualifier * * @return the user details service */ @Bean @Qualifier("captchaUserDetailsService") public UserDetailsService captchaUserDetailsService() { // 驗證碼登錄後密碼無心義了可是須要填充一下 return username -> User.withUsername(username).password("TEMP") //todo 這裏權限 你須要本身注入 .authorities(AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_APP")).build(); } /** * 驗證碼認證器. * * @param captchaService the captcha service * @param userDetailsService the user details service * @return the captcha authentication provider */ @Bean public CaptchaAuthenticationProvider captchaAuthenticationProvider(CaptchaService captchaService, @Qualifier("captchaUserDetailsService") UserDetailsService userDetailsService) { return new CaptchaAuthenticationProvider(userDetailsService, captchaService); } /** * 驗證碼認證過濾器. * * @param authenticationSuccessHandler the authentication success handler * @param authenticationFailureHandler the authentication failure handler * @param captchaAuthenticationProvider the captcha authentication provider * @return the captcha authentication filter */ @Bean public CaptchaAuthenticationFilter captchaAuthenticationFilter(AuthenticationSuccessHandler authenticationSuccessHandler, AuthenticationFailureHandler authenticationFailureHandler, CaptchaAuthenticationProvider captchaAuthenticationProvider) { CaptchaAuthenticationFilter captchaAuthenticationFilter = new CaptchaAuthenticationFilter(); // 配置 authenticationManager ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider)); captchaAuthenticationFilter.setAuthenticationManager(providerManager); // 成功處理器 captchaAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler); // 失敗處理器 captchaAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler); return captchaAuthenticationFilter; } }
然而這並無完,你須要將CaptchaAuthenticationFilter
配置到整個Spring Security的過濾器鏈中,這種看了胖哥教程的同窗應該很是熟悉了。
請特別注意:務必保證登陸接口和驗證碼接口能夠匿名訪問,若是是動態權限能夠給接口添加
ROLE_ANONYMOUS
角色。
大功告成,測試以下:
並且原先的登陸方式不受影響。
經過對UsernamePasswordAuthenticationFilter和 AuthenticationManager的系統學習,咱們瞭解了Spring Security認證的整個流程,本文是對這兩篇的一個實際運用。相信看到這一篇後你就不會對前幾篇的圖解懵逼了,這也是理論到實踐的一次嘗試。DEMO 能夠經過我的博客felord.cn相關文章獲取。
關注公衆號:Felordcn 獲取更多資訊