本文由讀者 muggle 投稿,muggle 是一位具備極客精神的90後單身老實猿,對 Spring Security 有豐富的使用經驗,muggle 個人博客地址是 https://muggle0.github.io。
Security 原理分析
SpringSecurity 過濾器鏈
SpringSecurity 採用的是責任鏈的設計模式,它有一條很長的過濾器鏈。現在對這條過濾器鏈的各個進行說明:
- WebAsyncManagerIntegrationFilter:將 Security 上下文與 Spring Web 中用於處理異步請求映射的 WebAsyncManager 進行集成。
- SecurityContextPersistenceFilter:在每次請求處理之前將該請求相關的安全上下文信息加載到 SecurityContextHolder 中,然後在該次請求處理完成之後,將 SecurityContextHolder 中關於這次請求的信息存儲到一個「倉儲」中,然後將 SecurityContextHolder 中的信息清除,例如在Session中維護一個用戶的安全信息就是這個過濾器處理的。
- HeaderWriterFilter:用於將頭信息加入響應中。
- CsrfFilter:用於處理跨站請求偽造。
- LogoutFilter:用於處理退出登錄。
- UsernamePasswordAuthenticationFilter:用於處理基於表單的登錄請求,從表單中獲取用戶名和密碼。默認情況下處理來自 /login 的請求。從表單中獲取用戶名和密碼時,默認使用的表單 name 值為 username 和 password,這兩個值可以通過設置這個過濾器的usernameParameter 和 passwordParameter 兩個參數的值進行修改。
- DefaultLoginPageGeneratingFilter:如果沒有配置登錄頁面,那系統初始化時就會配置這個過濾器,並且用於在需要進行登錄時生成一個登錄表單頁面。
- BasicAuthenticationFilter:檢測和處理 http basic 認證。
- RequestCacheAwareFilter:用來處理請求的緩存。
- SecurityContextHolderAwareRequestFilter:主要是包裝請求對象request。
- AnonymousAuthenticationFilter:檢測 SecurityContextHolder 中是否存在 Authentication 對象,如果不存在為其提供一個匿名 Authentication。
- SessionManagementFilter:管理 session 的過濾器
- ExceptionTranslationFilter:處理 AccessDeniedException 和 AuthenticationException 異常。
- FilterSecurityInterceptor:可以看做過濾器鏈的出口。
- RememberMeAuthenticationFilter:當用戶沒有登錄而直接訪問資源時, 從 cookie 里找出用戶的信息, 如果 Spring Security 能夠識別出用戶提供的remember me cookie, 用戶將不必填寫用戶名和密碼, 而是直接登錄進入系統,該過濾器默認不開啟。
SpringSecurity 流程圖
先來看下面一個 Spring Security 執行流程圖,只要把 SpringSecurity 的執行過程弄明白了,這個框架就會變得很簡單:
流程說明
- 客戶端發起一個請求,進入 Security 過濾器鏈。
- 當到 LogoutFilter 的時候判斷是否是登出路徑,如果是登出路徑則到 logoutHandler ,如果登出成功則到 logoutSuccessHandler 登出成功處理,如果登出失敗則由 ExceptionTranslationFilter ;如果不是登出路徑則直接進入下一個過濾器。
- 當到 UsernamePasswordAuthenticationFilter 的時候判斷是否為登錄路徑,如果是,則進入該過濾器進行登錄操作,如果登錄失敗則到 AuthenticationFailureHandler 登錄失敗處理器處理,如果登錄成功則到 AuthenticationSuccessHandler 登錄成功處理器處理,如果不是登錄請求則不進入該過濾器。
- 當到 FilterSecurityInterceptor 的時候會拿到 uri ,根據 uri 去找對應的鑒權管理器,鑒權管理器做鑒權工作,鑒權成功則到 Controller 層否則到 AccessDeniedHandler 鑒權失敗處理器處理。
Security 配置
在 WebSecurityConfigurerAdapter 這個類裡面可以完成上述流程圖的所有配置
配置類偽代碼
@Configuration@EnableWebSecuritypublic class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailService).passwordEncoder(new BCryptPasswordEncoder()); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/resources/**/*.html", "/resources/**/*.js"); } @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .loginPage("/login_page") .passwordParameter("username") .passwordParameter("password") .loginProcessingUrl("/sign_in") .permitAll() .and().authorizeRequests().antMatchers("/test").hasRole("test") .anyRequest().authenticated().accessDecisionManager(accessDecisionManager()) .and().logout().logoutSuccessHandler(new MyLogoutSuccessHandler()) .and().csrf().disable(); http.addFilterAt(getAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class); http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler()); http.addFilterAfter(new MyFittler(), LogoutFilter.class); }}
配置簡介
- configure(AuthenticationManagerBuilder auth)
AuthenticationManager 的建造器,配置 AuthenticationManagerBuilder 會讓Security 自動構建一個 AuthenticationManager(該類的功能參考流程圖);如果想要使用該功能你需要配置一個 UserDetailService 和 PasswordEncoder。UserDetailsService 用於在認證器中根據用戶傳過來的用戶名查找一個用戶, PasswordEncoder 用於密碼的加密與比對,我們存儲用戶密碼的時候用PasswordEncoder.encode() 加密存儲,在認證器里會調用 PasswordEncoder.matches() 方法進行密碼比對。如果重寫了該方法,Security 會啟用 DaoAuthenticationProvider 這個認證器,該認證就是先調用 UserDetailsService.loadUserByUsername 然後使用 PasswordEncoder.matches() 進行密碼比對,如果認證成功成功則返回一個 Authentication 對象。
- configure(WebSecurity web)
這個配置方法用於配置靜態資源的處理方式,可使用 Ant 匹配規則。
- configure(HttpSecurity http)
這個配置方法是最關鍵的方法,也是最複雜的方法。我們慢慢掰開來說:
http.formLogin().loginPage("/login_page").passwordParameter("username").passwordParameter("password").loginProcessingUrl("/sign_in").permitAll()
這是配置登錄相關的操作從方法名可知,配置了登錄頁請求路徑,密碼屬性名,用戶名屬性名,和登錄請求路徑,permitAll()代表任意用戶可訪問。
http.authorizeRequests().antMatchers("/test").hasRole("test").anyRequest().authenticated().accessDecisionManager(accessDecisionManager());
以上配置是權限相關的配置,配置了一個 /test url 該有什麼權限才能訪問, anyRequest() 表示所有請求,authenticated() 表示已登錄用戶才能訪問, accessDecisionManager() 表示綁定在 url 上的鑒權管理器
為了對比,現在貼出另一個權限配置清單:
http.authorizeRequests().antMatchers("/tets_a/**","/test_b/**").hasRole("test").antMatchers("/a/**","/b/**").authenticated().accessDecisionManager(accessDecisionManager())
我們可以看到權限配置的自由度很高,鑒權管理器可以綁定到任意 url 上;而且可以硬編碼各種 url 權限:
http.logout().logoutUrl("/logout").logoutSuccessHandler(new MyLogoutSuccessHandler())
登出相關配置,這裡配置了登出 url 和登出成功處理器:
http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
上面代碼是配置鑒權失敗的處理器。
http.addFilterAfter(new MyFittler(), LogoutFilter.class);http.addFilterAt(getAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
上面代碼展示如何在過濾器鏈中插入自己的過濾器,addFilterBefore 加在對應的過濾器之前,addFilterAfter 加在對應的過濾器之後,addFilterAt 加在過濾器同一位置,事實上框架原有的 Filter 在啟動 HttpSecurity 配置的過程中,都由框架完成了其一定程度上固定的配置,是不允許更改替換的。根據測試結果來看,調用 addFilterAt 方法插入的 Filter ,會在這個位置上的原有 Filter 之前執行。
註:關於 HttpSecurity 使用的是鏈式編程,其中 http.xxxx.and.yyyyy 這種寫法和 http.xxxx;http.yyyy 寫法意義一樣。
- 自定義 AuthenticationManager 和 AccessDecisionManager
重寫 authenticationManagerBean() 方法,並構造一個 authenticationManager:
@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception { ProviderManager authenticationManager = new ProviderManager(Arrays.asLis(getMyAuthenticationProvider(),daoAuthenticationProvider())); return authenticationManager;}
我這裡給 authenticationManager 配置了兩個認證器,執行過程參考流程圖。
定義構造AccessDecisionManager的方法並在配置類中調用,配置參考 configure(HttpSecurity http) 說明:
public AccessDecisionManager accessDecisionManager(){ List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList( new MyExpressionVoter(), new WebExpressionVoter(), new RoleVoter(), new AuthenticatedVoter()); return new UnanimousBased(decisionVoters);}
投票管理器會收集投票器投票結果做統計,最終結果大於等於0代表通過;每個投票器會返回三個結果:-1(反對),0(通過),1(贊成)。
Security 權限系統
- UserDetails
Security 中的用戶接口,我們自定義用戶類要實現該接口。
- GrantedAuthority
Security 中的用戶權限接口,自定義權限需要實現該接口:
public class MyGrantedAuthority implements GrantedAuthority { private String authority;}
authority 表示權限欄位,需要注意的是在 config 中配置的權限會被加上 ROLE_ 前綴,比如我們的配置 authorizeRequests().antMatchers("/test").hasRole("test"),配置了一個 test 權限但我們存儲的權限欄位(authority)應該是 ROLE_test 。
- UserDetailsService
Security 中的用戶 Service,自定義用戶服務類需要實現該接口:
@Servicepublic class MyUserDetailService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { return..... }}
loadUserByUsername的作用在上文中已經說明,就是根據用戶名查詢用戶對象。
- SecurityContextHolder
用戶在完成登錄後 Security 會將用戶信息存儲到這個類中,之後其他流程需要得到用戶信息時都是從這個類中獲得,用戶信息被封裝成 SecurityContext ,而實際存儲的類是 SecurityContextHolderStrategy ,默認的SecurityContextHolderStrategy 實現類是 ThreadLocalSecurityContextHolderStrategy 它使用了ThreadLocal來存儲了用戶信息。
手動填充 SecurityContextHolder 示例:
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("test","test",list);SecurityContextHolder.getContext().setAuthentication(token);
對於使用 token 鑒權的系統,我們就可以驗證token後手動填充SecurityContextHolder,填充時機只要在執行投票器之前即可,或者乾脆可以在投票器中填充,然後在登出操作中清空SecurityContextHolder。
Security 擴展
Security 可擴展的有
- 鑒權失敗處理器
- 驗證器
- 登錄成功處理器
- 投票器
- 自定義token處理過濾器
- 登出成功處理器
- 登錄失敗處理器
- 自定義 UsernamePasswordAuthenticationFilter
- 鑒權失敗處理器
Security 鑒權失敗默認跳轉登錄頁面,我們可以實現 AccessDeniedHandler 接口,重寫 handle() 方法來自定義處理邏輯;然後參考配置類說明將處理器加入到配置當中。
- 驗證器
實現 AuthenticationProvider 接口來實現自己驗證邏輯。需要注意的是在這個類裡面就算你拋出異常,也不會中斷驗證流程,而是算你驗證失敗,我們由流程圖知道,只要有一個驗證器驗證成功,就算驗證成功,所以你需要留意這一點。
- 登錄成功處理器
在 Security 中驗證成功默認跳轉到上一次請求頁面或者路徑為 "/" 的頁面,我們同樣可以自定義:繼承 SimpleUrlAuthenticationSuccessHandler 這個類或者實現 AuthenticationSuccessHandler 接口。我這裡建議採用繼承的方式,SimpleUrlAuthenticationSuccessHandler 是默認的處理器,採用繼承可以契合里氏替換原則,提高代碼的復用性和避免不必要的錯誤。
- 投票器
投票器可繼承 WebExpressionVoter 或者實現 AccessDecisionVoter接口;WebExpressionVoter 是 Security 默認的投票器;我這裡同樣建議採用繼承的方式;添加到配置的方式參考 上文;
注意:投票器 vote 方法返回一個int值;-1代表反對,0代表棄權,1代表贊成;投票管理器收集投票結果,如果最終結果大於等於0則放行該請求。
- 自定義token處理過濾器
自定義 token 處理器繼承自 OncePerRequestFilter 或者 GenericFilterBean 或者 Filter 都可以,在這個處理器裡面需要完成的邏輯是:獲取請求里的 token,驗證 token 是否合法然後填充 SecurityContextHolder ,雖然說過濾器只要添加在投票器之前就可以,但我這裡還是建議添加在 http.addFilterAfter(new MyFittler(), LogoutFilter.class);
- 登出成功處理器
實現LogoutSuccessHandler接口,添加到配置的方式參考上文。
- 登錄失敗處理器
登錄失敗默認跳轉到登錄頁,我們同樣可以自定義。繼承 SimpleUrlAuthenticationFailureHandler 或者實現 AuthenticationFailureHandler,建議採用繼承。
- 自定義UsernamePasswordAuthenticationFilter
我們自定義UsernamePasswordAuthenticationFilter可以極大提高我們 Security的靈活性(比如添加驗證驗證碼是否正確的功能)。
我們直接繼承 UsernamePasswordAuthenticationFilter ,然後在配置類中初始化這個過濾器,給這個過濾器添加登錄失敗處理器,登錄成功處理器,登錄管理器,登錄請求 url 。
這裡配置略微複雜,貼一下代碼清單
初始化過濾器:
MyUsernamePasswordAuthenticationFilte getAuthenticationFilter(){ MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter = new MyUsernamePasswordAuthenticationFilter(redisService); myUsernamePasswordAuthenticationFilter.setAuthenticationFailureHandler(new MyUrlAuthenticationFailureHandler()); myUsernamePasswordAuthenticationFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler()); myUsernamePasswordAuthenticationFilter.setFilterProcessesUrl("/sign_in"); myUsernamePasswordAuthenticationFilter.setAuthenticationManager(getAuthenticationManager()); return myUsernamePasswordAuthenticationFilter;}
添加到配置:
http.addFilterAt(getAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class);
總結
對於 Security 的擴展配置關鍵在於 configure(HttpSecurityhttp) 方法;擴展認證方式可以自定義 authenticationManager 並加入自己驗證器,在驗證器中拋出異常不會終止驗證流程;擴展鑒權方式可以自定義 accessDecisionManager 然後添加自己的投票器並綁定到對應的 url(url 匹配方式為 ant)上,投票器 vote(Authenticationauthentication,FilterInvocationfi,Collection<ConfigAttribute>attributes) 方法返回值為三種:-1 0 1,分別表示反對棄權贊成。
對於 token 認證的校驗方式,可以暴露一個獲取的接口,或者重寫 UsernamePasswordAuthenticationFilter 過濾器和擴展登錄成功處理器來獲取 token,然後在 LogoutFilter 之後添加一個自定義過濾器,用於校驗和填充 SecurityContextHolder。
另外,Security 的處理器大部分都是重定向的,我們的項目如果是前後端分離的話,我們希望無論什麼情況都返回 json ,那麼就需要重寫各個處理器了。