[譯]Spring Security Architecture

LeeR發表於2019-04-21

原文

也可以在我的部落格上檢視

雖然這篇文章也有其他的翻譯,但是看起來像是機翻的,所以我自己翻譯了一下,並且加上自己的理解。如果有理解錯誤的地方或者錯別字,還望指出。

本文是 Spring Security 的入門指南,深入解析了 Spring Security 框架的設計和基礎模組。我們僅涉及程式安全性的基礎知識,但是這可以幫助使用 Spring Security 的開發者解開一些疑惑。為此,我們如何使用 filter 和方法註解來實踐 web 應用的安全。如果你想在更高層次上理解如何保障應用安全性,或者想要定製應用安全,又或者你只是想了解設計應用安全的思路,那麼本指南就很適合你。

本指南不是解決最基本問題之外的使用者手冊(這樣文章已經有很多了),但是本文對初學者和高手都有一定的幫助。Spring Boot 在本文中經常被提及,因為它為 Spring Security 提供了一些預設配置並且這有助於理解 Spring Security 是如何適應整個架構的。而所有這些原則對不使用 Spring Boot 的應用同樣適用。

認證和訪問控制

應用安全總結起來就是兩大問題:認證(authentication,你是誰?)和授權(authorization,允許你做什麼?)有時候也會用訪問控制(access control)這個名詞來代替授權,這會讓我們一些困惑,但以這種方式思考可能會有幫助:“授權”在其他地方已經實現了。Spring Security 的架構旨在將認證從授權中分離出來,並也有適用於兩者的策略和可擴充套件的設計。

認證(Authentication)

用於認證的主要介面是AuthenticationManager,它只有一個方法:

public interface AuthenticationManager {
  Authentication authenticate(Authentication authentication)
    throws AuthenticationException;
}
複製程式碼

一個 AuthenticationManagerauthenticate()方法中有三種情況:

  1. 返回 Authentication (authenticated=true),如果驗證輸入是合法的Principal)。
  2. 丟擲AuthenticationException異常,如果輸入不合法。
  3. 如果無法判斷,則返回null

AuthenticationException是一個執行時異常,通常被應用程式以常規的方式的處理,這取決於應用的母的和程式碼風格。換句話說,程式碼中一般不會捕捉和處理這個異常。比如,可以使得網頁顯示認證失敗,後端返回 401 HTTP 狀態碼,響應頭中的WWW-Authenticate 有無視情況而定。

AuthenticationManager最普遍的實現是ProviderManagerProviderManager將認證委託給一系列的AuthenticationProvider例項 。AuthenticationProviderAuthenticationManager 很類似,但是它有一個額外的方法允許查詢它支援的Authentication方式:

public interface AuthenticationProvider {
	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;
  
	boolean supports(Class<?> authentication);
}
複製程式碼

supports方法的Class<?> authentication引數其實是Class<? extends Authentication>型別的。一個ProviderManager在一個應用中能支援多種不同的認證機制,通過將認證委託給一系列的AuthenticationProviderProviderManager沒有識別出的認證型別,將會被忽略。

每個ProviderManager可以有一個父類,如果所有AuthenticationProvider都返回null,那麼就交給父類去認證。如果父類也不可用,則丟擲AuthenticationException異常。

有時應用的資源會有邏輯分組(比如所有網站資源都匹配URL/api/**),並且每個組都有自己的AuthenticationManager,通常是一個ProviderManager,它們之間有共同的父類認證器。那麼父類就是一種全域性資源,充當所有認證器的 fallback。

authentication

圖1 ProviderManager 的繼承關係

自定義AuthenticationManager

Spring Security 提供了一些配置方式幫助你快速的配置通用的AuthenticationManager。最常見的是AuthenticationManagerBuilder,它可以使用記憶體方式(in-memory)、JDBC 或 LDAP、或自定義的UserDetailService來認證使用者。下面是設定全域性認證器的例子:

@Configuration
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {

   ... // web stuff here

  @Autowired
  public initialize(AuthenticationManagerBuilder builder, DataSource dataSource) {
    builder.jdbcAuthentication().dataSource(dataSource).withUser("dave")
      .password("secret").roles("USER");
  }

}
複製程式碼

雖然這個例子僅僅設計一個 web 應用,但是AuthenticationManagerBuilder的用處大為廣闊(詳細情況請看[Web 安全](#Web 安全)是如何實現的)。請注意AuthenticationManagerBuilder是通過@AutoWired注入到被@Bean註解的一個方法中的,這使得它成為一個全域性AuthenticationManager。相反的,如果我們這樣寫:

@Configuration
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {

  @Autowired
  DataSource dataSource;

   ... // web stuff here

  @Override
  public configure(AuthenticationManagerBuilder builder) {
    builder.jdbcAuthentication().dataSource(dataSource).withUser("dave")
      .password("secret").roles("USER");
  }

}
複製程式碼

重寫configure(AuthenticationManagerBuilder builder)方法,那麼AuthenticationManagerBuilder僅會構造一個“本地”的AuthenticationManager,只是全域性認證器的一個子實現。在 Spring Boot 應用中你可以使用@Autowired注入全域性的AuthenticationManager,但是你不能注入“本地”的,除非你自己公開暴露它。

Spring Boot 提供預設的全域性AuthenticationManager,除非你提供自己的全域性AuthenticationManager。不用擔心,預設的已經足夠安全了,除非你真的需要一個自定義的全域性AuthenticationManager。一般的,你只需只用“本地”的AuthenticationManagerBuilder來配置,而不需要擔心全域性的。

授權(Authorization)

一旦認證成功,我們就可以進行授權了,它核心的策略就是AccessDecisionManager。它提供三個方法並且全部委託給AccessDecisionVoter,這有點像ProviderManager將認證委託給AuthenticationProvider

一個AccessDecisionVoter考慮一個Authentication(代表一個Principal)和一個被ConfigAttributes裝飾的安全物件:

boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);

int vote(Authentication authentication, S object,
        Collection<ConfigAttribute> attributes);
複製程式碼

AccessDecisionVoterAccessDecisionManager方法中的object引數是完全泛型化的,它代表任何使用者想要訪問(web 資源或 Java 方法是最常見的兩種情況)。ConfigAttributes也是相當泛型化的,它表示一個被裝飾的安全物件並帶有訪問許可權級別的後設資料。ConfigAttributes是一個介面,僅有一個返回String的方法,返回的字串中包含資源所有者,解釋了訪問資源的規則。常見的ConfigAttributes是使用者的角色(比如ROLE_ADMINROLE_AUDIT),它們通常有一定的格式(比如以ROLE_作為字首)或者是可計算的表示式。

大部分人使用預設的AccessDecisionManager,即AffirmativeBased(如果沒有 voters 返回那麼該訪問將被授權)。任何自定義的行為最好放在 voter 中,不亂世新增一個新的 voter 還是修改已有的 voter。

使用 Spring Expression Language(SpEL)表示式的ConfigAttributes是很常見的,比如isFullyAuthenticated() && hasRole('FOO')。解析表示式和載入表示式由AccessDecisionVoter實現。要擴充套件可處理的表示式的範圍,需要自定義SecurityExpressionRoot,有時候也需要SecurityExpressionHandler

Web 安全

Web 層中的 Spring Security 基於 Servlet 的Filter。所以先來看下Filter在 web 安全中所扮演的角色。下圖展示了處理單個 HTTP 請求的經典分層結構。

filters

客戶端嚮應用傳送請求,然後容器根據 URI 來決定哪個 filter(過濾器) 和哪個 Servlet 適用於它。一個 servlet 最多處理一個請求,過濾器是鏈式的,它們是有順序的。事實上一個過濾器可以否決接下來的過濾器,如果它想獨自處理這個請求的話。一個過濾器也可以對下流的過濾器和 servlet 修改響應和請求。所以過濾器的順序十分重要,Spring Boot 提供管理過濾器的兩種機制:一個是被@Bean註解的Filter可以用@Order註解或實現Ordered介面;另一個是過濾器是FilterRegistrationBean的一部分,它本身就有一個順序。一些現有的過濾器定義了自己的常量來表示順序,以幫助表明他們相對於彼此的順序(比如 Spring Session 中的SessionRepositoryFilterDEFAULT_ORDER的值為Integer.MIN_VALUE + 50,它表示這個過濾器相對的在過濾鏈的前端,但是也不排斥在它之前的過濾器,前面還剩下50個位置)。

Spring Security 在過濾鏈中表現為一個Filter,其型別是FilterChainProxy,原因你很快就會知道。在一個 Spring Boot 應用中安全過濾器是ApplicationContext中的一個Bean,並且它是預設配置的,所以在每次請求中都會存在。而它在過濾鏈中的位置由SecurityProperties.DEFAULT_FILTER_ORDER決定,而該位置又由FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER(值為0,是 Spring Boot 中改變請求行為的過濾器的順序的最大值)錨定。(譯者注:Order的值越小,越在過濾鏈的前端)。除此之外,從容器的角度來看 Spring Security 是一個單獨的過濾器,但是其中包含了額外過濾器,每個過濾器都發揮特殊的作用,如下圖所示:

security-filters

圖2 Spring Security 是一個單獨的物理過濾器,但是它將請求委託一系列的內部過濾器

事實上內部的安全過濾器不止一個層次結構:它們通常是DelegatingFilterProxy,不需要是一個 Spring Bean。代理委託給FilterChainProxy,而它是一個Bean,bean 的名字通常是springSecurityFilterChainFilterChainProxy包含了所有內部安全過濾器,並且以一定順序排列成過濾鏈。其中所有的過濾器都有相同的 API(它們都實現了Servlet規範的Filter介面),它們都有機會否決過濾鏈的下流部分。

Spring Security 可以在同一頂層FilterChainProxy中管理多個過濾器鏈,並且對容器來說都是未知的。Spring Security Filter 包含了一系列的過濾鏈,並且向這些鏈分發匹配它們的請求。下圖展示了根據請求路徑來分發(/foo/**/**之前匹配)。這是一種常見但不是唯一的分發方式。最重要的特徵是,分發過程中,只有一條過濾鏈只處理該請求。

security-filters-dispatch

圖3 Spring Security FilterChainProxy 分發請求給首先匹配的過濾鏈。

一個純淨的(沒有自定義安全配置的) Spring Boot 應用通常有 n 條過濾鏈,n = 6。第一條鏈(n-1)是忽略靜態資源的,比如/css/**/images/**,和錯誤頁面/error(這些路徑可以在SecurityProperties中的security.ignored裡配置)。最後一條鏈匹配所有路徑/**,並且包含認證邏輯、授權、異常處理、session 處理,響應頭處理等。這條過濾鏈中預設一共有 11 個過濾器,我們一般不關心使用哪個過濾器以及在何時使用他們。

注意:意識到 Spring Security 的內部過濾器對容器是透明的這是很重要的,所有的Filter都以@Bean的方式自動註冊到容器中。所以如果你想在安全過濾鏈中新增過濾器,你不需要使用@Bean註解或將其包裹在顯示禁用容器註冊的FilterRegistrationBean中。

建立和自定義過濾鏈

Spring Boot 中預設的 fallback 過濾鏈(使用/**匹配的過濾鏈)有一個預定義的順序SecurityProperties.BASIC_AUTH_ORDER。你可以使用security.base.enabled=false關閉它,或者你可以定義一個更低的順序值(譯者注:越低的值表示順序更前,所以它的順序在預設的 fallback 之前)。只要新增一個WebSecurityConfigurerAdapterWebSecurityConfigurer的 Bean 然後用@Order註解。比如:

@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)
public class ApplicationConfigurerAdapter extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.antMatcher("/foo/**")
     ...;
  }
}
複製程式碼

這個 Bean 將導致 Spring Security 在預設的 fallback 之前新增一個過濾鏈。

許多應用中對一組資源的和另一組資源的訪問規則可能大不相同。比如一個有前端頁面和後端 API 的應用支援基於 cookie 的認證將使用者重定向到登入介面,同時也支援基於 token 的認證,認證失敗將返回 401 響應碼。每組資源有它自己的WebSecurityConfigurerAdapter,並且有這唯一的順序和他自己的請求匹配規則。如果匹配規則重疊,則匹配順序最前的過濾鏈。

請求匹配分發和授權

一條安全過濾鏈(等價的 ,就是一個WebSecurityConfigurerAdapter)擁有一個請求匹配規則用來匹配 HTTP 請求。一旦有應用了一條過濾鏈,則其他過濾鏈就不會使用。但在一條過濾鏈中,你可以通過HttpSecurity更細的粒度上配置匹配規則。比如:

@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)
public class ApplicationConfigurerAdapter extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.antMatcher("/foo/**")
      .authorizeRequests()
        .antMatchers("/foo/bar").hasRole("BAR")
        .antMatchers("/foo/spam").hasRole("SPAM")
        .anyRequest().isAuthenticated();
  }
}
複製程式碼

配置 Spring Security 最容易犯的一個錯誤就是忘記匹配規則可以應用在不同的範圍中,一個是整條過濾鏈,另一個是應用於過濾鏈匹配規則中的規則。

將應用安全規則與 Actuator 規則結合

略,我沒用過 Actuator,所以就沒翻譯

Method 安全

Spring Security 在支援 web 安全的同時,也提供了對 Java 方法執行的訪問規則。對於 Spring Security 來說,方法只是一種不同型別的“資源”而已。對使用者來說,訪問規則在ConfigAttribute中有相同的格式(比如 角色 或者 表示式),但在程式碼中有不同的配置。第一步就是啟用方法安全,比如你可以在應用的啟動類上進行配置:

@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SampleSecureApplication {
}
複製程式碼

之後,便可以在方法上直接使用註解:

@Service
public class MyService {

  @Secured("ROLE_USER")
  public String secure() {
    return "Hello Security";
  }

}
複製程式碼

這個例子是一個有安全方法的服務。如果 Spring 建立了MyService Bean,那麼它將被代理,呼叫者必須在方法呼叫之前通過一個安全攔截器。如果訪問被拒絕,呼叫者會丟擲一個AccessDeniedException而不是執行這個方法的結果。

還有其他可用於強制執行安全約束的方法註解,特別是@PreAuthorize@PostAuthorize, 它們允許你在其中寫 SpEL 表示式並可以引用方法的引數和返回值。

提示: 把 web 安全和方法安全放在一起並不突兀。過濾鏈提供了使用者體驗特性,比如認證和重定向到登入介面。而方法安全在更細粒度級別上提供了保護。

Spring Security 和執行緒

Spring Security是執行緒繫結的,因為它需要保證當前的已認證的使用者(authenticated principal)對下流的消費者可用。基本構建塊是SecurityContext,它可能包含Authentication(當一個使用者登陸後,authenticated肯定是 true)。你總是可以從SecurityContextHolder中的靜態方法得到SecurityContext,它內部使用了ThreadLocal進行管理。

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
assert(authentication.isAuthenticated);
複製程式碼

這種操作並不常見,但是它可能對你有幫助。比如,你需要寫一個自定義的認證過濾器(儘管如此,Spring Security 中還有一些基類可用於避免使用SecurityContextHolder的地方)。

如果需要訪問 web endpoint(譯者注:對應響應的 URL) 中經過身份驗證的使用者,則可以在@RequestMapping中使用方法引數註解。例如:

@RequestMapping("/foo")
public String foo(@AuthenticationPrincipal User user) {
  ... // do stuff with user
}
複製程式碼

這個註解相當於從SecurityContext中獲得當前Authentication,並呼叫getPrincipal()方法賦值給方法引數。Authentication中的Principal取決與用來認證的AuthenticationManager,所以這對於獲得對使用者資料型別的安全引用來說是一個有用的小技巧。

如果使用了 Spring Security,那麼在HttpServletRequest中的Principal將是Authentication型別,因此你也可以直接使用它:

@RequestMapping("/foo")
public String foo(Principal principal) {
  Authentication authentication = (Authentication) principal;
  User = (User) authentication.getPrincipal();
  ... // do stuff with user
}
複製程式碼

如果你需要編寫在沒有使用 Spring Security 的情況下的程式碼,那麼這會很有用(你需要在載入Authentication類時更加謹慎)。

非同步執行安全方法

因為SecurityContext是執行緒繫結的,所以如果你想在後臺執行安全方法,比如使用@Async,你需要確保上下文的傳遞。這總結起來就是將SecurityContextRunnableCallable等包裹起來在後臺執行。Spring Security 提供了一些幫助使之變得簡單,比如RunnableCallable的包裝器。 要將 SecurityContext 傳遞到@Async註解的方法,你需要編寫 AsyncConfigurer 並確保 Executor 的正確性:

@Configuration
public class ApplicationConfiguration extends AsyncConfigurerSupport {

  @Override
  public Executor getAsyncExecutor() {
    return new DelegatingSecurityContextExecutorService(Executors.newFixedThreadPool(5));
  }
}
複製程式碼

相關文章