一. 前言
學習了SpringSecurity的使用,以及跟著源碼分析了一遍認證流程,掌握了這個登錄認證流程,才能更方便我們做自定義操作。
下面我們來學習下怎么實現多種登錄方式,比如新增加一種郵箱驗證碼登錄的形式,但SpringSecurity默認的Usernamepassword方式不影響。
二. 自定義郵件驗證碼認證
0. 說明
自定義一個郵箱驗證碼的認證,將郵箱號碼作為key,驗證碼作為value存放到Redis中緩存。
1. 回顧
首先回顧下之前源碼分析的認證流程,如下圖:
2. 設計思路
首先前端是填寫郵箱,點擊獲取驗證碼
輸入獲取到的驗證碼,點擊登錄按鈕,發送登錄接口(/emial/login,此處不能使用默認的
/login
,因為我們屬于擴展)自定義過濾器
EmailCodeAuthenticationFilter
(類似UsernamepasswordAuthenticationFilter
),獲取郵箱號碼與驗證碼將郵箱號碼與驗證碼封裝為一個需要認證的自定義
Authentication
對象EmailCodeAuthenticationToken
(類似UsernamepasswordAuthenticationToken
)將
EmailCodeAuthenticationToken
傳給AuthenticationManager
接口的authenticate
方法認證-
因為
AuthenticationManager
的默認實現類為ProviderManager
,而ProviderManager
又是委托給了AuthenticationProvider
,因此自定義一個
AuthenticationProvider
接口的實現類EmailCodeAuthenticationProvider
,實現authenticate
方法認證 認證成功與認證失敗的處理:一種是直接在過濾器
EmailCodeAuthenticationFilter
中重寫successfulAuthentication
和unsuccessfulAuthentication
,另一種是實現AuthenticationSuccessHandler
和AuthenticationFailureHandler
進行處理總歸一句:照貓畫瓢
總結:
需要實現以下幾個類:
- 過濾器EmailCodeAuthenticationFilter
- Authentication對象EmailCodeAuthenticationToken
- AuthenticationProvider類EmailCodeAuthenticationProvider
- 自定義認證成功與認證失敗的Handler
3. 代碼實現
-
自定義Authentication對象(這里是EmailCodeAuthenticationToken)
public class EmailCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; // 郵箱賬號 private final Object principal; // 郵箱驗證碼 private Object credentials; /** * 沒有經過驗證時,權限位空,setAuthenticated設置為不可信令牌 * @param principal * @param credentials */ public EmailCodeAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; this.credentials = credentials; setAuthenticated(false); } /** * 已認證后,將權限加上,setAuthenticated設置為可信令牌 * @param principal * @param credentials * @param authorities */ public EmailCodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); } @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; } }
說明:
模仿UsernamepasswordAuthenticationToken定義,繼承AbstractAuthenticationToken,這里注意的是要定義兩個構造器,分別對應未認證和已認證的Token,已認證的調用
super.setAuthenticated(true);
-
自定義Filter(這里是EmailCodeAuthenticationFilter)
public class EmailCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { // 前端傳來的參數名 private final String SPRING_SECURITY_EMAIL_KEY = "email"; private final String SPRING_SECURITY_EMAIL_CODE_KEY = "email_code"; // 自定義的路徑匹配器,攔截Url為:/email/login private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/email/login", "POST"); // 是否僅POST方式 private boolean postOnly = true; public EmailCodeAuthenticationFilter() { super(DEFAULT_ANT_PATH_REQUEST_MATCHER); } /** * 認證方法,在父類的doFilter中調用 * @param request * @param response * @return * @throws AuthenticationException * @throws IOException * @throws ServletException */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not support : " + request.getMethod()); } System.out.println("email attemptAuthentication"); // 獲取郵箱號碼 String email = obtainEmail(request); email = (email != null) ? email : ""; email = email.trim(); // 獲取郵箱驗證碼 String emailCode = obtainEmailCode(request); emailCode = (emailCode != null) ? emailCode : ""; // 構造Token EmailCodeAuthenticationToken authRequest = new EmailCodeAuthenticationToken(email, emailCode); setDetails(request, authRequest); // 使用AuthenticationManager來進行認證 return this.getAuthenticationManager().authenticate(authRequest); } /** * 獲取請求中email參數 * @param request * @return */ @Nullable protected String obtainEmail(HttpServletRequest request) { return request.getParameter(this.SPRING_SECURITY_EMAIL_KEY); } /** * 獲取請求中驗證碼參數email_code * @param request * @return */ @Nullable protected String obtainEmailCode(HttpServletRequest request) { return request.getParameter(this.SPRING_SECURITY_EMAIL_CODE_KEY); } protected void setDetails(HttpServletRequest request, EmailCodeAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } }
說明:
模仿UsernamepasswordAuthentionFilter實現自定義的過濾器,核心是attemptAuthentication方法.
-
自定義AuthenticationProvider(這里是EmailCodeAuthenticationProvider)
public class EmailCodeAuthenticationProvider implements AuthenticationProvider { protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private EmailCodeUserDetailsService emailCodeUserDetailsService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(EmailCodeAuthenticationToken.class, authentication, () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); // 此時的authentication還沒認證,獲取郵箱號碼 EmailCodeAuthenticationToken unAuthenticationToken = (EmailCodeAuthenticationToken) authentication; // 做校驗 UserDetails user = this.emailCodeUserDetailsService.loadUserByEmail(unAuthenticationToken); if (user == null) { throw new InternalAuthenticationServiceException("EmailCodeUserDetailsService returned null, which is an interface contract violation"); } System.out.println("authentication successful!"); Object principalToReturn = user; return createSuccessAuthentication(principalToReturn, authentication, user); } @Override public boolean supports(Class<?> authentication) { return EmailCodeAuthenticationToken.class.isAssignableFrom(authentication); } protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { EmailCodeAuthenticationToken result = new EmailCodeAuthenticationToken(principal, authentication.getCredentials(), user.getAuthorities()); result.setDetails(authentication.getDetails()); return result; } public void setEmailCodeUserDetailsService(EmailCodeUserDetailsService emailCodeUserDetailsService) { this.emailCodeUserDetailsService = emailCodeUserDetailsService; } }
說明:
Provider是真正做認證的地方,這里調用emailCodeUserDetailsService服務去執行驗證,因為要用到這個Service,所以提供了一個set方法setEmailCodeUserDetailsService用于注入。這里的這個service是我們自定義的,可以不用實現UserDetailsService, Service里的邏輯可以自定義
-
自定義認證成功與失敗的Handler
public class EmailCodeAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("text/plain;charset=UTF-8"); response.getWriter().write(authentication.getName()); } } public class EmailCodeAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setContentType("text/plain;charset=UTF-8"); response.getWriter().write("郵箱驗證碼錯誤!"); } }
說明:
這里的是認證成功或失敗后的處理,需要實現對應的接口以及方法。這里的邏輯只是簡單測試,具體邏輯以后根據業務邏輯去編寫。
-
添加自定義認證的配置
為了讓我們自定義的認證生效,需要將我們的Filter和Provider加入到SpringSecurity的配置中。這里我們使用
apply
這個方法將其他一些配置合并到SpringSecurity的配置中,形成插件化。比如:httpSecurity.apply(new xxxxConfig());
因此我們可以將我們的配置單獨放到一個配置類中。
public class EmailCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { // 注入email驗證服務 @Autowired private EmailCodeUserDetailsService emailCodeUserDetailsService; @Override public void configure(HttpSecurity http) { // 配置Filter EmailCodeAuthenticationFilter emailCodeAuthenticationFilter = new EmailCodeAuthenticationFilter(); // 設置AuthenticationManager emailCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); // 設置認證成功處理Handler emailCodeAuthenticationFilter.setAuthenticationSuccessHandler(new EmailCodeAuthenticationSuccessHandler()); // 設置認證失敗處理Handler emailCodeAuthenticationFilter.setAuthenticationFailureHandler(new EmailCodeAuthenticationFailureHandler()); // 配置Provider EmailCodeAuthenticationProvider emailCodeAuthenticationProvider = new EmailCodeAuthenticationProvider(); // 設置email驗證服務 emailCodeAuthenticationProvider.setEmailCodeUserDetailsService(emailCodeUserDetailsService); // 將過濾器添加到過濾器鏈路中 http.authenticationProvider(emailCodeAuthenticationProvider).addFilterAfter(emailCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
注意:
這里需要注意的是,一定要將
AuthenticationManager
提供給Filter,如果沒有這一步,那么在Filter中進行認證的時候無法找到對應的Provider,因為AuthenticationManger就是管理Provider的。
http.getSharedObject(AuthenticationManager.class)
解釋:SharedObject
是在配置中進行共享的一些對象,HttpSecurity共享了一些非常有用的對象可以供外部使用,比如AuthenticationManager
最后在SpringSecurity的主配置中加入我們的自定義配置:
@Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private EmailCodeAuthenticationSecurityConfig emailCodeAuthenticationSecurityConfig; @Autowired private DefaultUserDetailsService defaultUserDetailsService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override protected AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(defaultUserDetailsService); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/getEmailCode", "/**/*.html"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/login") .permitAll() .and() .logout() .logoutUrl("/logout") .and() .apply(emailCodeAuthenticationSecurityConfig) .and() .csrf() .disable(); } }
說明:
因為這里使用了數據庫保存用戶信息,所以在SpringSecurity的默認表單登錄里,修改了UserDetailService,在這里進行校驗,所以在主配置中要設置UserDetailService:
auth.userDetailsService(defaultUserDetailsService);
-
其他一些文件
查看我上傳的gitee源碼吧,整個工程都上傳了。
-
前端頁面實現
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>登錄</title> <!-- 最新版本的 Bootstrap 核心 CSS 文件 --> <link rel="stylesheet" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous"> <script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script> <!-- 最新的 Bootstrap 核心 JavaScript 文件 --> <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script> <style> body { background-color: gray; } .login-div { width: 400px; /* height: 200px; */ margin: 0 auto; margin-top: 200px; border: 1px solid black; padding: 10px; } </style> </head> <body> <div class="login-div"> <ul class="nav nav-tabs" role="tablist"> <li class="active"> <a href="#usernameLogin" data-toggle="tab">用戶名登錄</a> </li> <li> <a href="#emailLogin" data-toggle="tab">郵箱驗證碼登錄</a> </li> </ul> <!-- 用戶名登錄 --> <div class="tab-content"> <div class="tab-pane active" id="usernameLogin"> <form action="/login" method="POST"> <div class="form-group"> <label>用戶名</label> <input type="text" class="form-control" placeholder="Username" name="username"> </div> <div class="form-group"> <label>密碼</label> <input type="password" class="form-control" placeholder="Password" name="password"> </div> <div class="checkbox"> <label> <input type="checkbox" name="rememberType"> 記住我 </label> </div> <button type="submit" class="btn btn-default">登錄</button> </form> </div> <!-- 郵箱登錄 --> <div class="tab-pane" id="emailLogin"> <form action="/email/login" method="POST"> <div class="form-group" > <label>郵箱地址</label> <input type="email" class="form-control" placeholder="Email" name="email" id="email"> </div> <div class="form-group"> <label>驗證碼</label> <input type="text" class="form-control" placeholder="Code" name="email_code"> </div> <div class="form-group"> <label> <button type="button" class="btn btn-default" id="getCode">獲取驗證碼</button> <span id="showCode" style="margin-left: 20px;"></span> </label> </div> <button type="submit" class="btn btn-default">登錄</button> </form> </div> </div> </div> <script> $('#nav a').on('click', function(e) { e.preventDefault(); $(this).tab('show'); }); $('#getCode').on('click', function() { $.ajax({ type: "GET", url: "/getEmailCode", data: { email: $('#email').val() }, // dataType: "dataType", success: function (response) { $('#showCode').text(response); } }); }); </script> </body> </html>
說明:
前端頁面只是簡單的顯示使用兩種方式來登錄的操作,一些輸入校驗什么的沒有詳細實現,所以這里默認各位大佬都是正常操作哈。
這個前端支持兩種登錄方式,用戶名密碼登錄方式使用的SpringSecurity默認的UsernamepasswordAuthenticationFilter,郵箱驗證碼使用的是自定義的EmailCodeAuthenticationFilter,在郵箱登錄頁面,點擊獲取驗證碼按鈕,會請求服務器獲取一個隨機的字符串作為驗證碼,并且存入Redis中,有效期60s(記住我功能在這里沒有實現)
demo-emailLogin.jpg
demo-usernameLogin.jpg -
數據庫操作
因為目前只是自定義認證,不涉及授權,所以只有一個用戶表
CREATE TABLE `user` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) DEFAULT NULL, `password` VARCHAR(255) DEFAULT NULL, `email` VARCHAR(255) DEFAULT NULL, `enabled` TINYINT(1) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=INNODB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8; INSERT INTO `user` VALUES ('1', 'root', '$2a$10$RMuFXGQ5AtH4wOvkUqyvuecpqUSeoxZYqilXzbz50dceRsga.WYiq','123456@qq.com', '1');
隨便插入一個用戶,密碼是123,數據庫的是經過加密的。