2. SpringSecurity 自定義表單登錄

在上一篇文章的結尾,我們列入了默認使用 SpringSecurity 一些待優化和解決的問題,我們再來回顧一下

  • 用戶登錄不可能以這種彈框形式去登錄,一般網頁都有自己的登錄頁面(自定義登錄頁面)
  • 用戶名、密碼應該是從數據庫中讀取,而不是默認和隨機的(自定義認證邏輯)
  • 並不是對所有的資源或接口都需要認證(設置資源白名單)
  • 認證成功或者失敗的處理,比如登錄成功可以做一些記錄,失敗做一些處理

本篇文章就主要解決上面四點問題

自定義登錄頁面/登錄地址

OK,首先第一點,讓我們來解決一下,將默認的彈框登錄方式改爲網頁表單登錄方式。我們只需要在我們的項目中自定義一個 WebSecurityConfigurerAdapter 的實現類,並重寫它的 configure(HttpSecurity http) 方法,在這個方法中我們顯示指定登錄方式爲 formLogin (默認爲 httpBasic) 示例如下:

/**
 * @author: hblolj
 * @Date: 2019/3/14 10:07
 * @Description:
 * @Version:
 **/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin() // 指定登錄認證方式爲表單登錄
                .and()
                .authorizeRequests()
                .anyRequest() // 對所有的請求
                .authenticated(); // 都進行認證

    }
}

然後,重新啓動應用,再次訪問 http://localhost:8080/security/hello 接口

2-1

用戶名任然是 user,密碼是日誌中輸出的 password。如果我們輸錯了用戶名、密碼,會有如下提示

2-3

輸入正確則可以訪問到我們的接口資源。

OK,到目前爲止我們將認證方式從 HttpBasic 轉變爲了 FormLogin 登錄,但是還是離我們的要求有一些差距

  • 登錄頁面雖然是表單登錄了,但是是默認的。我們需要自定義的登錄頁面。
  • 在前後端分離的情況下,我們需要自定義登錄接口地址

我們此時分析一下,發現問題的核心不在於登錄頁面,也不在於登錄接口。我們上面訪問一個資源跳轉到所謂的登錄頁面。實質上是系統判斷我們沒有認證,引導我們跳轉到一個地址,這個地址既可以是一個 web 頁面,也可以是一個 restful 接口。所以上面兩個問題本質上是一個問題,就是配置系統的表單認證地址。具體實例如下:

/**
 * @author: hblolj
 * @Date: 2019/3/14 10:07
 * @Description:
 * @Version:
 **/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin() // 指定登錄認證方式爲表單登錄
            	//指定自定義登錄頁面地址,一般前後端分離,這裏就用不到了
                .loginPage("/page/login.html") 
            	// 自定義表單登錄的 action 地址,默認是 /login
                .loginProcessingUrl("/authentication/form") 
                .and()
                .authorizeRequests()
            	// 允許登錄頁面不需要認證就可以訪問,不然會死循環導致重定向次數過多
                .antMatchers("/page/login.html").permitAll() 
                .anyRequest() // 對所有的請求
                .authenticated() // 都進行認證
            	.and()
                .csrf()
                    .disable(); // 關閉 csrf 防護

    }
}

這裏我們注意,loginPage 指定認證頁面地址,loginProcessingUrl 指定認證地址,兩者只需要配置一個即可,如果都配置了,則只有 loginProcessingUrl 生效。

這裏我們可能會遇到一個需求,我們的後端應用同時給 Web 頁面與 App 提供服務,這樣他們的認證引導方式不一樣,該怎麼解決。我們要注意的是我們可以在 loginProcessingUrl 配置的接口裏通過對請求的判斷來動態對 web 和 app 請求進行定製化處理。

另外,如果配置的是 loginPage,則需要設置 .antMatchers("/page/login.html").permitAll() 表示認證頁面的訪問不需要認證,否則會死循環導致重定向次數過多問題。這樣我們就完美的解決了自定義登錄頁面與地址問題,第一個問題解決。

設置資源白名單

既然這裏用到了 antMatcher 與 permitAll,那我們提前說一下第三個問題,資源白名單,這裏要分情況討論一下:

  • 前後端分離
    • 前端頁面資源不在我們後端應用的管轄下,我們只需要管理好我們的接口訪問權限即可
  • 不分離
    • 前端頁面放在應用文件夾下,那麼久對對應的文件路徑進行管理

具體管理方式有兩種,一種指定具體的訪問地址,例如.antMatchers("/page/login.html").permitAll() ,這裏還可以使用 * 通配符進行範圍指定

  • /page/*.html : page 下的所有 html

  • /page/** : page 下的所有資源

另一種方式是在自定義的 WebSecurityConfig 類中重寫 configure(WebSecurity web) 方法,在方法中對靜態資源設置不攔截,這裏注意一下,spring boot 的默認靜態資源放置位置是在 resource/static 下,可以在 static 下新建一個文件夾,然後在上述方法中指定跳過攔截的文件路徑即可。

@Override
public void configure(WebSecurity web) throws Exception {
    web.ignoring().antMatchers("/page/**");
}

到了這裏,第三個問題基本上也解決了。那我們還剩下兩個問題要處理,自定義認證邏輯與認證結果處理。我們按照業務順序先來處理一下自定義認證邏輯。

自定義認證邏輯

自定義認證邏輯我們可以分爲三塊

  • 從請求中獲取用戶認證信息,在表單認證這裏就是用戶名與密碼
  • 按照認證信息從數據庫查詢取出用戶信息
  • 對取出的用戶信息與認證信息進行校驗比對
    • 比對密碼
    • 校驗用戶狀態,比如賬號是否是凍結的等等

如果使用 SpringSecurity 默認幫我們實現的表單認證邏輯,我們只需要實現第二步即可,具體步驟如下:

  • 自定義一個 UserDetailsService 的實現類,重寫它的 loadUserByUsername 方法,在這個方法裏面按參數到數據庫中查詢用戶信息,最後返回一個 UserDetail 的實現類。示例如下:

    /**
     * @author: hblolj
     * @Date: 2019/3/14 10:40
     * @Description:
     * @Version:
     **/
    @Component
    public class FormUserDetailService implements UserDetailsService{
    
        @Override
        public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    
            // TODO: 2019/3/14 按參數 s 從數據庫查找用戶信息,一般注入 dao 查詢
            
            // 返回的是 org.springframework.security.core.userdetails 下 User 類
            // 在實際業務時,可以使系統的 User 類去實現 UserDetail 接口,然後返回自己的 User 類即可
            // 構造方法傳入的三個參數分別是用戶名、密碼、權限集合
            // 還有另外一個構造方法,可以傳額外四個參數,表示賬號狀態(啓用、凍結、鎖定等)
            // 如果密碼使用了加密,從數據庫中取出的應該是加密過的密碼,不是明文
            return new User(s, "123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
        }
    }
    

    這樣,當我們使用表單登錄時就會使用我們自定義的邏輯了(默認使用的其實是 InMemoryUserDetailsManager 這個類)。

  • 這裏有幾點注意說明一下

    • 在用戶註冊時對用戶密碼使用了加密時的處理。

      • SpringSecurity 給我們提供了 PasswordEncoder 來加密密碼,我們可以制定一種加密類型,然後放入 IOC 容器中,加密解密使用這個共享的 PasswordEncoder。

        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
        

        我們註冊時,可以使用該 PasswordEncoder 對用戶的密碼進行加密存儲到數據庫中,取出時,SpringSecurity 會從獲取到該 passwordEncoder 來進行解密校驗。我們自己模擬的時候,可以對密碼進行加密返回。示例:

        /**
         * @author: hblolj
         * @Date: 2019/3/14 10:40
         * @Description:
         * @Version:
         **/
        @Component
        public class FormUserDetailService implements UserDetailsService{
        
            @Autowired
            private PasswordEncoder passwordEncoder;
        
            @Override
            public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        
                // 模擬從數據庫中取出的密碼是已經加密過的密碼
                String password = passwordEncoder.encode("123");
                
                User user = new User(s, password, true, true, true,
                        true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
        
                return user;
            }
        }
        
    • 當自己定義了多個 UserDetailsService 的實現類放到 IOC 容器時,會發現默認的 formLogin 會使用 InMemoryUserDetailsManager 的實現來處理校驗邏輯。同時 SpringScurity 使用的 PasswordEncoder 也不是我們自己實現的,會出現密碼校驗不上

      • 解決方案,全局指定默認的 UserDetailService 與 PasswordEncoder

        /**
         * @author: hblolj
         * @Date: 2019/3/15 18:12
         * @Description: 指定全局默認的 UserDetailService 與 PasswordEncoder
         * @Version:
         **/
        @Configuration
        public class GlobalAuthenticationConfigurer extends GlobalAuthenticationConfigurerAdapter {
        
            private final UserDetailsService userService;
        
            private final PasswordEncoder passwordEncoder;
        
            @Autowired
            public GlobalAuthenticationConfigurer(@Qualifier("formUserDetailService") UserDetailsService userDetailsService,
                                                  PasswordEncoder passwordEncoder) {
                this.userService = userDetailsService;
                this.passwordEncoder = passwordEncoder;
            }
        
            @Override
            public void init(AuthenticationManagerBuilder auth) throws Exception {
                auth.userDetailsService(userService).passwordEncoder(passwordEncoder);
            }
        }
        
    • 系統提供的 formLogin 不能滿足我們的需求,需要自定義認證方式,比如短信驗證碼登錄、微信登錄等等。

      • 下一章節會示例,To Be Continue…

認證結果自定義處理

經過前面的認證,現在會有兩個結果,認證成功與認證失敗。我們需求往往會要求我們正在這時做出對應的處理,比如記錄信息、引導用戶,返回用戶信息等等。在 SpringSecurity 裏面,框架幫我們封裝了兩個接口(AuthenticationFailureHandler 與 AuthenticationSuccessHandler),我們只需要實現這兩個接口,重寫 (onAuthenticationFailure 與 onAuthenticationSuccess 方法) 並將其實現類配置到我們自定義的 WebSecurityConfig 即可使用。

  • 認證成功處理

    /**
     * @author: hblolj
     * @Date: 2019/3/14 14:56
     * @Description:
     * @Version:
     **/
    @Slf4j
    @Component
    public class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                            Authentication authentication) throws IOException, ServletException {
    
            log.info("Login Success!");
    
            httpServletResponse.setContentType("application/json;charset=UTF-8");
            			
          httpServletResponse.getWriter().write(authentication.getPrincipal().toString());
        }
    }
    
  • 認證失敗處理

    /**
     * @author: hblolj
     * @Date: 2019/3/14 14:56
     * @Description:
     * @Version:
     **/
    @Slf4j
    @Component
    public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler{
    
        @Override
        public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                            AuthenticationException e) throws IOException, ServletException {
    
            // 自定義登錄失敗處理邏輯
            log.info("Login Failure!");
            httpServletResponse.setCharacterEncoding("UTF-8");
            httpServletResponse.setContentType("application/json;charset=UTF-8");
            httpServletResponse.getWriter().write(e.getMessage());
        }
    }
    
  • 添加到配置

    /**
     * @author: hblolj
     * @Date: 2019/3/14 10:07
     * @Description:
     * @Version:
     **/
    @Configuration
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    
        @Autowired
        private AuthenticationEntryPoint authenticationEntryPoint;
    
        @Autowired
        private AuthenticationSuccessHandler authenticationSuccessHandler;
    
        @Autowired
        private AuthenticationFailureHandler authenticationFailureHandler;
    
        @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
    
            http.formLogin() // 指定登錄認證方式爲表單登錄
    //                .loginPage("http://www.baidu.com") //指定自定義登錄頁面地址,一般前後端分離,這裏就用不到了
                    .loginProcessingUrl("/authentication/form") // 自定義表單登錄的 action 地址,默認是 /login
                    .successHandler(authenticationSuccessHandler)
                    .failureHandler(authenticationFailureHandler)
                    .and()
                    .authorizeRequests()
                    .antMatchers("/page/login.html").permitAll() // 允許登錄頁面不需要認證就可以訪問,不然會死循環導致重定向次數過多
                    .anyRequest() // 對所有的請求
                    .authenticated(); // 都進行認證
    //                .and()
    //                .exceptionHandling()
    //                .authenticationEntryPoint(authenticationEntryPoint); // 實現了 EntryPoint 對 loginPage 有覆蓋作用,loginPage 不生效
        }
    }
    

這裏要注意幾點,在我們的需求中可能會出現,比如登錄前訪問 A 頁面,現在登陸後需要自動跳轉到 A 頁面。這裏我們可以觀察一下,AuthenticationSuccessHandler 與 AuthenticationFailureHandler 接口的實現類

  • SavedRequestAwareAuthenticationSuccessHandler
    
    • 繼承該類,調用 super.onAuthenticationSuccess 方法,會跳轉到認證前的頁面
    SimpleUrlAuthenticationFailureHandler
    
    • 繼承該類,調用 super.onAuthenticationFailure 方法會跳轉到設置的頁面,如果沒有設置會返回 401,同時可以指定 forward 與 redirect 方式

關於適配 Web 與 App 方面,在處理方法中從請求中分析出客戶端類型,然後做出對應的處理即可。比如是引導頁面跳轉,還是返回一段 JSON。

OK,到了這裏,開頭我們的幾個目標問題都已經解決了,下一篇文章我們將給大家帶了在 SpringSecurity 下自定義認證方式的實現說明(手機號登陸),To Be Continue!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章