跳到主要内容
版本:7.0.2

架构

DeepSeek V3 中英对照 Architecture

本节讨论基于Servlet的应用程序中Spring Security的高层架构。我们将在参考文档的身份验证、授权和漏洞防护章节中基于这一高层理解展开阐述。

过滤器回顾​

Spring Security 的 Servlet 支持基于 Servlet 过滤器,因此首先了解过滤器的通用角色会很有帮助。下图展示了对单个 HTTP 请求处理器的典型分层结构。

filterchain

图 1. FilterChain

客户端向应用程序发送请求,容器会根据请求URI的路径创建一个FilterChain,其中包含应处理HttpServletRequest的Filter实例和Servlet。在Spring MVC应用程序中,该Servlet是DispatcherServlet的一个实例。最多只能有一个Servlet处理单个HttpServletRequest和HttpServletResponse。然而,可以使用多个Filter来:

  • 阻止下游的 Filter 实例或 Servlet 被调用。在这种情况下,Filter 通常会写入 HttpServletResponse。

  • 修改下游 Filter 实例和 Servlet 所使用的 HttpServletRequest 或 HttpServletResponse。

Filter 的强大之处在于传入其中的 FilterChain。

@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 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

图 2. DelegatingFilterProxy

DelegatingFilterProxy 从 ApplicationContext 中查找 Bean Filter0,然后调用 Bean Filter0。以下清单展示了 DelegatingFilterProxy 的伪代码:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName); 1
delegate.doFilter(request, response); 2
}
  • 延迟获取已注册为 Spring Bean 的 Filter。对于 DelegatingFilterProxy 中的示例,delegate 是 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 的作用。

filterchainproxy

图 3. FilterChainProxy

SecurityFilterChain​

SecurityFilterChain 被 FilterChainProxy 用于确定当前请求应调用哪些 Spring Security Filter 实例。

下图展示了 SecurityFilterChain 的作用。

securityfilterchain

图 4. SecurityFilterChain

安全过滤器在 SecurityFilterChain 中通常是 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 实例:

多安全过滤器链

图 5. 多个 SecurityFilterChain

在多个SecurityFilterChain图示中,FilterChainProxy 负责决定应使用哪个 SecurityFilterChain。只有第一个匹配的 SecurityFilterChain 会被调用。如果请求的 URL 是 /api/messages/,它会首先匹配 SecurityFilterChain0 的模式 /api/**,因此即使它也匹配 SecurityFilterChainn,也只会调用 SecurityFilterChain0。如果请求的 URL 是 /messages/,它不匹配 SecurityFilterChain0 的模式 /api/**,因此 FilterChainProxy 会继续尝试每个 SecurityFilterChain。假设没有其他 SecurityFilterChain 实例匹配,则会调用 SecurityFilterChainn。

请注意,SecurityFilterChain0 仅配置了三个安全 Filter 实例。然而,SecurityFilterChainn 配置了四个安全 Filter 实例。需要特别注意的是,每个 SecurityFilterChain 都可以是唯一的,并且可以独立配置。实际上,如果应用程序希望 Spring Security 忽略某些请求,SecurityFilterChain 甚至可能包含零个安全 Filter 实例。

安全过滤器​

安全过滤器通过 SecurityFilterChain API 被插入到 FilterChainProxy 中。这些过滤器可用于多种不同的目的,例如漏洞防护、身份验证、授权等。过滤器按特定顺序执行,以确保它们在正确的时机被调用,例如,执行身份验证的 Filter 应在执行授权的 Filter 之前被调用。通常不需要了解 Spring Security Filter 的排序。然而,有时了解排序是有益的,如果你想知道它们,可以查看 FilterOrderRegistration 代码。

这些安全过滤器最常通过 HttpSecurity 实例来声明。为了举例说明上述段落,让我们考虑以下安全配置:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

return http.build();
}

}

上述配置将产生以下 Filter 排序:

过滤器添加方式
CsrfFilterHttpSecurity#csrf
BasicAuthenticationFilterHttpSecurity#httpBasic
UsernamePasswordAuthenticationFilterHttpSecurity#formLogin
AuthorizationFilterHttpSecurity#authorizeHttpRequests
  1. 首先,调用 CsrfFilter 以防止 CSRF 攻击。

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

  3. 最后,调用 AuthorizationFilter 对请求进行授权。

备注

可能还有其他未列出的 Filter 实例。如果您想查看特定请求所调用的过滤器列表,可以打印它们。

打印安全过滤器​

通常,查看针对特定请求调用的安全 Filter 列表是很有用的。例如,你可能需要确认你添加的过滤器是否在安全过滤器列表中。

过滤器列表会在应用程序启动时以 DEBUG 级别打印,因此您可以在控制台输出中看到类似以下内容:

2023-06-14T08:55:22.321-03:00  DEBUG 76975 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [ DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter]

这将让你对每个过滤器链配置的安全过滤器有一个很好的了解。

但这还不是全部,你还可以配置应用程序,使其为每个请求打印每个独立过滤器的调用情况。这有助于查看你添加的过滤器是否针对特定请求被调用,或者检查异常来自何处。为此,你可以配置应用程序以记录安全事件。

向过滤器链添加过滤器​

大多数情况下,默认的安全过滤器足以保障应用程序的安全。然而,有时您可能需要在SecurityFilterChain中添加自定义的Filter。

HttpSecurity 提供了三种方法来添加过滤器:

  • #addFilterBefore(Filter, Class<?>) 在另一个过滤器之前添加你的过滤器

  • #addFilterAfter(Filter, Class<?>) 在另一个过滤器之后添加你的过滤器

  • #addFilterAt(Filter, Class<?>) 用你的过滤器替换另一个过滤器

添加自定义过滤器​

如果您正在创建自己的过滤器,您需要确定它在过滤器链中的位置。请查看以下过滤器链中发生的关键事件:

  1. 从会话中加载 SecurityContext

  2. 请求受到常见攻击防护;安全头部、CORS、CSRF

  3. 请求进行身份验证

  4. 请求进行授权

考虑需要发生哪些事件才能定位你的过滤器。以下是一个经验法则:

如果您的过滤器是则应将其置于因为这些事件已经发生
漏洞利用防护过滤器SecurityContextHolderFilter1
身份验证过滤器LogoutFilter1, 2
授权过滤器AnonymousAuthenticationFilter1, 2, 3
提示

通常情况下,应用程序会添加自定义的身份验证。这意味着它们应该放置在 LogoutFilter 之后。

例如,假设您想要添加一个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
}

}

上述示例代码执行了以下操作:

  • 从请求头中获取租户ID。

  • 检查当前用户是否有权访问该租户ID。

  • 如果用户有权访问,则调用过滤器链中的其余过滤器。

  • 如果用户无权访问,则抛出 AccessDeniedException。

提示

除了实现 Filter 接口,你也可以继承 OncePerRequestFilter。这是一个过滤器基类,确保每个请求仅被调用一次,并提供了一个带有 HttpServletRequest 和 HttpServletResponse 参数的 doFilterInternal 方法。

现在,你需要将过滤器添加到 SecurityFilterChain 中。之前的描述已经为我们提供了添加过滤器位置的线索,由于我们需要知道当前用户,因此需要在身份验证过滤器之后添加它。

根据经验法则,将其添加在AnonymousAuthenticationFilter之后,即过滤器链中的最后一个认证过滤器,如下所示:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class); 1
return http.build();
}
  • 使用 HttpSecurity#addFilterAfter 将 TenantFilter 添加到 AnonymousAuthenticationFilter 之后。

通过在AnonymousAuthenticationFilter之后添加过滤器,我们确保TenantFilter在身份验证过滤器之后被调用。

就是这样,现在 TenantFilter 将在过滤器链中被调用,并检查当前用户是否有权访问该租户 ID。

将过滤器声明为 Bean​

当你将 Filter 声明为 Spring bean 时,无论是通过 @Component 注解还是通过在配置中将其声明为 bean,Spring Boot 都会自动将其注册到嵌入式容器中。这可能导致过滤器被调用两次,一次由容器调用,另一次由 Spring Security 调用,并且调用顺序也可能不同。

因此,过滤器通常不是 Spring bean。

然而,如果你的过滤器需要成为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;
}

这样一来,只有 HttpSecurity 会添加它。

自定义 Spring Security 过滤器​

通常,你可以使用过滤器的DSL方法来配置Spring Security的过滤器。例如,添加BasicAuthenticationFilter的最简单方式是让DSL来完成:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(Customizer.withDefaults())
// ...

return http.build();
}

然而,如果您想自行构建一个 Spring Security 过滤器,可以在 DSL 中使用 addFilterAt 来指定它,如下所示:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
BasicAuthenticationFilter basic = new BasicAuthenticationFilter();
// ... configure

http
// ...
.addFilterAt(basic, BasicAuthenticationFilter.class);

return http.build();
}

请注意,如果该过滤器已被添加,Spring Security 将抛出异常。例如,调用 HttpSecurity#httpBasic 会自动添加一个 BasicAuthenticationFilter。因此,以下配置会失败,因为两次调用都试图添加 BasicAuthenticationFilter:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
BasicAuthenticationFilter basic = new BasicAuthenticationFilter();
// ... configure

http
.httpBasic(Customizer.withDefaults())
// ... on no! BasicAuthenticationFilter is added twice!
.addFilterAt(basic, BasicAuthenticationFilter.class);

return http.build();
}

在这种情况下,请移除对 httpBasic 的调用,因为您正在自行构建 BasicAuthenticationFilter。

提示

如果你无法重新配置 HttpSecurity 以不添加某个过滤器,通常可以通过调用其 DSL 的 disable 方法来禁用 Spring Security 过滤器,如下所示:

.httpBasic((basic) -> basic.disable())

处理安全异常​

ExceptionTranslationFilter 作为安全过滤器之一,被插入到FilterChainProxy中。

下图展示了 ExceptionTranslationFilter 与其他组件的关系:

exceptiontranslationfilter

  • 1 首先,ExceptionTranslationFilter 调用 FilterChain.doFilter(request, response) 来执行应用程序的其余部分。

  • 2 如果用户未认证或发生 AuthenticationException,则开始认证。

    • 清空 SecurityContextHolder。
    • 保存 HttpServletRequest(已保存的请求),以便在认证成功后重放原始请求。
    • 使用 AuthenticationEntryPoint 向客户端请求凭据。例如,它可能重定向到登录页面或发送 WWW-Authenticate 头。
  • 3 否则,如果是 AccessDeniedException,则访问被拒绝。调用 AccessDeniedHandler 来处理访问被拒绝的情况。

备注

如果应用程序没有抛出 AccessDeniedException 或 AuthenticationException,那么 ExceptionTranslationFilter 不会执行任何操作。

ExceptionTranslationFilter 的伪代码大致如下:

try {
filterChain.doFilter(request, response); 1
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication(); 2
} else {
accessDenied(); 3
}
}
  • 如 过滤器综述 所述,调用 FilterChain.doFilter(request, response) 等同于调用应用程序的其余部分。这意味着如果应用程序的另一部分(AuthorizationFilter 或方法安全)抛出 AuthenticationException 或 AccessDeniedException,它会被捕获并在这里处理。

  • 如果用户未通过身份验证或这是一个 AuthenticationException,则开始身份验证。

  • 否则,拒绝访问

保存认证之间的请求​

如处理安全异常所述,当请求未携带认证信息且访问的是需要认证的资源时,需要保存该请求以便在认证成功后重新发起。在 Spring Security 中,这是通过使用 RequestCache 实现来保存 HttpServletRequest 完成的。

RequestCache​

HttpServletRequest 被保存在 RequestCache 中。当用户成功认证后,RequestCache 会被用来重放原始请求。RequestCacheAwareFilter 在用户认证后使用 RequestCache 来获取保存的 HttpServletRequest,而 ExceptionTranslationFilter 则在检测到 AuthenticationException 后、重定向用户到登录端点之前,使用 RequestCache 来保存 HttpServletRequest。

默认情况下,系统使用 HttpSessionRequestCache。以下代码演示了如何自定义 RequestCache 实现,该实现用于在存在名为 continue 的参数时检查 HttpSession 中是否有已保存的请求。

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

防止请求被保存​

您可能出于多种原因不希望将用户的未认证请求存储在会话中。您可能希望将该存储卸载到用户的浏览器上,或将其存储在数据库中。或者,您可能希望关闭此功能,因为您总是希望将用户重定向到主页,而不是他们登录前尝试访问的页面。

为此,你可以使用 NullRequestCache 实现。

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

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 http://localhost: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 令牌缺失,这就是请求被拒绝的原因。

要将您的应用程序配置为记录所有安全事件,您可以在应用程序中添加以下内容:

logging.level.org.springframework.security=TRACE
<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>