Spring Security 解析(一) —— 授權過程
Spring Security 解析(一) —— 授權過程
在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程中加強印象和理解所撰寫的,如有侵權請告知。
專案環境:
- JDK1.8
- Spring boot 2.x
- Spring Security 5.x
一、 一個簡單的Security Demo
1、 自定義的UserDetailsService實現
自定義MyUserDetailsUserService類,實現 UserDetailsService 介面的 loadUserByUsername()方法,這裡就簡單的返回一個Spring Security 提供的 User 物件。為了後面方便演示Spring Security 的許可權控制,這裡使用AuthorityUtils.commaSeparatedStringToAuthorityList("admin")
@Component
public class MyUserDetailsUserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 不能直接使用 建立 BCryptPasswordEncoder 物件來加密, 這種加密方式 沒有 {bcrypt} 字首,
// 會導致在 matches 時導致獲取不到加密的演演算法出現
// java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null" 問題
// 問題原因是 Spring Security5 使用 DelegatingPasswordEncoder(委託) 替代 NoOpPasswordEncoder,
// 並且 預設使用 BCryptPasswordEncoder 加密(注意 DelegatingPasswordEncoder 委託加密方法BCryptPasswordEncoder 加密前 添加了加密型別的字首) https://blog.csdn.net/alinyua/article/details/80219500
return new User("user" ,PasswordEncoderFactories.createDelegatingPasswordEncoder().encode("123456"),AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
複製程式碼
注意Spring Security 5 開始沒有使用 NoOpPasswordEncoder作為其預設的密碼編碼器,而是預設使用 DelegatingPasswordEncoder 作為其密碼編碼器,其 encode 方法是通過 密碼編碼器的名稱作為字首 + 委託各類密碼編碼器來實現encode的。
public String encode(CharSequence rawPassword) {
return "{" + this.idForEncode + "}" + this.passwordEncoderForEncode.encode(rawPassword);
}
複製程式碼
這裡的 idForEncode 就是密碼編碼器的簡略名稱,可以通過 PasswordEncoderFactories.createDelegatingPasswordEncoder() 內部實現看到預設是使用的字首是 bcrypt 也就是 BCryptPasswordEncoder
public class PasswordEncoderFactories {
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String,PasswordEncoder> encoders = new HashMap();
encoders.put(encodingId,new BCryptPasswordEncoder());
encoders.put("ldap",new LdapShaPasswordEncoder());
encoders.put("MD4",new Md4PasswordEncoder());
encoders.put("MD5",new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop",NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2",new Pbkdf2PasswordEncoder());
encoders.put("scrypt",new SCryptPasswordEncoder());
encoders.put("SHA-1",new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256",new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256",new StandardPasswordEncoder());
return new DelegatingPasswordEncoder(encodingId,encoders);
}
}
複製程式碼
2、 設定Spring Security配置
定義SpringSecurityConfig 配置類,並繼承WebSecurityConfigurerAdapter覆蓋其configure(HttpSecurity http) 方法。
@Configuration
@EnableWebSecurity //1
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //2
.and()
.authorizeRequests() //3
.antMatchers("/index","/").permitAll() //4
.anyRequest().authenticated(); //6
}
}
複製程式碼
配置解析:
- @EnableWebSecurity 檢視其註解原始碼,主要是引用WebSecurityConfiguration.class 和 加入了@EnableGlobalAuthentication 註解 ,這裡就不介紹了,我們只要明白新增 @EnableWebSecurity 註解將開啟 Security 功能。
- formLogin() 使用表單登入(預設請求地址為 /login),在Spring Security 5 裡其實已經將舊版本預設的 httpBasic() 更換成 formLogin() 了,這裡為了表明表單登入還是配置了一次。
- authorizeRequests() 開始請求許可權配置
- antMatchers() 使用Ant風格的路徑匹配,這裡配置匹配 / 和 /index
- permitAll() 使用者可任意訪問
- anyRequest() 匹配所有路徑
- authenticated() 使用者登入後可訪問
3、 配置html 和測試介面
在 resources/static 目錄下新建 index.html , 其內部定義一個訪問測試介面的按鈕
<!DOCTYPE html>
<html lang="en" >
<head>
<meta charset="UTF-8">
<title>歡迎</title>
</head>
<body>
Spring Security 歡迎你!
<p> <a href="/get_user/test">測試驗證Security 許可權控制</a></p>
</body>
</html>
複製程式碼
建立 rest 風格的獲取使用者資訊介面
@RestController
public class TestController {
@GetMapping("/get_user/{username}")
public String getUser(@PathVariable String username){
return username;
}
}
複製程式碼
4、 啟動專案測試
1、訪問 localhost:8080 無任何阻攔直接成功
2、點選測試驗證許可權控制按鈕 被重定向到了 Security預設的登入頁面
3、使用 MyUserDetailsUserService定義的預設賬戶 user : 123456 進行登入後成功跳轉到 /get_user 介面
二、 @EnableWebSecurity 配置解析
還記得之前講過 @EnableWebSecurity 引用了 WebSecurityConfiguration 配置類 和 @EnableGlobalAuthentication 註解嗎? 其中 WebSecurityConfiguration 就是與授權相關的配置,@EnableGlobalAuthentication 配置了 認證相關的我們下節再細討。
首先我們檢視 WebSecurityConfiguration 原始碼,可以很清楚的發現 springSecurityFilterChain() 方法。
@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
public Filter springSecurityFilterChain() throws Exception {
boolean hasConfigurers = webSecurityConfigurers != null
&& !webSecurityConfigurers.isEmpty();
if (!hasConfigurers) {
WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
.postProcess(new WebSecurityConfigurerAdapter() {
});
webSecurity.apply(adapter);
}
return webSecurity.build(); //1
}
複製程式碼
這個方法首先會判斷 webSecurityConfigurers 是否為空,為空載入一個預設的 WebSecurityConfigurerAdapter物件,由於自定義的 SpringSecurityConfig 本身是繼承 WebSecurityConfigurerAdapter物件 的,所以我們自定義的 Security 配置肯定會被載入進來的(如果想要了解如何載入進來可以看下WebSecurityConfiguration.setFilterChainProxySecurityConfigurer() 方法)。
我們看下 webSecurity.build() 方法實現 實際呼叫的是 AbstractConfiguredSecurityBuilder.doBuild() 方法,其方法內部實現如下:
@Override
protected final O doBuild() throws Exception {
synchronized (configurers) {
buildState = BuildState.INITIALIZING;
beforeInit();
init();
buildState = BuildState.CONFIGURING;
beforeConfigure();
configure();
buildState = BuildState.BUILDING;
O result = performBuild(); // 1 建立 DefaultSecurityFilterChain (Security Filter 責任鏈 )
buildState = BuildState.BUILT;
return result;
}
}
複製程式碼
我們把關注點放到 performBuild() 方法,看其實現子類 HttpSecurity.performBuild() 方法,其內部排序 filters 並建立了 DefaultSecurityFilterChain 物件。
@Override
protected DefaultSecurityFilterChain performBuild() throws Exception {
Collections.sort(filters,comparator);
return new DefaultSecurityFilterChain(requestMatcher,filters);
}
複製程式碼
檢視DefaultSecurityFilterChain 的構造方法,我們可以看到有記錄日誌。
public DefaultSecurityFilterChain(RequestMatcher requestMatcher,List<Filter> filters) {
logger.info("Creating filter chain: " + requestMatcher + "," + filters); // 按照正常情況,我們可以看到控制檯輸出 這條日誌
this.requestMatcher = requestMatcher;
this.filters = new ArrayList<>(filters);
}
複製程式碼
我們可以回頭看下專案啟動日誌。可以看到下圖明顯列印了 這條日誌,並且把所有 Filter名都打印出來了。==(請注意這裡列印的 filter 鏈,接下來我們的所有授權過程都是依靠這條filter 鏈展開 )==
那麼還有個疑問: HttpSecurity.performBuild() 方法中的 filters 是怎麼載入的呢? 這個時候需要檢視 WebSecurityConfigurerAdapter.init() 方法,這個方法內部 呼叫 getHttp() 方法返回 HttpSecurity 物件(看到這裡我們應該能想到 filters 就是這個方法中新增好了資料),具體如何載入的也就不介紹了。
public void init(final WebSecurity web) throws Exception {
final HttpSecurity http = getHttp(); // 1
web.addSecurityFilterChainBuilder(http).postBuildAction(new Runnable() {
public void run() {
FilterSecurityInterceptor securityInterceptor = http
.getSharedObject(FilterSecurityInterceptor.class);
web.securityInterceptor(securityInterceptor);
}
});
}
複製程式碼
用了這麼長時間解析 @EnableWebSecurity ,其實最關鍵的一點就是建立了 DefaultSecurityFilterChain 也就是我們常 security filter 責任鏈,接下來我們圍繞這個 DefaultSecurityFilterChain 中 的 filters 進行授權過程的解析。
三、 授權過程解析
Security的授權過程可以理解成各種 filter 處理最終完成一個授權。那麼我們再看下之前 列印的filter 鏈,這裡為了方便,再次貼出圖片
這裡我們只關注以下幾個重要的 filter :
- SecurityContextPersistenceFilter
- UsernamePasswordAuthenticationFilter (AbstractAuthenticationProcessingFilter)
- BasicAuthenticationFilter
- AnonymousAuthenticationFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
1、SecurityContextPersistenceFilter
SecurityContextPersistenceFilter 這個filter的主要負責以下幾件事:
- 通過 (SecurityContextRepository)repo.loadContext() 方法從請求Session中獲取 SecurityContext(Security 上下文 ,類似 ApplicaitonContext ) 物件,如果請求Session中沒有預設建立一個 authentication(認證的關鍵物件,由於本節只講授權,暫不介紹) 屬性為 null 的 SecurityContext 物件
- SecurityContextHolder.setContext() 將 SecurityContext 物件放入 SecurityContextHolder進行管理(SecurityContextHolder預設使用ThreadLocal 策略來儲存認證資訊)
- 由於在 finally 裡實現 會在最後通過 SecurityContextHolder.clearContext() 將 SecurityContext 物件 從 SecurityContextHolder中清除
- 由於在 finally 裡實現 會在最後通過 repo.saveContext() 將 SecurityContext 物件 放入Session中
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,response);
//從Session中獲取SecurityContxt 物件,如果Session中沒有則建立一個 authtication 屬性為 null 的SecurityContext物件
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
// 將 SecurityContext 物件放入 SecurityContextHolder進行管理 (SecurityContextHolder預設使用ThreadLocal 策略來儲存認證資訊)
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(),holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
// 將 SecurityContext 物件 從 SecurityContextHolder中清除
SecurityContextHolder.clearContext();
// 將 SecurityContext 物件 放入Session中
repo.saveContext(contextAfterChainExecution,holder.getRequest(),holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared,as request processing completed");
}
}
複製程式碼
我們在 SecurityContextPersistenceFilter 中打上斷點,啟動專案,訪問 localhost:8080,來debug看下實現:
我們可以清楚的看到建立了一個authtication 為null 的 SecurityContext物件,並且可以看到請求呼叫的filter鏈具體有哪些。接下來看下 finally 內部處理你會發現這裡的SecurityContxt中的 authtication 是一個名為 anonymousUser (匿名使用者)的認證資訊,這是因為 請求呼叫到了 AnonymousAuthenticationFilter,Security預設建立了一個匿名使用者訪問。
2、UsernamePasswordAuthenticationFilter (AbstractAuthenticationProcessingFilter)
看filter字面意思就知道這是一個通過獲取請求中的賬戶密碼來進行授權的filter,按照慣例,整理了這個filter的職責:
- 通過 requiresAuthentication()判斷 是否以POST 方式請求 /login
- 呼叫 attemptAuthentication() 方法進行認證,內部建立了 authenticated 屬性為 false(即未授權)的UsernamePasswordAuthenticationToken 物件, 並傳遞給 AuthenticationManager().authenticate() 方法進行認證,認證成功後 返回一個 authenticated = true (即授權成功的)UsernamePasswordAuthenticationToken 物件
- 通過 sessionStrategy.onAuthentication() 將 Authentication 放入Session中
- 通過 successfulAuthentication() 呼叫 AuthenticationSuccessHandler 的 onAuthenticationSuccess 介面 進行成功處理( 可以 通過 繼承 AuthenticationSuccessHandler 自行編寫成功處理邏輯 )successfulAuthentication(request,response,chain,authResult);
- 通過 unsuccessfulAuthentication() 呼叫AuthenticationFailureHandler 的 onAuthenticationFailure 介面 進行失敗處理(可以通過繼承AuthenticationFailureHandler 自行編寫失敗處理邏輯 )
我們再看下官方原始碼的處理邏輯:
// 1 AbstractAuthenticationProcessingFilter 的 doFilter 方法
public void doFilter(ServletRequest req,ServletResponse res,FilterChain chain)
throws IOException,ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
// 2 判斷請求地址是否是 /login 和 請求方式為 POST (UsernamePasswordAuthenticationFilter 構造方法 確定的)
if (!requiresAuthentication(request,response)) {
chain.doFilter(request,response);
return;
}
Authentication authResult;
try {
// 3 呼叫 子類 UsernamePasswordAuthenticationFilter 的 attemptAuthentication 方法
// attemptAuthentication 方法內部建立了 authenticated 屬性為 false (即未授權)的 UsernamePasswordAuthenticationToken 物件, 並傳遞給 AuthenticationManager().authenticate() 方法進行認證,
//認證成功後 返回一個 authenticated = true (即授權成功的) UsernamePasswordAuthenticationToken 物件
authResult = attemptAuthentication(request,response);
if (authResult == null) {
return;
}
// 4 將認證成功的 Authentication 存入Session中
sessionStrategy.onAuthentication(authResult,request,response);
}
catch (InternalAuthenticationServiceException failed) {
// 5 認證失敗後 呼叫 AuthenticationFailureHandler 的 onAuthenticationFailure 介面 進行失敗處理( 可以 通過 繼承 AuthenticationFailureHandler 自行編寫失敗處理邏輯 )
unsuccessfulAuthentication(request,failed);
return;
}
catch (AuthenticationException failed) {
// 5 認證失敗後 呼叫 AuthenticationFailureHandler 的 onAuthenticationFailure 介面 進行失敗處理( 可以 通過 繼承 AuthenticationFailureHandler 自行編寫失敗處理邏輯 )
unsuccessfulAuthentication(request,failed);
return;
}
......
// 6 認證成功後 呼叫 AuthenticationSuccessHandler 的 onAuthenticationSuccess 介面 進行失敗處理( 可以 通過 繼承 AuthenticationSuccessHandler 自行編寫成功處理邏輯 )
successfulAuthentication(request,authResult);
}
複製程式碼
從原始碼上看,整個流程其實是很清晰的:從判斷是否處理,到認證,最後判斷認證結果分別作出認證成功和認證失敗的處理。
debug 除錯下看 結果,這次我們請求 localhast:8080/get_user/test,由於沒許可權會直接跳轉到登入介面,我們先輸入錯誤的賬號密碼,看下認證失敗是否與我們總結的一致。
結果與預想時一致的,也許你會奇怪這裡的提示為啥時中文,這就不得不說Security 5 開始支援 中文,說明咋中國程式設計師在世界上越來越有地位了!!!
這次輸入正確的密碼,看下返回的Authtication 物件資訊:
可以看到這次成功返回一個 authticated = ture ,沒有密碼的 user賬戶資訊,而且還包含我們定義的一個admin許可權資訊。放開斷點,由於Security預設的成功處理器是SimpleUrlAuthenticationSuccessHandler ,這個處理器會重定向到之前訪問的地址,也就是 localhast:8080/get_user/test。 至此整個流程結束。不,我們還差一個,Session,我們從瀏覽器Cookie中看到 Session:
3、BasicAuthenticationFilter
BasicAuthenticationFilter 與UsernameAuthticationFilter類似,不過區別還是很明顯,BasicAuthenticationFilter 主要是從Header 中獲取 Authorization 引數資訊,然後呼叫認證,認證成功後最後直接訪問介面,不像UsernameAuthticationFilter過程一樣通過AuthenticationSuccessHandler 進行跳轉。這裡就不在貼程式碼了,想了解的同學可以直接看原始碼。不過有一點要注意的是,BasicAuthenticationFilter 的 onSuccessfulAuthentication() 成功處理方法是一個空方法。
為了試驗BasicAuthenticationFilter,我們需要將 SpringSecurityConfig 中的formLogin()更換成httpBasic()以支援BasicAuthenticationFilter,重啟專案,同樣訪問 localhast:8080/get_user/test,這時由於沒許可權訪問這個介面地址,頁面上會彈出一個登陸框,熟悉Security4的同學一定很眼熟吧,同樣,我們輸入賬戶密碼後,看下debug資料:
這時,我們就能夠獲取到 Authorization 引數,進而解析獲取到其中的賬戶和密碼資訊,進行認證,我們檢視認證成功後返回的Authtication物件資訊其實是和UsernamePasswordAuthticationFilter中的一致,最後再次呼叫下一個filter,由於已經認證成功了會直接進入FilterSecurityInterceptor 進行許可權驗證。
4、AnonymousAuthenticationFilter
這裡為什麼要提下 AnonymousAuthenticationFilter呢,主要是因為在Security中不存在沒有賬戶這一說法(這裡可能描述不是很清楚,但大致意思是這樣的),針對這個Security官方專門指定了這個AnonymousAuthenticationFilter ,用於前面所有filter都認證失敗的情況下,自動建立一個預設的匿名使用者,擁有匿名訪問許可權。還記得 在講解 SecurityContextPersistenceFilter 時我們看到得匿名 autication資訊麼?如果不記得還得回頭看下哦,這裡就不再敘述了。
5、ExceptionTranslationFilter
ExceptionTranslationFilter 其實沒有做任何過濾處理,但別小看它得作用,它最大也最牛叉之處就在於它捕獲AuthenticationException 和AccessDeniedException,如果發生的異常是這2個異常 會呼叫 handleSpringSecurityException()方法進行處理。 我們模擬下 AccessDeniedException(無許可權,禁止訪問異常)情況,首先我們需要修改下 /get_user 介面:
- 在Controller 上新增 @EnableGlobalMethodSecurity(prePostEnabled =true) 啟用Security 方法級別得許可權控制
- 在 介面上新增 @PreAuthorize("hasRole('user')") 只允許有user角色得賬戶訪問(還記得我們預設得user 賬戶時admin角色麼?)
@RestController
@EnableGlobalMethodSecurity(prePostEnabled =true) // 開啟方法級別的許可權控制
public class TestController {
@PreAuthorize("hasRole('user')") //只允許user角色訪問
@GetMapping("/get_user/{username}")
public String getUser(@PathVariable String username){
return username;
}
}
複製程式碼
重啟專案,重新訪問 /get_user 介面,輸入正確的賬戶密碼,發現返回一個 403 狀態的錯誤頁面,這與我們之前將的流程時一致的。debug,看下處理:
可以明顯的看到異常物件是 AccessDeniedException ,異常資訊是不允許訪問,我們再看下 AccessDeniedException 異常後的處理方法accessDeniedHandler.handle(),進入到了 AccessDeniedHandlerImpl 的handle()方法,這個方法會先判斷系統是否配置了 errorPage (錯誤頁面),沒有的話直接往 response 中設定403 狀態碼。
6、FilterSecurityInterceptor
FilterSecurityInterceptor 是整個Security filter鏈中的最後一個,也是最重要的一個,它的主要功能就是判斷認證成功的使用者是否有許可權訪問介面,其最主要的處理方法就是 呼叫父類(AbstractSecurityInterceptor)的 super.beforeInvocation(fi),我們來梳理下這個方法的處理流程:
- 通過 obtainSecurityMetadataSource().getAttributes() 獲取 當前訪問地址所需許可權資訊
- 通過 authenticateIfRequired() 獲取當前訪問使用者的許可權資訊
- 通過 accessDecisionManager.decide() 使用 投票機制判權,判權失敗直接丟擲 AccessDeniedException 異常
protected InterceptorStatusToken beforeInvocation(Object object) {
......
// 1 獲取訪問地址的許可權資訊
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
if (attributes == null || attributes.isEmpty()) {
......
return null;
}
......
// 2 獲取當前訪問使用者許可權資訊
Authentication authenticated = authenticateIfRequired();
try {
// 3 預設呼叫AffirmativeBased.decide() 方法,其內部 使用 AccessDecisionVoter 物件 進行投票機制判權,判權失敗直接丟擲 AccessDeniedException 異常
this.accessDecisionManager.decide(authenticated,object,attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object,attributes,authenticated,accessDeniedException));
throw accessDeniedException;
}
......
return new InterceptorStatusToken(SecurityContextHolder.getContext(),false,object);
}
複製程式碼
整個流程其實看起來不復雜,主要就分3個部分,首選獲取訪問地址的許可權資訊,其次獲取當前訪問使用者的許可權資訊,最後通過投票機制判斷出是否有權。
四、 個人總結
整個授權流程核心的就在於這幾次核心filter的處理,這裡我用序列圖來概況下這個授權流程
複製程式碼
(PS: 如果圖片展示不清楚,可訪問專案的 github 地址)
本文介紹授權過程的程式碼可以訪問程式碼倉庫中的 security 模組 ,專案的github 地址 : github.com/BUG9/spring…
如果您對這些感興趣,歡迎star、follow、收藏、轉發給予支援!
歡迎繼續閱讀下一篇 Spring Security 解析(二) —— 認證過程