架构

本节讨论 Spring Security 在基于 Servlet 的应用程序中的高级架构。我们在参考的身份验证授权防止漏洞利用部分中,基于这种高级理解进行构建。

过滤器回顾

Spring Security 的 Servlet 支持基于 Servlet 过滤器,因此首先了解过滤器的作用很有帮助。下图显示了单个 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中的Security Filters通常是 Bean,但它们是注册到FilterChainProxy而不是DelegatingFilterProxy中的。FilterChainProxy相较于直接向 Servlet 容器或DelegatingFilterProxy注册,提供了许多优势。首先,它为所有 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实例。

Security Filters

安全过滤器使用SecurityFilterChain API 插入到FilterChainProxy中。这些过滤器可用于许多不同的目的,例如身份验证授权漏洞防护等等。这些过滤器按特定顺序执行,以保证它们在正确的时间被调用,例如,执行身份验证的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顺序

过滤器 添加者

CsrfFilter

HttpSecurity#csrf

UsernamePasswordAuthenticationFilter

HttpSecurity#formLogin

BasicAuthenticationFilter

HttpSecurity#httpBasic

AuthorizationFilter

HttpSecurity#authorizeHttpRequests

  1. 首先,调用CsrfFilter以防止CSRF 攻击

  2. 其次,调用身份验证过滤器以对请求进行身份验证。

  3. 第三,调用AuthorizationFilter以授权请求。

可能还有其他未列出的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

例如,假设您想要添加一个Filter,该过滤器获取租户 ID 头并检查当前用户是否具有对该租户的访问权限。前面的描述已经给了我们一个关于在哪里添加过滤器的线索,因为我们需要知道当前用户,我们需要在身份验证过滤器之后添加它。

首先,让我们创建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

与其实现Filter,不如从OncePerRequestFilter扩展,它是一个仅对每个请求调用一次的过滤器的基类,并提供了一个带有HttpServletRequestHttpServletResponse参数的doFilterInternal方法。

现在,我们需要将过滤器添加到安全过滤器链中。

  • 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之前添加过滤器,我们确保在身份验证过滤器之后调用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;
}

处理安全异常

ExceptionTranslationFilter作为安全过滤器之一插入到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 过滤器回顾中所述,调用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实现,如果存在名为continue的参数,则检查HttpSession中是否有保存的请求。

仅当存在continue参数时,RequestCache才检查已保存的请求
  • 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"/>

阻止保存请求

您可能有很多理由不想将用户的未经身份验证的请求存储在会话中。您可能希望将该存储卸载到用户的浏览器或将其存储在数据库中。或者,您可能希望关闭此功能,因为您始终希望将用户重定向到主页,而不是重定向到他们在登录前尝试访问的页面。

为此,您可以使用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>