Spring Security 解析(四) —— 簡訊登錄開發 在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設 ...
Spring Security 解析(四) —— 簡訊登錄開發
在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程中加強印象和理解所撰寫的,如有侵權請告知。
項目環境:
- JDK1.8
- Spring boot 2.x
- Spring Security 5.x
一、如何在Security的基礎上實現簡訊登錄功能?
回顧下Security實現表單登錄的過程:
從流程中我們發現其在登錄過程中存在特殊處理或者說擁有其他姊妹實現子類的
:
- AuthenticationFilter:用於攔截登錄請求;
- 未認證的Authentication 對象,作為認證方法的入參;
- AuthenticationProvider 進行認證處理。
因此我們可以完全通過自定義 一個 SmsAuthenticationFilter 進行攔截 ,一個 SmsAuthenticationToken 來進行傳輸認證數據,一個 SmsAuthenticationProvider 進行認證業務處理。由於我們知道 UsernamePasswordAuthenticationFilter 的 doFilter 是通過 AbstractAuthenticationProcessingFilter 來實現的,而 UsernamePasswordAuthenticationFilter 本身只實現了attemptAuthentication() 方法。按照這樣的設計,我們的 SmsAuthenticationFilter 也 只實現 attemptAuthentication() 方法,那麼如何進行驗證碼的驗證呢?這時我們需要在 SmsAuthenticationFilter 前 調用 一個 實現驗證碼的驗證過濾 filter :ValidateCodeFilter。整理實現過後的流程如下圖:
二、簡訊登錄認證開發
(一) SmsAuthenticationFilter 實現
模擬UsernamePasswordAuthenticationFilter實現SmsAuthenticationFilter後其代碼如下:
@EqualsAndHashCode(callSuper = true)
@Data
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// 獲取request中傳遞手機號的參數名
private String mobileParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE;
private boolean postOnly = true;
// 構造函數,主要配置其攔截器要攔截的請求地址url
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
// 判斷請求是否為 POST 方式
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// 調用 obtainMobile 方法從request中獲取手機號
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
// 創建 未認證的 SmsCodeAuthenticationToken 對象
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
setDetails(request, authRequest);
// 調用 認證方法
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 獲取手機號
*/
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
/**
* 原封不動照搬UsernamePasswordAuthenticationFilter 的實現 (註意這裡是 SmsCodeAuthenticationToken )
*/
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
/**
* 開放設置 RemmemberMeServices 的set方法
*/
@Override
public void setRememberMeServices(RememberMeServices rememberMeServices) {
super.setRememberMeServices(rememberMeServices);
}
}
其內部實現主要有幾個註意點:
- 設置傳輸手機號的參數屬性
- 構造方法調用父類的有參構造方法,主要用於設置其要攔截的url
- 照搬UsernamePasswordAuthenticationFilter 的 attemptAuthentication() 的實現 ,其內部需要改造有2點:1、 obtainMobile 獲取 手機號信息 2、創建 SmsCodeAuthenticationToken 對象
- 為了實現簡訊登錄也擁有記住我的功能,這裡開放 setRememberMeServices() 方法用於設置 rememberMeServices 。
(二) SmsAuthenticationToken 實現
一樣的我們模擬UsernamePasswordAuthenticationToken實現SmsAuthenticationToken:
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
/**
* 未認證時,內容為手機號
* @param mobile
*/
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
/**
*
* 認證成功後,其中為用戶信息
*
* @param principal
* @param authorities
*/
public SmsCodeAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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();
}
}
對比UsernamePasswordAuthenticationToken,我們減少了 credentials(可以理解為密碼),其他的基本上是原封不動。
(三) SmsAuthenticationProvider 實現
由於SmsCodeAuthenticationProvider 是一個全新的不同的認證委托實現,因此這個我們按照自己的設想寫,不必參照 DaoAuthenticationProvider。看下我們自己實現的代碼:
@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {
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);
}
}
通過直接繼承 AuthenticationProvider實現其介面方法 authenticate() 和 supports() 。 supports() 我們直接參照其他Provider寫的,這個主要是判斷當前處理的Authentication是否為SmsCodeAuthenticationToken或其子類。 authenticate() 我們就直接調用 userDetailsService的loadUserByUsername()方法簡單實現,因為驗證碼已經在 ValidateCodeFilter 驗證通過了,所以這裡我們只要能通過手機號查詢到用戶信息那就直接判頂當前用戶認證成功,並且生成 已認證 的 SmsCodeAuthenticationToken返回。
(四) ValidateCodeFilter 實現
正如我們之前描述的一樣ValidateCodeFilter只做驗證碼的驗證,這裡我們設置通過redis獲取生成驗證碼來對比用戶輸入的驗證碼:
@Component
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
/**
* 驗證碼校驗失敗處理器
*/
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
/**
* 系統配置信息
*/
@Autowired
private SecurityProperties securityProperties;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 存放所有需要校驗驗證碼的url
*/
private Map<String, String> urlMap = new HashMap<>();
/**
* 驗證請求url與配置的url是否匹配的工具類
*/
private AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 初始化要攔截的url配置信息
*/
@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
urlMap.put(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_SMS);
addUrlToMap(securityProperties.getSms().getSendSmsUrl(), SecurityConstants.DEFAULT_PARAMETER_NAME_CODE_SMS);
}
/**
* 講系統中配置的需要校驗驗證碼的URL根據校驗的類型放入map
*
* @param urlString
* @param smsParam
*/
protected void addUrlToMap(String urlString, String smsParam) {
if (StringUtils.isNotBlank(urlString)) {
String[] urls = StringUtils.splitByWholeSeparatorPreserveAllTokens(urlString, ",");
for (String url : urls) {
urlMap.put(url, smsParam);
}
}
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String code = request.getParameter(getValidateCode(request));
if (code != null) {
try {
String oldCode = stringRedisTemplate.opsForValue().get(request.getParameter(SecurityConstants.DEFAULT_PARAMETER_NAME_MOBILE));
if (StringUtils.equalsIgnoreCase(oldCode,code)) {
logger.info("驗證碼校驗通過");
} else {
throw new ValidateCodeException("驗證碼失效或錯誤!");
}
} catch (AuthenticationException e) {
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
chain.doFilter(request, response);
}
/**
* 獲取校驗碼
*
* @param request
* @return
*/
private String getValidateCode(HttpServletRequest request) {
String result = null;
if (!StringUtils.equalsIgnoreCase(request.getMethod(), "get")) {
Set<String> urls = urlMap.keySet();
for (String url : urls) {
if (pathMatcher.match(url, request.getRequestURI())) {
result = urlMap.get(url);
}
}
}
return result;
}
}
這裡主要看下 doFilterInternal 實現驗證碼驗證邏輯即可。
三、如何將設置SMS的Filter加入到FilterChain生效呢?
這裡我們需要引進新的配置類 SmsCodeAuthenticationSecurityConfig,其實現代碼如下:
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler ;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Resource
private UserDetailsService userDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
// 設置 AuthenticationManager
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
// 分別設置成功和失敗處理器
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
// 設置 RememberMeServices
smsCodeAuthenticationFilter.setRememberMeServices(http
.getSharedObject(RememberMeServices.class));
// 創建 SmsCodeAuthenticationProvider 並設置 userDetailsService
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
// 將Provider添加到其中
http.authenticationProvider(smsCodeAuthenticationProvider)
// 將過濾器添加到UsernamePasswordAuthenticationFilter後面
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
最後我們需要 在 SpringSecurityConfig 配置類中引用 SmsCodeAuthenticationSecurityConfig :
http.addFilterBefore(validateCodeFilter, AbstractPreAuthenticatedProcessingFilter.class)
.apply(smsCodeAuthenticationSecurityConfig)
. ...
四、新增發送驗證碼介面和驗證碼登錄表單
新增發送驗證碼介面(主要設置成無許可權訪問):
@GetMapping("/send/sms/{mobile}")
public void sendSms(@PathVariable String mobile) {
// 隨機生成 6 位的數字串
String code = RandomStringUtils.randomNumeric(6);
// 通過 stringRedisTemplate 緩存到redis中
stringRedisTemplate.opsForValue().set(mobile, code, 60 * 5, TimeUnit.SECONDS);
// 模擬發送簡訊驗證碼
log.info("向手機: " + mobile + " 發送簡訊驗證碼是: " + code);
}
新增驗證碼登錄表單:
// 註意這裡的請求介面要與 SmsAuthenticationFilter的構造函數 設置的一致
<form action="/loginByMobile" method="post">
<table>
<tr>
<td>手機號:</td>
<td><input type="text" name="mobile" value="15680659123"></td>
</tr>
<tr>
<td>簡訊驗證碼:</td>
<td>
<input type="text" name="smsCode">
<a href="/send/sms/15680659123">發送驗證碼</a>
</td>
</tr>
<tr>
<td colspan='2'><input name="remember-me" type="checkbox" value="true"/>記住我</td>
</tr>
<tr>
<td colspan="2">
<button type="submit">登錄</button>
</td>
</tr>
</table>
</form>
五、個人總結
其實實現另一種登錄方式,關鍵點就在與 filter 、 AuthenticationToken、AuthenticationProvider 這3個點上。整理出來就是: 通過自定義 一個 SmsAuthenticationFilter 進行攔截 ,一個 AuthenticationToken 來進行傳輸認證數據,一個 AuthenticationProvider 進行認證業務處理。由於我們知道 UsernamePasswordAuthenticationFilter 的 doFilter 是通過 AbstractAuthenticationProcessingFilter 來實現的,而 UsernamePasswordAuthenticationFilter 本身只實現了attemptAuthentication() 方法。按照這樣的設計,我們的 AuthenticationFilter 也 只實現 attemptAuthentication() 方法,但同時需要在 AuthenticationFilter 前 調用 一個 實現驗證過濾 filter :ValidatFilter。 正如下麵的流程圖一樣,可以按照這種方式添加任意一種登錄方式:
本文介紹簡訊登錄開發的代碼可以訪問代碼倉庫中的 security 模塊 ,項目的github 地址 : https://github.com/BUG9/spring-security
如果您對這些感興趣,歡迎star、follow、收藏、轉發給予支持!