跳到主要内容
版本:6.4.3

架构

ChatGPT-4o-mini 中英对照 Architecture

本节讨论了 Spring Security 在基于 Servlet 的应用程序中的高层架构。我们在参考文献的 身份验证、授权 和 防止攻击 部分中建立了这种高层次的理解。

过滤器回顾​

Spring Security 的 Servlet 支持基于 Servlet 过滤器,因此首先了解过滤器的一般作用是很有帮助的。下图显示了单个 HTTP 请求的处理程序的典型分层。

filterchain

图 1. 过滤链

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

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

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

Filter 的强大之处在于传递给它的 FilterChain。

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 定义的 Beans。您可以通过标准的 Servlet 容器机制注册 DelegatingFilterProxy,但将所有工作委托给实现了 Filter 的 Spring Bean。

这里是 DelegatingFilterProxy 如何适配于 过滤器实例和过滤器链 的示意图。

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 Beans,而这在 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 中的 Security Filters 通常是 Beans,但它们是注册在 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

在 Multiple 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 实例。

安全过滤器​

安全过滤器通过 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
UsernamePasswordAuthenticationFilterHttpSecurity#formLogin
BasicAuthenticationFilterHttpSecurity#httpBasic
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]

这将对为 每个过滤器链 配置的安全过滤器有一个相当好的了解。

但这并不是全部,您还可以配置您的应用程序,以打印每个请求中每个单独过滤器的调用。这有助于查看您添加的过滤器是否在特定请求中被调用,或检查异常来自何处。要做到这一点,您可以配置您的应用程序以 记录安全事件。

添加过滤器到过滤链​

大多数情况下,默认的 Security Filters 足以为您的应用程序提供安全性。然而,有时您可能希望向 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,该 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。

提示

你可以扩展自 OncePerRequestFilter,而不是实现 Filter,它是一个仅在每个请求中调用一次的过滤器的基类,并提供了一个带有 HttpServletRequest 和 HttpServletResponse 参数的 doFilterInternal 方法。

现在,您需要将过滤器添加到 SecurityFilterChain。之前的描述已经给出了我们添加过滤器的线索,因为我们需要知道当前用户,所以我们需要在身份验证过滤器之后添加它。

根据经验法则,将其添加到 AnonymousAuthenticationFilter 后面,这是链中的最后一个身份验证过滤器,如下所示:

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

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

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

将您的过滤器声明为 Bean​

当你将 Filter 声明为 Spring bean 时,无论是通过使用 @Component 注解,还是在你的配置中声明它,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 过滤器,可以使用 addFilterAt 在 DSL 中指定它,如下所示:

@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 中,作为 Security Filters 之一。

以下图像显示了 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
}
}
  • 如在 A Review of Filters 中所述,调用 FilterChain.doFilter(request, response) 相当于调用应用程序的其余部分。这意味着如果应用程序的其他部分(FilterSecurityInterceptor 或方法安全)抛出 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>