架构
过滤器回顾
Spring Security 的 Servlet 支持基于 Servlet 过滤器,因此首先了解过滤器的一般作用是很有帮助的。下图显示了单个 HTTP 请求的处理程序的典型分层。
图 1. 过滤链
客户端向应用程序发送请求,容器创建一个 FilterChain
,该链包含应根据请求 URI 的路径处理 HttpServletRequest
的 Filter
实例和 Servlet
。在 Spring MVC 应用程序中,Servlet
是 DispatcherServlet 的一个实例。至多,一个 Servlet
可以处理单个 HttpServletRequest
和 HttpServletResponse
。然而,可以使用多个 Filter
来:
-
防止下游的
Filter
实例或Servlet
被调用。在这种情况下,Filter
通常会写入HttpServletResponse
。 -
修改下游
Filter
实例和Servlet
使用的HttpServletRequest
或HttpServletResponse
。
Filter
的强大之处在于传递给它的 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 提供了一个名为 DelegatingFilterProxy 的 Filter
实现,它允许在 Servlet 容器的生命周期与 Spring 的 ApplicationContext
之间进行桥接。Servlet 容器允许通过其自己的标准注册 Filter
实例,但它并不知道 Spring 定义的 Beans。您可以通过标准的 Servlet 容器机制注册 DelegatingFilterProxy
,但将所有工作委托给实现了 Filter
的 Spring Bean。
这里是 DelegatingFilterProxy
如何适配于 过滤器实例和过滤器链 的示意图。
图 2. DelegatingFilterProxy
DelegatingFilterProxy
从 ApplicationContext
中查找 Bean Filter0,然后调用 Bean Filter0。以下代码段展示了 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
}
懒惰地获取作为 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
的角色。
图 3. FilterChainProxy
SecurityFilterChain
SecurityFilterChain 被 FilterChainProxy 用来确定当前请求应该调用哪些 Spring Security Filter
实例。
以下图像显示了 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
实例:
图 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 实例声明。为了举例说明上述段落,让我们考虑以下安全配置:
- Java
- Kotlin
@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();
}
}
import org.springframework.security.config.web.servlet.invoke
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { }
httpBasic { }
formLogin { }
authorizeHttpRequests {
authorize(anyRequest, authenticated)
}
}
return http.build()
}
}
上述配置将导致以下 Filter
排序:
过滤器 | 添加者 |
---|---|
CsrfFilter | HttpSecurity#csrf |
UsernamePasswordAuthenticationFilter | HttpSecurity#formLogin |
BasicAuthenticationFilter | HttpSecurity#httpBasic |
AuthorizationFilter | HttpSecurity#authorizeHttpRequests |
-
首先,
CsrfFilter
被调用以防止 CSRF 攻击。 -
其次,身份验证过滤器 被调用以验证请求。
-
第三,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<?>)
用您的过滤器替换另一个过滤器
添加自定义过滤器
如果您正在创建自己的过滤器,您需要确定它在过滤器链中的位置。请查看以下在过滤器链中发生的关键事件:
考虑您需要发生哪些事件才能定位您的过滤器。以下是一个经验法则:
如果你的过滤器是一个 | 那么将其放在之后 | 因为这些事件已经发生 |
---|---|---|
漏洞保护过滤器 | SecurityContextHolderFilter | 1 |
认证过滤器 | LogoutFilter | 1, 2 |
授权过滤器 | AnonymousAuthenticationFilter | 1, 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 后面,这是链中的最后一个身份验证过滤器,如下所示:
- Java
- Kotlin
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class); 1
return http.build();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
// ...
.addFilterAfter(TenantFilter(), AnonymousAuthenticationFilter::class.java) 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 来完成它:
- Java
- Kotlin
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(Customizer.withDefaults())
// ...
return http.build();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
httpBasic { }
// ...
}
return http.build()
}
然而,如果您想自己构建一个 Spring Security 过滤器,可以使用 addFilterAt
在 DSL 中指定它,如下所示:
- Java
- Kotlin
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
BasicAuthenticationFilter basic = new BasicAuthenticationFilter();
// ... configure
http
// ...
.addFilterAt(basic, BasicAuthenticationFilter.class);
return http.build();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val basic = BasicAuthenticationFilter()
// ... configure
http
// ...
.addFilterAt(basic, BasicAuthenticationFilter::class.java)
return http.build()
}
注意,如果该过滤器已经被添加,那么 Spring Security 将抛出异常。例如,调用 HttpSecurity#httpBasic 会为您添加一个 BasicAuthenticationFilter
。因此,以下安排将失败,因为有两个调用都试图添加 BasicAuthenticationFilter
:
- Java
- Kotlin
@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();
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
val basic = BasicAuthenticationFilter()
// ... configure
http {
httpBasic { }
}
// ... on no! BasicAuthenticationFilter is added twice!
http.addFilterAt(basic, BasicAuthenticationFilter::class.java)
return http.build()
}
在这种情况下,移除对 httpBasic
的调用,因为您自己正在构造 BasicAuthenticationFilter
。
如果您无法重新配置 HttpSecurity
以不添加某个过滤器,通常可以通过调用其 DSL 的 disable
方法来禁用 Spring Security 过滤器,如下所示:
.httpBasic((basic) -> basic.disable())
处理安全异常
ExceptionTranslationFilter 允许将 AccessDeniedException 和 AuthenticationException 转换为 HTTP 响应。
ExceptionTranslationFilter
被插入到 FilterChainProxy 中,作为 Security Filters 之一。
以下图像显示了 ExceptionTranslationFilter
与其他组件的关系:
-
1 首先,
ExceptionTranslationFilter
调用FilterChain.doFilter(request, response)
来调用应用程序的其余部分。 -
2 如果用户未通过身份验证或发生了
AuthenticationException
,则 开始身份验证。-
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
中的保存请求。
- 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 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>