Spring Security 實戰乾貨:玩轉自定義登入

碼農小胖哥發表於2019-10-17

Spring Security 實戰乾貨:玩轉自定義登入

1. 前言

前面的關於 Spring Security 相關的文章只是一個預熱。為了接下來更好的實戰,如果你錯過了請從 Spring Security 實戰系列 開始。安全訪問的第一步就是認證(Authentication),認證的第一步就是登入。今天我們要通過對 Spring Security 的自定義,來設計一個可擴充套件,可伸縮的 form 登入功能。

2. form 登入的流程

下面是 form 登入的基本流程:

Spring Security 實戰乾貨:玩轉自定義登入

只要是 form 登入基本都能轉化為上面的流程。接下來我們看看 Spring Security 是如何處理的。

3. Spring Security 中的登入

昨天 Spring Security 實戰乾貨:自定義配置類入口WebSecurityConfigurerAdapter 中已經講到了我們通常的自定義訪問控制主要是通過 HttpSecurity 來構建的。預設它提供了三種登入方式:

  • formLogin() 普通表單登入
  • oauth2Login() 基於 OAuth2.0 認證/授權協議
  • openidLogin() 基於 OpenID 身份認證規範

以上三種方式統統是 AbstractAuthenticationFilterConfigurer 實現的,

4. HttpSecurity 中的 form 表單登入

啟用表單登入通過兩種方式一種是通過 HttpSecurityapply(C configurer) 方法自己構造一個 AbstractAuthenticationFilterConfigurer 的實現,這種是比較高階的玩法。 另一種是我們常見的使用 HttpSecurityformLogin() 方法來自定義 FormLoginConfigurer 。我們先搞一下比較常規的第二種。

4.1 FormLoginConfigurer

該類是 form 表單登入的配置類。它提供了一些我們常用的配置方法:

  • loginPage(String loginPage) : 登入 頁面而並不是介面,對於前後分離模式需要我們進行改造 預設為 /login
  • loginProcessingUrl(String loginProcessingUrl) 實際表單向後臺提交使用者資訊的 Action,再由過濾器UsernamePasswordAuthenticationFilter 攔截處理,該 Action 其實不會處理任何邏輯。
  • usernameParameter(String usernameParameter) 用來自定義使用者引數名,預設 username
  • passwordParameter(String passwordParameter) 用來自定義使用者密碼名,預設 password
  • failureUrl(String authenticationFailureUrl) 登入失敗後會重定向到此路徑, 一般前後分離不會使用它。
  • failureForwardUrl(String forwardUrl) 登入失敗會轉發到此, 一般前後分離用到它。 可定義一個 Controller (控制器)來處理返回值,但是要注意 RequestMethod
  • defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) 預設登陸成功後跳轉到此 ,如果 alwaysUsetrue 只要進行認證流程而且成功,會一直跳轉到此。一般推薦預設值 false
  • successForwardUrl(String forwardUrl) 效果等同於上面 defaultSuccessUrlalwaysUsetrue 但是要注意 RequestMethod
  • successHandler(AuthenticationSuccessHandler successHandler) 自定義認證成功處理器,可替代上面所有的 success 方式
  • failureHandler(AuthenticationFailureHandler authenticationFailureHandler) 自定義失敗成功處理器,可替代上面所有的 failure 方式
  • permitAll(boolean permitAll) form 表單登入是否放開

知道了這些我們就能來搞個定製化的登入了。

5. Spring Security 聚合登入 實戰

接下來是我們最激動人心的實戰登入操作。 有疑問的可認真閱讀 Spring 實戰 的一系列預熱文章。

5.1 簡單需求

我們的介面訪問都要通過認證,登陸錯誤後返回錯誤資訊(json),成功後前臺可以獲取到對應資料庫使用者資訊(json)(實戰中記得脫敏)。

我們定義處理成功失敗的控制器:

 @RestController
 @RequestMapping("/login")
 public class LoginController {
     @Resource
     private SysUserService sysUserService;
 
     /**
      * 登入失敗返回 401 以及提示資訊.
      *
      * @return the rest
      */
     @PostMapping("/failure")
     public Rest loginFailure() {
 
         return RestBody.failure(HttpStatus.UNAUTHORIZED.value(), "登入失敗了,老哥");
     }
 
     /**
      * 登入成功後拿到個人資訊.
      *
      * @return the rest
      */
     @PostMapping("/success")
     public Rest loginSuccess() {
           // 登入成功後使用者的認證資訊 UserDetails會存在 安全上下文暫存器 SecurityContextHolder 中
         User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
         String username = principal.getUsername();
         SysUser sysUser = sysUserService.queryByUsername(username);
         // 脫敏
         sysUser.setEncodePassword("[PROTECT]");
         return RestBody.okData(sysUser,"登入成功");
     }
 }
複製程式碼

然後 我們自定義配置覆寫 void configure(HttpSecurity http) 方法進行如下配置(這裡需要禁用crsf):

 @Configuration
 @ConditionalOnClass(WebSecurityConfigurerAdapter.class)
 @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
 public class CustomSpringBootWebSecurityConfiguration {
 
     @Configuration
     @Order(SecurityProperties.BASIC_AUTH_ORDER)
     static class DefaultConfigurerAdapter extends WebSecurityConfigurerAdapter {
         @Override
         protected void configure(AuthenticationManagerBuilder auth) throws Exception {
             super.configure(auth);
         }
 
         @Override
         public void configure(WebSecurity web) throws Exception {
             super.configure(web);
         }
 
         @Override
         protected void configure(HttpSecurity http) throws Exception {
             http.csrf().disable()
                     .cors()
                     .and()
                     .authorizeRequests().anyRequest().authenticated()
                     .and()
                     .formLogin()
                     .loginProcessingUrl("/process")
                     .successForwardUrl("/login/success").
                     failureForwardUrl("/login/failure");
 
         }
     }
 }
複製程式碼

使用 Postman 或者其它工具進行 Post 方式的表單提交 http://localhost:8080/process?username=Felordcn&password=12345 會返回使用者資訊:

 {
     "httpStatus": 200,
     "data": {
         "userId": 1,
         "username": "Felordcn",
         "encodePassword": "[PROTECT]",
         "age": 18
     },
     "msg": "登入成功",
     "identifier": ""
 }
複製程式碼

把密碼修改為其它值再次請求認證失敗後 :

  {
      "httpStatus": 401,
      "data": null,
      "msg": "登入失敗了,老哥",
      "identifier": "-9999"
  }
複製程式碼

6. 多種登入方式的簡單實現

就這麼完了了麼?現在登入的花樣繁多。常規的就有簡訊、郵箱、掃碼 ,第三方是以後我要講的不在今天範圍之內。 如何應對想法多的產品經理? 我們來搞一個可擴充套件各種姿勢的登入方式。我們在上面 2. form 登入的流程 中的 使用者判定 之間增加一個介面卡來適配即可。 我們知道這個所謂的 判定就是 UsernamePasswordAuthenticationFilter

我們只需要保證 uri 為上面配置的/process 並且能夠通過 getParameter(String name) 獲取使用者名稱和密碼即可

我突然覺得可以模仿 DelegatingPasswordEncoder 的搞法, 維護一個登錄檔執行不同的處理策略。當然我們要實現一個 GenericFilterBeanUsernamePasswordAuthenticationFilter 之前執行。同時制定登入的策略。

6.1 登入方式定義

定義登入方式列舉 ``。


  public enum LoginTypeEnum {
  
      /**
       * 原始登入方式.
       */
      FORM,
      /**
       * Json 提交.
       */
      JSON,
      /**
       * 驗證碼.
       */
      CAPTCHA
  
  }
複製程式碼

6.2 定義前置處理器介面

定義前置處理器介面用來處理接收的各種特色的登入引數 並處理具體的邏輯。這個藉口其實有點隨意 ,重要的是你要學會思路。我實現了一個 預設的 form' 表單登入 和 通過RequestBody放入json` 的兩種方式,篇幅限制這裡就不展示了。具體的 DEMO 參見底部。

   public interface LoginPostProcessor {
   
   
   
       /**
        * 獲取 登入型別
        *
        * @return the type
        */
       LoginTypeEnum getLoginTypeEnum();
   
       /**
        * 獲取使用者名稱
        *
        * @param request the request
        * @return the string
        */
       String obtainUsername(ServletRequest request);
   
       /**
        * 獲取密碼
        *
        * @param request the request
        * @return the string
        */
       String obtainPassword(ServletRequest request);
   
   }
複製程式碼

6.3 實現登入前置處理過濾器

該過濾器維護了 LoginPostProcessor 對映表。 通過前端來判定登入方式進行策略上的預處理,最終還是會交給 UsernamePasswordAuthenticationFilter 。通過 HttpSecurityaddFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)方法進行前置。

 package cn.felord.spring.security.filter;
 
 import cn.felord.spring.security.enumation.LoginTypeEnum;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.CollectionUtils;
 import org.springframework.web.filter.GenericFilterBean;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
 import javax.servlet.ServletRequest;
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY;
 import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
 
 /**
  * 預登入控制器
  *
  * @author Felordcn
  * @since 16 :21 2019/10/17
  */
 public class PreLoginFilter extends GenericFilterBean {
 
 
     private static final String LOGIN_TYPE_KEY = "login_type";
 
 
     private RequestMatcher requiresAuthenticationRequestMatcher;
     private Map<LoginTypeEnum, LoginPostProcessor> processors = new HashMap<>();
 
 
     public PreLoginFilter(String loginProcessingUrl, Collection<LoginPostProcessor> loginPostProcessors) {
         Assert.notNull(loginProcessingUrl, "loginProcessingUrl must not be null");
         requiresAuthenticationRequestMatcher = new AntPathRequestMatcher(loginProcessingUrl, "POST");
         LoginPostProcessor loginPostProcessor = defaultLoginPostProcessor();
         processors.put(loginPostProcessor.getLoginTypeEnum(), loginPostProcessor);
 
         if (!CollectionUtils.isEmpty(loginPostProcessors)) {
             loginPostProcessors.forEach(element -> processors.put(element.getLoginTypeEnum(), element));
         }
 
     }
 
 
     private LoginTypeEnum getTypeFromReq(ServletRequest request) {
         String parameter = request.getParameter(LOGIN_TYPE_KEY);
 
         int i = Integer.parseInt(parameter);
         LoginTypeEnum[] values = LoginTypeEnum.values();
         return values[i];
     }
 
 
     /**
      * 預設還是Form .
      *
      * @return the login post processor
      */
     private LoginPostProcessor defaultLoginPostProcessor() {
         return new LoginPostProcessor() {
 
 
             @Override
             public LoginTypeEnum getLoginTypeEnum() {
 
                 return LoginTypeEnum.FORM;
             }
 
             @Override
             public String obtainUsername(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_USERNAME_KEY);
             }
 
             @Override
             public String obtainPassword(ServletRequest request) {
                 return request.getParameter(SPRING_SECURITY_FORM_PASSWORD_KEY);
             }
         };
     }
 
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         ParameterRequestWrapper parameterRequestWrapper = new ParameterRequestWrapper((HttpServletRequest) request);
         if (requiresAuthenticationRequestMatcher.matches((HttpServletRequest) request)) {
 
             LoginTypeEnum typeFromReq = getTypeFromReq(request);
 
             LoginPostProcessor loginPostProcessor = processors.get(typeFromReq);
 
 
             String username = loginPostProcessor.obtainUsername(request);
 
             String password = loginPostProcessor.obtainPassword(request);
 
 
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_USERNAME_KEY, username);
             parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_PASSWORD_KEY, password);
 
         }
 
         chain.doFilter(parameterRequestWrapper, response);
 
 
     }
 }
複製程式碼

6.4 驗證

通過 POST 表單提交方式 http://localhost:8080/process?username=Felordcn&password=12345&login_type=0 可以請求成功。或者以下列方式也可以提交成功:

Spring Security 實戰乾貨:玩轉自定義登入

更多的方式 只需要實現介面 LoginPostProcessor 注入 PreLoginFilter

7. 總結

今天我們通過各種技術的運用實現了從簡單登入到可動態擴充套件的多種方式並存的實戰運用。相信對你來說會有不小的收貨 ,本次 **程式碼DEMO可通過關注公眾號:Felordcn 回覆 ss03 獲取,後面會更加精彩。

關注公眾號:Felordcn獲取更多資訊

個人部落格:https://felord.cn

Spring Security 實戰乾貨:玩轉自定義登入

相關文章