在上一篇文章的結尾,我們列入了默認使用 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 接口
用戶名任然是 user,密碼是日誌中輸出的 password。如果我們輸錯了用戶名、密碼,會有如下提示
輸入正確則可以訪問到我們的接口資源。
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!