Spring Security 實現多種登錄方式(常規方式外的郵件、手機驗證碼登錄)
不知道, 你在用Spring Security的時候,有沒有想過,用它實現多種登錄方式勒,這次我的小夥伴就給我提瞭一些登錄方面的需求,需要在原有賬號密碼登錄的基礎上,另外實現電話驗證碼以及郵件驗證碼登錄,以及在實現之後,讓我能夠做到實現第三方登錄,如gitee、github等。
本文主要是講解Security在實現賬號密碼的基礎上,並且不改變原有業務情況下,實現郵件、電話驗證碼登錄。
前言:
上一篇文章我寫瞭 Security登錄詳細流程詳解有源碼有分析。掌握這個登錄流程,我們才能更好的做Security的定制操作。
我在寫這篇文章之前,也看過很多博主的文章,寫的非常好,有對源碼方面的解析,也有對一些相關設計理念的理解的文章。
這對於已經學過一段時間,並且對Security已經有瞭解的小夥伴來說,還是比較合適的,但是對於我以及其他一些急於解決當下問題的小白,並不是那麼友善。😂
一、🤸♂️理論知識
我們先思考一下這個流程大致是如何的?
- 填寫郵件號碼,獲取驗證碼
- 輸入獲取到的驗證碼進行登錄(登錄的接口:/email/login,這裡不能使用默認的/login,因為我們是擴展)
- 在自定義的過濾器 EmailCodeAuthenticationFilter 中獲取發送過來的郵件號碼及驗證碼,判斷驗證碼是否正確,郵件賬號是否為空等
- 封裝成一個需要認證的 Authentication ,此處我們自定義實現為 EmailCodeAuthenticationToken。
- 將 Authentiction 傳給 AuthenticationManager 接口中 authenticate 方法進行認證處理
- AuthenticationManager 默認是實現類為 ProviderManager ,ProviderManager 又委托給 AuthenticationProvider 進行處理
- 我們自定義一個 EmailCodeAuthenticationProvider 實現 AuthenticationProvider ,實現身份驗證。
- 自定義的 EmailCodeAuthenticationFilter 繼承瞭 AbstractAuthenticationProcessingFilter 抽象類, AbstractAuthenticationProcessingFilter 在 successfulAuthentication 方法中對登錄成功進行瞭處理,通過 SecurityContextHolder.getContext().setAuthentication() 方法將 Authentication 認證信息對象綁定到 SecurityContext即安全上下文中。
- 其實對於身份驗證通過後的處理,有兩種方案,一種是直接在過濾器重寫successfulAuthentication,另外一種就是實現AuthenticationSuccessHandler來處理身份驗證通過。
- 身份驗證失敗也是一樣,可重寫unsuccessfulAuthentication方法,也可以實現 AuthenticationFailureHandler來對身份驗證失敗進行處理。
大致流程就是如此。從這個流程中我們可以知道,需要重寫的組件有以下幾個:
- EmailCodeAuthenticationFilter:郵件驗證登錄過濾器
- EmailCodeAuthenticationToken:身份驗證令牌
- EmailCodeAuthenticationProvider:郵件身份認證處理
- AuthenticationSuccessHandler:處理登錄成功操作
- AuthenticationFailureHandler:處理登錄失敗操作
接下來,我是模仿著源碼寫出我的代碼,建議大傢可以在使用的時候,多去看看,我這裡去除瞭一些不是和這個相關的代碼。
來吧!!
二、EmailCodeAuthenticationFilter
我們需要重寫的 EmailCodeAuthenticationFilter,實際繼承瞭AbstractAuthenticationProcessingFilter抽象類,我們不會寫,可以先看看它的默認實現UsernamePasswordAuthenticationFilter是怎麼樣的嗎,抄作業這是大傢的強項的哈。
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST"); //從前臺傳過來的參數 private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; private boolean postOnly = true; // 初始化一個用戶密碼 認證過濾器 默認的登錄uri 是 /login 請求方式是POST public UsernamePasswordAuthenticationFilter() { super(DEFAULT_ANT_PATH_REQUEST_MATCHER); } public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) { super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager); } /** 執行實際身份驗證。實現應執行以下操作之一: 1、為經過身份驗證的用戶返回填充的身份驗證令牌,表示身份驗證成功 2、返回null,表示認證過程還在進行中。 在返回之前,實現應該執行完成流程所需的任何額外工作。 3、如果身份驗證過程失敗,則拋出AuthenticationException */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); username = (username != null) ? username : ""; username = username.trim(); String password = obtainPassword(request); password = (password != null) ? password : ""; //生成 UsernamePasswordAuthenticationToken 稍後交由AuthenticationManager中的authenticate進行認證 UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); // 可以放一些其他信息進去 setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } @Nullable protected String obtainPassword(HttpServletRequest request) { return request.getParameter(this.passwordParameter); } @Nullable protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.usernameParameter); } protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } //set、get方法 }
接下來我們就抄個作業哈:
package com.crush.security.auth.email_code; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; 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.security.web.util.matcher.RequestMatcher; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.ArrayList; /** * @Author: crush * @Date: 2021-09-08 21:13 * version 1.0 */ public class EmailCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { /** * 前端傳來的 參數名 - 用於request.getParameter 獲取 */ private final String DEFAULT_EMAIL_NAME="email"; private final String DEFAULT_EMAIL_CODE="e_code"; @Autowired @Override public void setAuthenticationManager(AuthenticationManager authenticationManager) { super.setAuthenticationManager(authenticationManager); } /** * 是否 僅僅post方式 */ private boolean postOnly = true; /** * 通過 傳入的 參數 創建 匹配器 * 即 Filter過濾的url */ public EmailCodeAuthenticationFilter() { super(new AntPathRequestMatcher("/email/login","POST")); } /** * filter 獲得 用戶名(郵箱) 和 密碼(驗證碼) 裝配到 token 上 , * 然後把token 交給 provider 進行授權 */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if(postOnly && !request.getMethod().equals("POST") ){ throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); }else{ String email = getEmail(request); if(email == null){ email = ""; } email = email.trim(); //如果 驗證碼不相等 故意讓token出錯 然後走springsecurity 錯誤的流程 boolean flag = checkCode(request); //封裝 token EmailCodeAuthenticationToken token = new EmailCodeAuthenticationToken(email,new ArrayList<>()); this.setDetails(request,token); //交給 manager 發證 return this.getAuthenticationManager().authenticate(token); } } /** * 獲取 頭部信息 讓合適的provider 來驗證他 */ public void setDetails(HttpServletRequest request , EmailCodeAuthenticationToken token ){ token.setDetails(this.authenticationDetailsSource.buildDetails(request)); } /** * 獲取 傳來 的Email信息 */ public String getEmail(HttpServletRequest request ){ String result= request.getParameter(DEFAULT_EMAIL_NAME); return result; } /** * 判斷 傳來的 驗證碼信息 以及 session 中的驗證碼信息 */ public boolean checkCode(HttpServletRequest request ){ String code1 = request.getParameter(DEFAULT_EMAIL_CODE); System.out.println("code1**********"+code1); // TODO 另外再寫一個鏈接 生成 驗證碼 那個驗證碼 在生成的時候 存進redis 中去 //TODO 這裡的驗證碼 寫在Redis中, 到時候取出來判斷即可 驗證之後 刪除驗證碼 if(code1.equals("123456")){ return true; } return false; } // set、get方法... }
三、EmailCodeAuthenticationToken
我們EmailCodeAuthenticationToken是繼承AbstractAuthenticationToken的,按照同樣的方式,我們接著去看看AbstractAuthenticationToken的默認實現是什麼樣的就行瞭。
/** */ public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; // 這裡指的賬號密碼哈 private final Object principal; private Object credentials; /** 沒經過身份驗證時,初始化權限為空,setAuthenticated(false)設置為不可信令牌 */ public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; this.credentials = credentials; setAuthenticated(false); } /** 經過身份驗證後,將權限放進去,setAuthenticated(true)設置為可信令牌 */ public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); // must use super, as we override } @Override public Object getCredentials() { return this.credentials; } @Override public Object getPrincipal() { return this.principal; } @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); super.setAuthenticated(false); } @Override public void eraseCredentials() { super.eraseCredentials(); this.credentials = null; } }
日常抄作業哈:
/** * @Author: crush * @Date: 2021-09-08 21:13 * version 1.0 */ public class EmailCodeAuthenticationToken extends AbstractAuthenticationToken { /** * 這裡的 principal 指的是 email 地址(未認證的時候) */ private final Object principal; public EmailCodeAuthenticationToken(Object principal) { super((Collection) null); this.principal = principal; setAuthenticated(false); } public EmailCodeAuthenticationToken(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"); } else { super.setAuthenticated(false); } } }
這個很簡單的哈
四、EmailCodeAuthenticationProvider
自定義的EmailCodeAuthenticationProvider是實現瞭AuthenticationProvider接口,抄作業就得學會看看源碼。我們接著來。
4.1、先看看AbstractUserDetailsAuthenticationProvider,我們再來模仿
AuthenticationProvider 接口有很多實現類,不一一說明瞭,直接看我們需要看的AbstractUserDetailsAuthenticationProvider, 該類旨在響應 UsernamePasswordAuthenticationToken 身份驗證請求。但是它是一個抽象類,但其實就一個步驟在它的實現類中實現的,很簡單,稍後會講到。
在這個源碼中我把和檢查相關的一些操作都給刪除,隻留下幾個重點,我們一起來看一看哈。
//該類旨在響應UsernamePasswordAuthenticationToken身份驗證請求。 public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware { protected final Log logger = LogFactory.getLog(getClass()); private UserCache userCache = new NullUserCache(); @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); //獲取用戶名 String username = determineUsername(authentication); //判斷緩存中是否存在 boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { // 緩存中沒有 通過字類實現的retrieveUser 從數據庫進行檢索,返回一個 UserDetails 對象 user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException ex) { this.logger.debug("Failed to find user '" + username + "'"); if (!this.hideUserNotFoundExceptions) { throw ex; } throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { //進行相關檢查 因為可能是從緩存中取出來的 並非是最新的 this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException ex) { if (!cacheWasUsed) { throw ex; } // 沒有通過檢查, 重新檢索最新的數據 cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } // 再次進行檢查 this.postAuthenticationChecks.check(user); // 存進緩存中去 if (!cacheWasUsed) { this.userCache.putUserInCache(user); } Object principalToReturn = user; if (this.forcePrincipalAsString) { principalToReturn = user.getUsername(); } //創建一個可信的身份令牌返回 return createSuccessAuthentication(principalToReturn, authentication, user); } private String determineUsername(Authentication authentication) { return (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); } /** 簡而言之就是創建瞭一個通過身份驗證的UsernamePasswordAuthenticationToken */ protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities())); result.setDetails(authentication.getDetails()); this.logger.debug("Authenticated user"); return result; } /** 允許子類從特定於實現的位置實際檢索UserDetails ,如果提供的憑據不正確,則可以選擇立即拋出AuthenticationException (如果需要以用戶身份綁定到資源以獲得或生成一個UserDetails ) */ protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException; //... //簡而言之:當然有時候我們有多個不同的 `AuthenticationProvider`,它們分別支持不同的 `Authentication`對象,那麼當一個具體的 `AuthenticationProvier`傳進入 `ProviderManager`的內部時,就會在 `AuthenticationProvider`列表中挑選其對應支持的provider對相應的 Authentication對象進行驗證 @Override public boolean supports(Class<?> authentication) { return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)); } }
關於 protected abstract UserDetails retrieveUser 的實現,AbstractUserDetailsAuthenticationProvider實現是DaoAuthenticationProvider.
DaoAuthenticationProvider主要操作是兩個,第一個是從數據庫中檢索出相關信息,第二個是給檢索出的用戶信息進行密碼的加密操作。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { private UserDetailsService userDetailsService; @Override protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { // 檢索用戶,一般我們都會實現 UserDetailsService接口,改為從數據庫中檢索用戶信息 返回安全核心類 UserDetails UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation"); } return loadedUser; } catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; } catch (InternalAuthenticationServiceException ex) { throw ex; } catch (Exception ex) { throw new InternalAuthenticationServiceException(ex.getMessage(), ex); } } @Override protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { // 判斷是否用瞭密碼加密 針對這個點 沒有深入 大傢好奇可以去查一查這個知識點 boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword()); if (upgradeEncoding) { String presentedPassword = authentication.getCredentials().toString(); String newPassword = this.passwordEncoder.encode(presentedPassword); user = this.userDetailsPasswordService.updatePassword(user, newPassword); } return super.createSuccessAuthentication(principal, authentication, user); } }
4.2、抄作業啦
看完源碼,其實我們如果要重寫的話,主要要做到以下幾個事情:
重寫public boolean supports(Class<?> authentication)方法。
有時候我們有多個不同的 AuthenticationProvider,它們分別支持不同的 Authentication對象,那麼當一個具體的 AuthenticationProvier 傳進入 ProviderManager的內部時,就會在 AuthenticationProvider列表中挑選其對應支持的 provider 對相應的 Authentication對象進行驗證
簡單說就是指定AuthenticationProvider驗證哪個 Authentication 對象。如指定DaoAuthenticationProvider認證UsernamePasswordAuthenticationToken,
所以我們指定EmailCodeAuthenticationProvider認證EmailCodeAuthenticationToken。
檢索數據庫,返回一個安全核心類UserDetail。
創建一個經過身份驗證的Authentication對象
瞭解要做什麼事情瞭,我們就可以動手看看代碼啦。
/** * @Author: crush * @Date: 2021-09-08 21:14 * version 1.0 */ @Slf4j public class EmailCodeAuthenticationProvider implements AuthenticationProvider { ITbUserService userService; public EmailCodeAuthenticationProvider(ITbUserService userService) { this.userService = userService; } /** * 認證 */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!supports(authentication.getClass())) { return null; } log.info("EmailCodeAuthentication authentication request: %s", authentication); EmailCodeAuthenticationToken token = (EmailCodeAuthenticationToken) authentication; UserDetails user = userService.getByEmail((String) token.getPrincipal()); System.out.println(token.getPrincipal()); if (user == null) { throw new InternalAuthenticationServiceException("無法獲取用戶信息"); } System.out.println(user.getAuthorities()); EmailCodeAuthenticationToken result = new EmailCodeAuthenticationToken(user, user.getAuthorities()); /* Details 中包含瞭 ip地址、 sessionId 等等屬性 也可以存儲一些自己想要放進去的內容 */ result.setDetails(token.getDetails()); return result; } @Override public boolean supports(Class<?> aClass) { return EmailCodeAuthenticationToken.class.isAssignableFrom(aClass); } }
五、在配置類中進行配置
主要就是做下面幾件事:將過濾器、認證器註入到spring中
將登錄成功處理、登錄失敗處理器註入到Spring中,或者在自定義過濾器中對登錄成功和失敗進行處理。
添加到過濾鏈中
@Bean public EmailCodeAuthenticationFilter emailCodeAuthenticationFilter() { EmailCodeAuthenticationFilter emailCodeAuthenticationFilter = new EmailCodeAuthenticationFilter(); emailCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); emailCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); return emailCodeAuthenticationFilter; } @Bean public EmailCodeAuthenticationProvider emailCodeAuthenticationProvider() { return new EmailCodeAuthenticationProvider(userService); } /** * 因為使用瞭BCryptPasswordEncoder來進行密碼的加密,所以身份驗證的時候也的用他來判斷哈、, * * @param auth * @throws Exception */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder()); //authenticationProvider 根據傳入的自定義AuthenticationProvider添加身份AuthenticationProvider 。 auth.authenticationProvider(emailCodeAuthenticationProvider()); }
.and() .authenticationProvider(emailCodeAuthenticationProvider()) .addFilterBefore(emailCodeAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class) .authenticationProvider(mobileCodeAuthenticationProvider()) .addFilterBefore(mobileCodeAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class)
六、測試及源代碼
項目具體的配置、啟動方式、環境等、都在github及gitee的文檔上有詳細說明。
源代碼中包含sql文件、配置文件以及相關博客鏈接,源代碼中也加瞭很多註釋,盡最大程度讓大傢能夠看明白。
在最大程度上保證大傢都能正確的運行及測試。
源碼:gitee-Security
七、自言自語
如果這篇存在不太懂的內容,可以先看我的另一篇文章:
SpringBoot集成Security實現安全控制,使用Jwt制作Token令牌。
之後再回過頭來看這一篇文章,應該會更加容易理解。
到此這篇關於Spring Security 實現多種登錄方式(常規方式外的郵件、手機驗證碼登錄)的文章就介紹到這瞭,更多相關SpringSecurity 登錄 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Spring Security認證的完整流程記錄
- Security 登錄認證流程詳細分析詳解
- Spring Security 實現用戶名密碼登錄流程源碼詳解
- Spring Security OAuth 自定義授權方式實現手機驗證碼
- spring security 自定義Provider 如何實現多種認證