Spring Security 實戰(zhàn)干貨:從零手寫一個驗證碼登錄
1. 前言
前面關(guān)于Spring Security胖哥又寫了兩篇文章,分別圖文并茂地介紹了UsernamePasswordAuthenticationFilter和?AuthenticationManager。很多同學(xué)表示無法理解這兩個東西有什么用,能解決哪些實際問題?所以今天就對這兩篇理論進(jìn)行實戰(zhàn)運用,我們從零寫一個短信驗證碼登錄并適配到Spring Security體系中。如果你在閱讀中有什么疑問可以回頭看看這兩篇文章,能解決很多疑惑。
當(dāng)然你可以修改成郵箱或者其它通訊設(shè)備的驗證碼登錄。
2. 驗證碼生命周期
驗證碼存在有效期,一般 5 分鐘。一般邏輯是用戶輸入手機號后去獲取驗證碼,服務(wù)端對驗證碼進(jìn)行緩存。在最大有效期內(nèi)用戶只能使用驗證碼驗證成功一次(避免驗證碼浪費);超過最大時間后失效。
驗證碼的緩存生命周期:
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等等來做這個事情。為了方便收看該教程的同學(xué)們所使用的不同的中間件。這里我結(jié)合Spring Cache特意抽象了驗證碼的緩存處理。
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)?{
????????}
????};
}
務(wù)必保證緩存的可靠性,這與用戶的體驗息息相關(guān)。
接著我們就來編寫和業(yè)務(wù)無關(guān)的驗證碼服務(wù)了,驗證碼服務(wù)的核心功能有兩個:發(fā)送驗證碼和驗證碼校驗。其它的諸如統(tǒng)計、黑名單、歷史記錄可根據(jù)實際業(yè)務(wù)定制。這里只實現(xiàn)核心功能。
/**
?*?驗證碼服務(wù).
?*?兩個功能:?發(fā)送和校驗.
?*
?*?@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))?{
????????????????//?節(jié)約成本的話如果緩存中有當(dāng)前手機可用的驗證碼?不再發(fā)新的驗證碼
????????????????return?true;
????????????}
????????????//?生成驗證碼并放入緩存
????????????String?captchaCode?=?captchaCacheStorage.put(phone);
????????????log.info("captcha:?{}",?captchaCode);
????????????//todo?這里自行完善調(diào)用第三方短信服務(wù)發(fā)送驗證碼
????????????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;
????????}
????};
}
接下來就可以根據(jù)CaptchaService編寫短信發(fā)送接口/captcha/{phone}了。
@RestController
@RequestMapping("/captcha")
public?class?CaptchaController?{
????@Resource
????CaptchaService?captchaService;
????/**
?????*?模擬手機號發(fā)送驗證碼.
?????*
?????*?@param?phone?the?mobile
?????*?@return?the?rest
?????*/
????@GetMapping("/{phone}")
????public?Rest>?captchaByMobile(@PathVariable?String?phone)?{
????????//todo 手機號?正則自行驗證
????????if?(captchaService.sendCaptcha(phone)){
????????????return?RestBody.ok("驗證碼發(fā)送成功");
????????}
????????return?RestBody.failure(-999,"驗證碼發(fā)送失敗");
????}
}
3. 集成到 Spring Security
下面的教程就必須用到前兩篇介紹的知識了。我們要實現(xiàn)驗證碼登錄就必須定義一個Servlet Filter進(jìn)行處理。它的作用這里再重復(fù)一下:
攔截短信登錄接口。 獲取登錄參數(shù)并封裝為 Authentication憑據(jù)。交給 AuthenticationManager認(rèn)證。
我們需要先定制Authentication和AuthenticationManager
3.1 驗證碼憑據(jù)
Authentication在我看來就是一個載體,在未得到認(rèn)證之前它用來攜帶登錄的關(guān)鍵參數(shù),比如用戶名和密碼、驗證碼;在認(rèn)證成功后它攜帶用戶的信息和角色集。所以模仿UsernamePasswordAuthenticationToken?來實現(xiàn)一個CaptchaAuthenticationToken,去掉不必要的功能,抄就完事兒了:
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;
/**
?*?驗證碼認(rèn)證憑據(jù).
?*?@author?felord.cn
?*/
public?class?CaptchaAuthenticationToken?extends?AbstractAuthenticationToken?{
????private?static?final?long?serialVersionUID?=?SpringSecurityCoreVersion.SERIAL_VERSION_UID;
????private?final?Object?principal;
????private?String?captcha;
????/**
?????*?此構(gòu)造函數(shù)用來初始化未授信憑據(jù).
?????*
?????*?@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);
????}
????/**
?????*?此構(gòu)造函數(shù)用來初始化授信憑據(jù).
?????*
?????*?@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;
????}
3.2 驗證碼認(rèn)證管理器
我們還需要定制一個AuthenticationManager來對上面定義的憑據(jù)CaptchaAuthenticationToken進(jìn)行認(rèn)證處理。下面這張圖有必要再拿出來看一下:

定義AuthenticationManager只需要定義其實現(xiàn)ProviderManager。而ProviderManager又需要依賴AuthenticationProvider。
所以我們要實現(xiàn)一個專門處理CaptchaAuthenticationToken的AuthenticationProvider。AuthenticationProvider的流程是:
從 CaptchaAuthenticationToken拿到手機號、驗證碼。利用手機號從數(shù)據(jù)庫查詢用戶信息,并判斷用戶是否是有效用戶,實際上就是實現(xiàn) UserDetailsService接口驗證碼校驗。 校驗成功則封裝授信的憑據(jù)。 校驗失敗拋出認(rèn)證異常。
根據(jù)這個流程實現(xiàn)如下:
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;
/**
?*?驗證碼認(rèn)證器.
?*?@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?的可用性?是否過期??是否鎖定?是否失效的檢驗??建議根據(jù)實際情況添加??或者在?UserDetailsService?的實現(xiàn)中處理
????????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);
????}
????/**
?????*?認(rèn)證成功將非授信憑據(jù)轉(zhuǎn)為授信憑據(jù).
?????*?封裝用戶信息?角色信息。
?????*
?????*?@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));
經(jīng)過3.1和3.2的準(zhǔn)備,我們的準(zhǔn)備工作就完成了。
3.3 驗證碼認(rèn)證過濾器
定制好驗證碼憑據(jù)和驗證碼認(rèn)證管理器后我們就可以定義驗證碼認(rèn)證過濾器了。修改一下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
接下來就是配置了。
3.4 配置
我把所有的驗證碼認(rèn)證的相關(guān)配置集中了起來,并加上了注釋。
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;
/**
?*?驗證碼認(rèn)證配置.
?*
?*?@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)?{
????????????}
????????};
????}
????/**
?????*?驗證碼服務(wù).
?????*?兩個功能:?發(fā)送和校驗.
?????*
?????*?@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))?{
????????????????????//?節(jié)約成本的話如果緩存存在可用的驗證碼?不再發(fā)新的驗證碼
????????????????????log.warn("captcha?code?【?{}?】?is?available?now",?existed);
????????????????????return?false;
????????????????}
????????????????//?生成驗證碼并放入緩存
????????????????String?captchaCode?=?captchaCacheStorage.put(phone);
????????????????log.info("captcha:?{}",?captchaCode);
????????????????//todo?這里自行完善調(diào)用第三方短信服務(wù)
????????????????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;
????????????}
????????};
????}
????/**
?????*?自行實現(xiàn)根據(jù)手機號查詢可用的用戶,這里簡單舉例.
?????*?注意該接口可能出現(xiàn)多態(tài)。所以最好加上注解@Qualifier
?????*
?????*?@return?the?user?details?service
?????*/
????@Bean
????@Qualifier("captchaUserDetailsService")
????public?UserDetailsService?captchaUserDetailsService()?{
????????//?驗證碼登陸后密碼無意義了但是需要填充一下
????????return?username?->?User.withUsername(username).password("TEMP")
????????????????//todo??這里權(quán)限?你需要自己注入
????????????????.authorities(AuthorityUtils.createAuthorityList("ROLE_ADMIN",?"ROLE_APP")).build();
????}
????/**
?????*?驗證碼認(rèn)證器.
?????*
?????*?@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);
????}
????/**
?????*?驗證碼認(rèn)證過濾器.
?????*
?????*?@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的過濾器鏈中,這種看了胖哥教程的同學(xué)應(yīng)該非常熟悉了。

**請?zhí)貏e注意:**務(wù)必保證登錄接口和驗證碼接口可以匿名訪問,如果是動態(tài)權(quán)限可以給接口添加?
ROLE_ANONYMOUS?角色。
大功告成,測試如下:

而且原先的登錄方式不受影響,它們可以并存。
4. 總結(jié)
通過對UsernamePasswordAuthenticationFilter和?AuthenticationManager的系統(tǒng)學(xué)習(xí),我們了解了Spring Security認(rèn)證的整個流程,本文是對這兩篇的一個實際運用。相信看到這一篇后你就不會對前幾篇的圖解懵逼了,這也是理論到實踐的一次嘗試。
本文DEMO 可以通過關(guān)注下方公眾號:碼農(nóng)小胖哥?
回復(fù)captcha?獲取,如果有用還請關(guān)注、點贊、轉(zhuǎn)發(fā)給胖哥一個創(chuàng)作的動力。

往期推薦
