架構

本節討論 Spring Security 在基於 Servlet 的應用程式中的高階架構。我們在參考文件的 驗證授權防止漏洞攻擊 章節中,以此高階理解為基礎進行擴展。

Filter 的回顧

Spring Security 的 Servlet 支援是基於 Servlet Filter,因此首先了解 Filter 的一般角色會很有幫助。下圖顯示了單個 HTTP 請求處理程序的典型分層。

filterchain
圖 1. FilterChain

用戶端向應用程式發送請求,容器建立一個 FilterChain,其中包含應根據請求 URI 路徑處理 HttpServletRequestFilter 實例和 Servlet。在 Spring MVC 應用程式中,ServletDispatcherServlet 的實例。最多只能有一個 Servlet 處理單個 HttpServletRequestHttpServletResponse。但是,可以使用多個 Filter

  • 防止下游 Filter 實例或 Servlet 被調用。在這種情況下,Filter 通常會寫入 HttpServletResponse

  • 修改下游 Filter 實例和 Servlet 使用的 HttpServletRequestHttpServletResponse

Filter 的強大之處來自傳遞給它的 FilterChain

FilterChain 用法範例
  • Java

  • Kotlin

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	// do something before the rest of the application
    chain.doFilter(request, response); // invoke the rest of the application
    // do something after the rest of the application
}
fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
    // do something before the rest of the application
    chain.doFilter(request, response) // invoke the rest of the application
    // do something after the rest of the application
}

由於 Filter 只影響下游 Filter 實例和 Servlet,因此每個 Filter 被調用的順序非常重要。

DelegatingFilterProxy

Spring 提供了一個名為 DelegatingFilterProxyFilter 實作,允許在 Servlet 容器的生命週期和 Spring 的 ApplicationContext 之間建立橋樑。Servlet 容器允許使用自己的標準註冊 Filter 實例,但它不知道 Spring 定義的 Bean。您可以透過標準 Servlet 容器機制註冊 DelegatingFilterProxy,但將所有工作委派給實作 Filter 的 Spring Bean。

下圖顯示了 DelegatingFilterProxy 如何融入 Filter 實例和 FilterChain

delegatingfilterproxy
圖 2. DelegatingFilterProxy

DelegatingFilterProxyApplicationContext 查找 Bean Filter0,然後調用 Bean Filter0。以下列表顯示了 DelegatingFilterProxy 的虛擬程式碼

DelegatingFilterProxy 虛擬程式碼
  • Java

  • Kotlin

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
	Filter delegate = getFilterBean(someBeanName); (1)
	delegate.doFilter(request, response); (2)
}
fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
	val delegate: Filter = getFilterBean(someBeanName) (1)
	delegate.doFilter(request, response) (2)
}
1 延遲取得註冊為 Spring Bean 的 Filter。對於 DelegatingFilterProxy 中的範例,delegateBean Filter0 的實例。
2 將工作委派給 Spring Bean。

DelegatingFilterProxy 的另一個好處是它允許延遲查找 Filter bean 實例。這很重要,因為容器需要在容器啟動之前註冊 Filter 實例。但是,Spring 通常使用 ContextLoaderListener 來載入 Spring Bean,這在需要註冊 Filter 實例之後才會完成。

FilterChainProxy

Spring Security 的 Servlet 支援包含在 FilterChainProxy 中。FilterChainProxy 是 Spring Security 提供的一個特殊的 Filter,它允許透過 SecurityFilterChain 委派給多個 Filter 實例。由於 FilterChainProxy 是一個 Bean,因此它通常被包裝在 DelegatingFilterProxy 中。

下圖顯示了 FilterChainProxy 的角色。

filterchainproxy
圖 3. FilterChainProxy

SecurityFilterChain

SecurityFilterChainFilterChainProxy 用於確定應該為當前請求調用哪些 Spring Security Filter 實例。

下圖顯示了 SecurityFilterChain 的角色。

securityfilterchain
圖 4. SecurityFilterChain

SecurityFilterChain 中的 安全性 Filter 通常是 Bean,但它們是向 FilterChainProxy 而不是 DelegatingFilterProxy 註冊的。與直接向 Servlet 容器或 DelegatingFilterProxy 註冊相比,FilterChainProxy 提供了許多優勢。首先,它為 Spring Security 的所有 Servlet 支援提供了起點。因此,如果您嘗試對 Spring Security 的 Servlet 支援進行疑難排解,在 FilterChainProxy 中新增一個偵錯點是一個很好的起點。

其次,由於 FilterChainProxy 是 Spring Security 使用的核心,它可以執行不被視為可選的任務。例如,它會清除 SecurityContext 以避免記憶體洩漏。它還應用 Spring Security 的 HttpFirewall 來保護應用程式免受某些類型的攻擊。

此外,它在確定何時應調用 SecurityFilterChain 方面提供了更大的彈性。在 Servlet 容器中,Filter 實例僅根據 URL 調用。但是,FilterChainProxy 可以使用 RequestMatcher 介面根據 HttpServletRequest 中的任何內容確定調用。

下圖顯示了多個 SecurityFilterChain 實例

multi securityfilterchain
圖 5. 多個 SecurityFilterChain

多個 SecurityFilterChain 圖中,FilterChainProxy 決定應使用哪個 SecurityFilterChain。僅調用第一個匹配的 SecurityFilterChain。如果請求的 URL 為 /api/messages/,它首先匹配 SecurityFilterChain0/api/** 模式,因此僅調用 SecurityFilterChain0,即使它也匹配 SecurityFilterChainn。如果請求的 URL 為 /messages/,它不匹配 SecurityFilterChain0/api/** 模式,因此 FilterChainProxy 繼續嘗試每個 SecurityFilterChain。假設沒有其他 SecurityFilterChain 實例匹配,則調用 SecurityFilterChainn

請注意,SecurityFilterChain0 僅配置了三個安全性 Filter 實例。但是,SecurityFilterChainn 配置了四個安全性 Filter 實例。重要的是要注意,每個 SecurityFilterChain 都可以是唯一的,並且可以獨立配置。實際上,如果應用程式希望 Spring Security 忽略某些請求,則 SecurityFilterChain 可能具有零個安全性 Filter 實例。

安全性 Filter

安全性 Filter 使用 SecurityFilterChain API 插入到 FilterChainProxy 中。這些 Filter 可用於多種不同的目的,例如 驗證授權漏洞保護 等。Filter 以特定順序執行,以確保它們在正確的時間被調用,例如,執行驗證的 Filter 應在執行授權的 Filter 之前調用。通常沒有必要了解 Spring Security 的 Filter 的順序。但是,如果您想了解順序,有時了解順序是有益的,您可以查看 FilterOrderRegistration 程式碼

為了舉例說明上述段落,讓我們考慮以下安全性組態

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

}
import org.springframework.security.config.web.servlet.invoke

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { }
            authorizeHttpRequests {
                authorize(anyRequest, authenticated)
            }
            httpBasic { }
            formLogin { }
        }
        return http.build()
    }

}

上述組態將產生以下 Filter 順序

Filter 新增者

CsrfFilter

HttpSecurity#csrf

UsernamePasswordAuthenticationFilter

HttpSecurity#formLogin

BasicAuthenticationFilter

HttpSecurity#httpBasic

AuthorizationFilter

HttpSecurity#authorizeHttpRequests

  1. 首先,調用 CsrfFilter 以防止 CSRF 攻擊

  2. 其次,調用驗證 Filter 以驗證請求。

  3. 第三,調用 AuthorizationFilter 以授權請求。

可能還有其他未在上面列出的 Filter 實例。如果您想查看為特定請求調用的 Filter 列表,您可以列印它們

列印安全性 Filter

通常,查看為特定請求調用的安全性 Filter 列表很有用。例如,您想確保您新增的 Filter 在安全性 Filter 的列表中。

Filter 列表在應用程式啟動時以 INFO 級別列印,因此您可以在主控台輸出上看到類似以下的內容

2023-06-14T08:55:22.321-03:00  INFO 76975 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]

這將讓您很好地了解為每個 filter chain 配置的安全性 Filter。

但這還不是全部,您還可以將應用程式配置為列印每個請求的每個個別 filter 的調用。這有助於查看您新增的 filter 是否針對特定請求被調用,或檢查異常來自何處。為此,您可以將您的應用程式配置為記錄安全性事件

向 Filter Chain 新增自訂 Filter

大多數時候,預設的安全性 Filter 足以為您的應用程式提供安全性。但是,有時您可能想要向安全性 filter chain 新增自訂 Filter

例如,假設您想要新增一個 Filter,它取得租戶 ID 標頭並檢查當前使用者是否有權存取該租戶。先前的描述已經給了我們一個關於在哪裡新增 filter 的線索,因為我們需要知道當前使用者,所以我們需要在驗證 filter 之後新增它。

首先,讓我們建立 Filter

import java.io.IOException;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.access.AccessDeniedException;

public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String tenantId = request.getHeader("X-Tenant-Id"); (1)
        boolean hasAccess = isUserAllowed(tenantId); (2)
        if (hasAccess) {
            filterChain.doFilter(request, response); (3)
            return;
        }
        throw new AccessDeniedException("Access denied"); (4)
    }

}

上面的範例程式碼執行以下操作

1 從請求標頭取得租戶 ID。
2 檢查當前使用者是否有權存取租戶 ID。
3 如果使用者有權存取,則調用 chain 中的其餘 filter。
4 如果使用者無權存取,則拋出 AccessDeniedException

您可以從 OncePerRequestFilter 擴展,而不是實作 Filter,後者是用於每個請求僅調用一次的 filter 的基底類別,並提供具有 HttpServletRequestHttpServletResponse 參數的 doFilterInternal 方法。

現在,我們需要將 filter 新增到安全性 filter chain。

  • Java

  • Kotlin

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class); (1)
    return http.build();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http
        // ...
        .addFilterBefore(TenantFilter(), AuthorizationFilter::class.java) (1)
    return http.build()
}
1 使用 HttpSecurity#addFilterBeforeAuthorizationFilter 之前新增 TenantFilter

透過在 AuthorizationFilter 之前新增 filter,我們確保 TenantFilter 在驗證 filter 之後被調用。您也可以使用 HttpSecurity#addFilterAfter 在特定 filter 之後新增 filter,或使用 HttpSecurity#addFilterAt 在 filter chain 中的特定 filter 位置新增 filter。

就是這樣,現在 TenantFilter 將在 filter chain 中被調用,並檢查當前使用者是否有權存取租戶 ID。

當您將您的 filter 宣告為 Spring bean 時要小心,無論是透過使用 @Component 註釋它,還是透過在您的組態中將其宣告為 bean,因為 Spring Boot 都會自動 將其註冊到嵌入式容器。這可能會導致 filter 被調用兩次,一次由容器調用,一次由 Spring Security 調用,並且順序不同。

如果您仍然想將您的 filter 宣告為 Spring bean 以利用依賴注入等優勢,並避免重複調用,您可以告訴 Spring Boot 不要將其註冊到容器,方法是宣告一個 FilterRegistrationBean bean 並將其 enabled 屬性設定為 false

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

處理安全性例外

ExceptionTranslationFilter 作為安全性 Filter 之一插入到 FilterChainProxy 中。

下圖顯示了 ExceptionTranslationFilter 與其他組件的關係

exceptiontranslationfilter
  • number 1 首先,ExceptionTranslationFilter 調用 FilterChain.doFilter(request, response) 以調用應用程式的其餘部分。

  • number 2 如果使用者未經過驗證,或者它是 AuthenticationException,則開始驗證

    • 清除 SecurityContextHolder

    • 儲存 HttpServletRequest,以便在驗證成功後可用於重播原始請求。

    • AuthenticationEntryPoint 用於向用戶端請求憑證。例如,它可能會重新導向到登入頁面或發送 WWW-Authenticate 標頭。

  • number 3 否則,如果是 AccessDeniedException,則拒絕存取。調用 AccessDeniedHandler 以處理拒絕存取。

如果應用程式未拋出 AccessDeniedExceptionAuthenticationException,則 ExceptionTranslationFilter 不會執行任何操作。

ExceptionTranslationFilter 的虛擬程式碼看起來像這樣

ExceptionTranslationFilter 虛擬程式碼
try {
	filterChain.doFilter(request, response); (1)
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		startAuthentication(); (2)
	} else {
		accessDenied(); (3)
	}
}
1 Filter 的回顧 中所述,調用 FilterChain.doFilter(request, response) 相當於調用應用程式的其餘部分。這表示,如果應用程式的另一部分(FilterSecurityInterceptor 或方法安全性)拋出 AuthenticationExceptionAccessDeniedException,則會在此處捕獲並處理。
2 如果使用者未經過驗證,或者它是 AuthenticationException開始驗證
3 否則,拒絕存取

在驗證之間儲存請求

處理安全性例外 中所示,當請求沒有驗證,並且是針對需要驗證的資源時,需要儲存已驗證資源的請求,以便在驗證成功後重新請求。在 Spring Security 中,這是透過使用 RequestCache 實作來儲存 HttpServletRequest 完成的。

RequestCache

HttpServletRequest 儲存在 RequestCache 中。當使用者成功驗證時,RequestCache 用於重播原始請求。RequestCacheAwareFilter 在使用者驗證後使用 RequestCache 取得儲存的 HttpServletRequest,而 ExceptionTranslationFilter 在偵測到 AuthenticationException 後,在將使用者重新導向到登入端點之前,使用 RequestCache 儲存 HttpServletRequest

預設情況下,使用 HttpSessionRequestCache。下面的程式碼示範了如何自訂 RequestCache 實作,該實作用於檢查 HttpSession 中是否存在名為 continue 的參數的已儲存請求。

RequestCache 僅在 continue 參數存在時才檢查儲存的請求
  • Java

  • Kotlin

  • XML

@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
	HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
	requestCache.setMatchingRequestParameterName("continue");
	http
		// ...
		.requestCache((cache) -> cache
			.requestCache(requestCache)
		);
	return http.build();
}
@Bean
open fun springSecurity(http: HttpSecurity): SecurityFilterChain {
    val httpRequestCache = HttpSessionRequestCache()
    httpRequestCache.setMatchingRequestParameterName("continue")
    http {
        requestCache {
            requestCache = httpRequestCache
        }
    }
    return http.build()
}
<http auto-config="true">
	<!-- ... -->
	<request-cache ref="requestCache"/>
</http>

<b:bean id="requestCache" class="org.springframework.security.web.savedrequest.HttpSessionRequestCache"
	p:matchingRequestParameterName="continue"/>

防止請求被儲存

您可能有很多原因不想將使用者的未驗證請求儲存在 session 中。您可能想要將該儲存卸載到使用者的瀏覽器上,或將其儲存在資料庫中。或者,您可能想要關閉此功能,因為您始終希望將使用者重新導向到首頁,而不是他們在登入前嘗試訪問的頁面。

為此,您可以使用 NullRequestCache 實作

防止請求被儲存
  • Java

  • Kotlin

  • XML

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}
@Bean
open fun springSecurity(http: HttpSecurity): SecurityFilterChain {
    val nullRequestCache = NullRequestCache()
    http {
        requestCache {
            requestCache = nullRequestCache
        }
    }
    return http.build()
}
<http auto-config="true">
	<!-- ... -->
	<request-cache ref="nullRequestCache"/>
</http>

<b:bean id="nullRequestCache" class="org.springframework.security.web.savedrequest.NullRequestCache"/>

RequestCacheAwareFilter

RequestCacheAwareFilter 使用 RequestCache 來重播原始請求。

記錄

Spring Security 在 DEBUG 和 TRACE 級別提供了所有安全性相關事件的全面記錄。這在偵錯您的應用程式時非常有用,因為對於安全性措施,Spring Security 不會將請求被拒絕的原因的任何詳細資訊新增到回應正文中。如果您遇到 401 或 403 錯誤,很可能您會找到一條記錄訊息,可以幫助您了解發生了什麼事。

讓我們考慮一個範例,其中使用者嘗試向啟用了 CSRF 保護 的資源發出 POST 請求,但沒有 CSRF 權杖。在沒有記錄的情況下,使用者將看到 403 錯誤,但沒有說明請求被拒絕的原因。但是,如果您為 Spring Security 啟用記錄,您將看到類似以下的記錄訊息

2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Securing POST /hello
2023-06-14T09:44:25.797-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/15)
2023-06-14T09:44:25.798-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/15)
2023-06-14T09:44:25.800-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/15)
2023-06-14T09:44:25.801-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/15)
2023-06-14T09:44:25.802-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/15)
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.csrf.CsrfFilter         : Invalid CSRF token found for https://127.0.0.1:8080/hello
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.s.w.access.AccessDeniedHandlerImpl   : Responding with 403 status code
2023-06-14T09:44:25.814-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]

很明顯,CSRF 權杖遺失,這就是請求被拒絕的原因。

要將您的應用程式配置為記錄所有安全性事件,您可以將以下內容新增到您的應用程式

Spring Boot 中的 application.properties
logging.level.org.springframework.security=TRACE
logback.xml
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <!-- ... -->
    </appender>
    <!-- ... -->
    <logger name="org.springframework.security" level="trace" additivity="false">
        <appender-ref ref="Console" />
    </logger>
</configuration>