Architecture

红拂夜奔發表於2024-09-05

Architecture

本節討論 Spring Security 在基於 Servlet 的應用程式中的高階體系結構。我們在參考的 Authentication, Authorization, and Protection against Exploits 部分中建立了這種高階理解。

A Review of Filters

Spring Security 的 Servlet 支援基於 Servlet 過濾器,因此通常首先檢視過濾器的角色會很有幫助。下圖顯示了單個 HTTP 請求的處理程式的典型分層。

客戶端嚮應用程式傳送請求,容器建立一個 FilterChain,其中包含 Filter 例項和應根據請求 URI 的路徑處理 HttpServletRequest 的 Servlet。在 Spring MVC 應用程式中,Servlet 是 DispatcherServlet 的例項。一個 Servlet 最多可以處理單個 HttpServletRequest 和 HttpServletResponse。但是,可以使用多個 Filter 來:

  • Prevent downstream Filter instances or the Servlet from being invoked. In this case, the Filter typically writes the HttpServletResponse.
    • 防止下游 Filter 例項或 Servlet 被呼叫。在這種情況下,Filter 通常會寫入 HttpServletResponse。
  • Modify the HttpServletRequest or HttpServletResponse used by the downstream Filter instances and the Servlet.
    • 修改下游 Filter 例項和 Servlet 使用的 HttpServletRequest 或 HttpServletResponse。

Filter 的強大功能來自傳遞給它的 FilterChain。

FilterChain Usage Example

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
}

由於 Filter 僅影響下游 Filter 例項和 Servlet,因此呼叫每個 Filter 的順序非常重要。

DelegatingFilterProxy(委派篩選器代理器)

Spring 提供了一個名為 DelegatingFilterProxy 的Filter實現,它允許在 Servlet 容器的生命週期和 Spring 的ApplicationContext之間進行橋接。Servlet 容器允許使用自己的標準註冊 Filter 例項,但它不知道 Spring 定義的 Bean。你可以透過標準的 Servlet 容器機制註冊DelegatingFilterProxy,但將所有工作委託給實現Filter的 Spring Bean。這是 DelegatingFilterProxy 如何適應 Filter 例項和 FilterChain 的圖片。

DelegatingFilterProxy從ApplicationContext中查詢 Bean Filter0,然後呼叫 Bean Filter0。下面的清單顯示了DelegatingFilterProxy的虛擬碼:

DelegatingFilterProxy Pseudo Code

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    Filter delegate = getFilterBean(someBeanName);①
    delegate.doFilter(request, response);②
}

① 延遲獲取已註冊為 Spring Bean 的 Filter。對於 DelegatingFilterProxy 中的示例,委託是 Bean Filter0 的例項。

② 將工作委託給 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 的角色。

SecurityFilterChain

FilterChainProxy 用 SecurityFilterChain 來確定應該為當前請求呼叫哪些 Spring Security Filter 例項。下圖顯示了 SecurityFilterChain 的角色。

SecurityFilterChain 中的安全過濾器通常是 Bean,但它們是使用 FilterChainProxy 而不是 DelegatingFilterProxy 註冊的。FilterChainProxy為直接向 Servlet 容器或DelegatingFilterProxy註冊提供了許多優勢。首先,它為 Spring Security 的所有 Servlet 支援提供了一個起點。因此,如果你嘗試對 Spring Security 的 Servlet 支援進行故障排除,那麼在FilterChainProxy中新增一個除錯點是一個很好的起點。

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

此外,它在確定何時應呼叫 SecurityFilterChain 方面提供了更大的靈活性。在 Servlet 容器中,僅基於 URL 呼叫 Filter 例項。但是,FilterChainProxy可以透過使用RequestMatcher介面根據HttpServletRequest中的任何內容來確定呼叫。

下圖顯示了多個 SecurityFilterChain 例項:

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

請注意,SecurityFilterChain0 只配置了三個安全 Filter 例項。但是,SecurityFilterChainn 配置了四個安全篩選器例項。請務必注意,每個 SecurityFilterChain 可以是唯一的,並且可以單獨配置。實際上,如果應用程式希望 Spring Security 忽略某些請求,則SecurityFilterChain可能具有零安全Filter例項。

Security Filters

安全篩選器使用 SecurityFilterChain API 插入到 FilterChainProxy 中。這些過濾器可用於多種不同的目的,例如身份驗證、授權、漏洞利用保護等。過濾器按特定順序執行,以保證在正確的時間呼叫它們,例如,執行身份驗證的 Filter 應在執行授權的 Filter 之前呼叫。通常不需要知道 Spring Security 的過濾器的順序。但是,有時瞭解順序是有益的,如果您想了解它們,您可以檢視 FilterOrderRegistration 程式碼。

為了舉例說明上述段落,讓我們考慮以下安全配置:

@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();
    }

}

上述配置將導致以下 Filter 排序:

FilterAdded by

CsrfFilter

HttpSecurity#csrf

UsernamePasswordAuthenticationFilter

HttpSecurity#formLogin

BasicAuthenticationFilter

HttpSecurity#httpBasic

AuthorizationFilter

HttpSecurity#authorizeHttpRequests

  1. 首先,呼叫 CsrfFilter 來防止 CSRF 攻擊。
  2. 其次,呼叫身份驗證篩選器來驗證請求。
  3. 第三,呼叫 AuthorizationFilter 來授權請求。

Printing the Security Filters

通常,檢視為特定請求呼叫的安全過濾器列表非常有用。例如,您希望確保已新增的過濾器位於安全過濾器列表中。

篩選器列表在應用程式啟動時以 INFO 級別列印,因此您可以在控制檯輸出上看到類似於以下內容的內容,例如:

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

這將很好地瞭解為每個過濾器鏈配置的安全過濾器。

但這還不是全部,您還可以將應用程式配置為列印每個請求的每個單獨篩選條件的呼叫。這有助於檢視是否為特定請求呼叫了已新增的篩選器,或者檢查異常的來源。為此,您可以將應用程式配置為記錄安全事件。

Adding a Custom Filter to the Filter Chain

大多數情況下,預設安全篩選器足以為您的應用程式提供安全性。但是,有時您可能希望將自定義 Filter 新增到安全篩選器鏈中。

例如,假設您要新增一個 Filter 來獲取租戶 ID 標頭,並檢查當前使用者是否有權訪問該租戶。前面的描述已經給了我們在哪裡新增過濾器的線索,因為我們需要知道當前使用者,所以我們需要在認證過濾器之後新增它。

First, let’s create the 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. 如果使用者具有訪問許可權,則呼叫鏈中的其餘篩選器。
  4. 如果使用者沒有訪問許可權,則引發 AccessDeniedException。
Tip

你可以從 OncePerRequestFilter 擴充套件,而不是實現 Filter,OncePerRequestFilter 是每個請求僅呼叫一次的過濾器的基類,並提供帶有 HttpServletRequest 和 HttpServletResponse 引數的 doFilterInternal 方法。

現在,我們需要將過濾器新增到安全過濾器鏈中。

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
    return http.build();
}

① 使用 HttpSecurity#addFilterBefore 在 AuthorizationFilter 之前新增 TenantFilter。

透過在 AuthorizationFilter 之前新增過濾器,我們可以確保在身份驗證過濾器之後呼叫 TenantFilter。您還可以使用 HttpSecurity#addFilterAfter 在特定過濾器之後新增過濾器,或使用 HttpSecurity#addFilterAt 在過濾器鏈中的特定過濾器位置新增過濾器。

就是這樣,現在 TenantFilter 將在過濾器鏈中呼叫,並檢查當前使用者是否有權訪問租戶 ID。

將過濾器宣告為 Spring bean 時要小心,無論是用 @Component 註釋它,還是在配置中將其宣告為 bean,因為 Spring Boot 會自動將其註冊到嵌入式容器中。這可能會導致過濾器被呼叫兩次,一次由容器呼叫,一次由 Spring Security 呼叫,並且順序不同。

例如,如果你仍然想將過濾器宣告為 Spring Bean 以利用依賴關係注入,並避免重複呼叫,你可以透過宣告FilterRegistrationBean Bean 並將其enabled屬性設定為false來告訴 Spring Boot 不要在容器中註冊它:

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

Handling Security Exceptions

ExceptionTranslationFilter 允許將 AccessDeniedException 和 AuthenticationException 轉換為 HTTP 響應。

ExceptionTranslationFilter 作為安全篩選器之一插入到 FilterChainProxy 中。

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

  1. 首先,ExceptionTranslationFilter 呼叫 FilterChain.doFilter(request, response) 來呼叫應用程式的其餘部分。
  2. 如果使用者未透過身份驗證或為 AuthenticationException,則 Start Authentication (啟動身份驗證)。SecurityContextHolder被清除。HttpServletRequest 將被儲存,以便在身份驗證成功後可以使用它來重放原始請求。AuthenticationEntryPoint 用於從客戶端請求憑據。例如,它可能會重定向到登入頁面或傳送 WWW-Authenticate 標頭。
  3. 否則,如果它是 AccessDeniedException,則為 Access Denied。呼叫 AccessDeniedHandler 來處理被拒絕的訪問。
Tip

如果應用程式沒有丟擲AccessDeniedException或AuthenticationException,則ExceptionTranslationFilter不執行任何操作。

ExceptionTranslationFilter 的虛擬碼如下所示:

ExceptionTranslationFilter pseudocode

try {
    filterChain.doFilter(request, response);①
} catch (AccessDeniedException | AuthenticationException ex) {
    if (!authenticated || ex instanceof AuthenticationException) {
        startAuthentication();②
    } else {
        accessDenied();③
    }
}
  1. 如篩選器回顧 中所述,呼叫 FilterChain.doFilter(request, response) 等效於呼叫應用程式的其餘部分。這意味著,如果應用程式的另一部分(FilterSecurityInterceptor或方法安全性)丟擲AuthenticationException或AccessDeniedException,則會在此處捕獲並處理它。
  2. 如果使用者未經過身份驗證或為 AuthenticationException,則啟動 Authentication。
  3. 否則,Access Denied (訪問被拒絕)

Saving Requests Between Authentication

如處理安全異常中所述,當請求沒有身份驗證並且針對需要身份驗證的資源時,需要儲存請求,以便經過身份驗證的資源在身份驗證成功後重新請求。在 Spring Security 中,這是透過使用RequestCache實現儲存HttpServletRequest來完成的。

RequestCache

HttpServletRequest 儲存在 RequestCache 中。當使用者成功進行身份驗證時,將使用 RequestCache 重播原始請求。RequestCacheAwareFilter在使用者進行身份驗證後使用RequestCache來獲取儲存的HttpServletRequest,而ExceptionTranslationFilter在檢測到AuthenticationException後,在將使用者重定向到登入端點之前,使用RequestCache來儲存HttpServletRequest。

預設情況下,使用 HttpSessionRequestCache。下面的程式碼演示瞭如何自定義 RequestCache 實現,如果存在名為 continue 的引數,則用於檢查 HttpSession 中是否有已儲存的請求。

RequestCache Only Checks for Saved Requests if continue Parameter Present

@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
    requestCache.setMatchingRequestParameterName("continue");
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(requestCache)
        );
    return http.build();
}

Prevent the Request From Being Saved

出於多種原因,您可能希望不在會話中儲存使用者的未經身份驗證的請求。您可能希望將該儲存解除安裝到使用者的瀏覽器上或將其儲存在資料庫中。或者您可能希望關閉此功能,因為您總是希望將使用者重定向到主頁,而不是他們在登入前嘗試訪問的頁面。

為此,您可以使用 NullRequestCache 實現。

Prevent the Request From Being Saved

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}

RequestCacheAwareFilter

RequestCacheAwareFilter使用RequestCache重播原始請求。

Logging

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

讓我們考慮一個例子,使用者嘗試向啟用了 CSRF 保護的資源發出 POST 請求,但沒有 CSRF 令牌。如果沒有日誌,使用者將看到 403 錯誤,並且沒有解釋請求被拒絕的原因。但是,如果為 Spring Security 啟用日誌記錄,則會看到如下日誌訊息:

1 2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Securing POST /hello
2 2023-06-14T09:44:25.797-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/15)
3 2023-06-14T09:44:25.798-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/15)
4 2023-06-14T09:44:25.800-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderFilter (3/15)
5 2023-06-14T09:44:25.801-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/15)
6 2023-06-14T09:44:25.802-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (5/15)
7 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 http://localhost:8080/hello
8 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
9 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 令牌丟失了,這就是請求被拒絕的原因。

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

<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>

相關文章