Spring Security 解析(一) —— 受權過程

Spring Security 解析(一) —— 受權過程

  在學習Spring Cloud 時,遇到了受權服務oauth 相關內容時,老是隻知其一;不知其二,所以決定先把Spring Security 、Spring Security Oauth2 等權限、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程當中增強印象和理解所撰寫的,若有侵權請告知。html

項目環境:java

  • JDK1.8
  • Spring boot 2.x
  • Spring Security 5.x

1、 一個簡單的Security Demo

一、 自定義的UserDetailsService實現

  自定義MyUserDetailsUserService類,實現 UserDetailsService 接口的 loadUserByUsername()方法,這裏就簡單的返回一個Spring Security 提供的 User 對象。爲了後面方便演示Spring Security 的權限控制,這裏使用AuthorityUtils.commaSeparatedStringToAuthorityList("admin") 設置了user帳號有一個admin的角色權限信息。實際項目中能夠在這裏經過訪問數據庫獲取到用戶及其角色、權限信息。git

@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 也就是 BCryptPasswordEncodergithub

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);
    }
}
複製代碼

二、 設置Spring Security配置

  定義SpringSecurityConfig 配置類,並繼承WebSecurityConfigurerAdapter覆蓋其configure(HttpSecurity http) 方法。web

@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() 用戶登陸後可訪問

三、 配置html 和測試接口

   在 resources/static 目錄下新建 index.html , 其內部定義一個訪問測試接口的按鈕spring

<!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;
    }
}
複製代碼

四、 啓動項目測試

一、訪問 localhost:8080 無任何阻攔直接成功瀏覽器

image

二、點擊測試驗證權限控制按鈕 被重定向到了 Security默認的登陸頁面

三、使用 MyUserDetailsUserService定義的默認帳戶 user : 123456 進行登陸後成功跳轉到 /get_user 接口


2、 @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 進行受權過程的解析。

3、 受權過程解析

  Security的受權過程能夠理解成各類 filter 處理最終完成一個受權。那麼咱們再看下以前 打印的filter 鏈,這裏爲了方便,再次貼出圖片

  這裏咱們只關注如下幾個重要的 filter :

  • SecurityContextPersistenceFilter
  • UsernamePasswordAuthenticationFilter (AbstractAuthenticationProcessingFilter)
  • BasicAuthenticationFilter
  • AnonymousAuthenticationFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor

一、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默認建立了一個匿名用戶訪問。

二、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, response, failed);
			return;
		}
		catch (AuthenticationException failed) {
		    // 5 認證失敗後 調用 AuthenticationFailureHandler 的 onAuthenticationFailure 接口 進行失敗處理( 能夠 經過 繼承 AuthenticationFailureHandler 自行編寫失敗處理邏輯 )
			unsuccessfulAuthentication(request, response, failed);
			return;
		}
		
        ......
         // 6 認證成功後 調用 AuthenticationSuccessHandler 的 onAuthenticationSuccess 接口 進行失敗處理( 能夠 經過 繼承 AuthenticationSuccessHandler 自行編寫成功處理邏輯 )
		successfulAuthentication(request, response, chain, authResult);
	}
複製代碼

  從源碼上看,整個流程實際上是很清晰的:從判斷是否處理,到認證,最後判斷認證結果分別做出認證成功和認證失敗的處理。

  debug 調試下看 結果,此次咱們請求 localhast:8080/get_user/test , 因爲沒權限會直接跳轉到登陸界面,咱們先輸入錯誤的帳號密碼,看下認證失敗是否與咱們總結的一致。

image

  結果與預想時一致的,也許你會奇怪這裏的提示爲啥時中文,這就不得不說Security 5 開始支持 中文,說明咋中國程序員在世界上愈來愈有地位了!!!

   此次輸入正確的密碼, 看下返回的Authtication 對象信息:

image

   能夠看到此次成功返回一個 authticated = ture ,沒有密碼的 user帳戶信息,並且還包含咱們定義的一個admin權限信息。放開斷點,因爲Security默認的成功處理器是SimpleUrlAuthenticationSuccessHandler ,這個處理器會重定向到以前訪問的地址,也就是 localhast:8080/get_user/test。 至此整個流程結束。不,咱們還差一個,Session,咱們從瀏覽器Cookie中看到 Session:

image

三、BasicAuthenticationFilter

  BasicAuthenticationFilter 與UsernameAuthticationFilter相似,不過區別仍是很明顯,BasicAuthenticationFilter 主要是從Header 中獲取 Authorization 參數信息,而後調用認證,認證成功後最後直接訪問接口,不像UsernameAuthticationFilter過程同樣經過AuthenticationSuccessHandler 進行跳轉。這裏就不在貼代碼了,想了解的同窗能夠直接看源碼。不過有一點要注意的是,BasicAuthenticationFilter 的 onSuccessfulAuthentication() 成功處理方法是一個空方法。

   爲了試驗BasicAuthenticationFilter, 咱們須要將 SpringSecurityConfig 中的formLogin()更換成httpBasic()以支持BasicAuthenticationFilter,重啓項目,一樣訪問 localhast:8080/get_user/test,這時因爲沒權限訪問這個接口地址,頁面上會彈出一個登錄框,熟悉Security4的同窗必定很眼熟吧,一樣,咱們輸入帳戶密碼後,看下debug數據:

image

   這時,咱們就可以獲取到 Authorization 參數,進而解析獲取到其中的帳戶和密碼信息,進行認證,咱們查看認證成功後返回的Authtication對象信息實際上是和UsernamePasswordAuthticationFilter中的一致,最後再次調用下一個filter,因爲已經認證成功了會直接進入FilterSecurityInterceptor 進行權限驗證。

四、AnonymousAuthenticationFilter

  這裏爲何要提下 AnonymousAuthenticationFilter呢,主要是由於在Security中不存在沒有帳戶這一說法(這裏可能描述不是很清楚,但大體意思是這樣的),針對這個Security官方專門指定了這個AnonymousAuthenticationFilter ,用於前面全部filter都認證失敗的狀況下,自動建立一個默認的匿名用戶,擁有匿名訪問權限。還記得 在講解 SecurityContextPersistenceFilter 時咱們看到得匿名 autication信息麼?若是不記得還得回頭看下哦,這裏就再也不敘述了。

五、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,看下處理:

image

  能夠明顯的看到異常對象是 AccessDeniedException ,異常信息是不容許訪問,咱們再看下 AccessDeniedException 異常後的處理方法accessDeniedHandler.handle(),進入到了 AccessDeniedHandlerImpl 的handle()方法,這個方法會先判斷系統是否配置了 errorPage (錯誤頁面),沒有的話直接往 response 中設置403 狀態碼。

image

六、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,
					attributes, object);
	}
複製代碼

   整個流程其實看起來不復雜,主要就分3個部分,首選獲取訪問地址的權限信息,其次獲取當前訪問用戶的權限信息,最後經過投票機制判斷出是否有權。

4、 我的總結

整個受權流程核心的就在於這幾回核心filter的處理,這裏我用序列圖來概況下這個受權流程
複製代碼

image
(PS: 若是圖片展現不清楚,可訪問項目的 github 地址)

   本文介紹受權過程的代碼能夠訪問代碼倉庫中的 security 模塊 ,項目的github 地址 : github.com/BUG9/spring…

         若是您對這些感興趣,歡迎star、follow、收藏、轉發給予支持!


   歡迎繼續閱讀下一篇    Spring Security 解析(二) —— 認證過程

相關文章
相關標籤/搜索