Spring Security 过滤器链全过程与源码入口
Spring Security Web 的核心不是 Controller 注解,而是一条位于 DispatcherServlet 之前的 Servlet Filter 委托链。JWT、Session、Basic、表单登录、CSRF、CORS、匿名身份、401、403 和 URL 授权,最终都要放到这条链的正确阶段。
本页以 JDK 8 + Boot 2.7 + Security 5.7 为基线。Security 6 的过滤器名称和默认行为有演进,但“Servlet 入口 → FilterChainProxy → 选择一条 SecurityFilterChain → 执行安全过滤器”的主结构不变。
一、学习目标
- 能区分原生 Servlet Filter 链与 Spring Security 内部链。
- 解释
DelegatingFilterProxy为什么需要委托 Spring Bean。 - 解释
FilterChainProxy如何选择第一条匹配的SecurityFilterChain。 - 知道过滤器顺序为什么属于安全语义,而不是代码风格。
- 追踪 SecurityContext 的加载、使用、保存和清理。
- 解释认证过滤器、匿名过滤器、异常翻译和授权过滤器的协作。
- 正确插入 JWT、traceId 或租户过滤器。
- 定位错误链匹配、重复注册、上下文为空、401/403 错误和异步上下文问题。
二、应用里其实有两层 Filter 链
flowchart TD
A["Tomcat 构造 Servlet FilterChain"] --> B["应用编码、日志等原生 Filter"]
B --> C["DelegatingFilterProxy"]
C --> D["Spring Bean:FilterChainProxy"]
D --> E["SecurityFilterChain 内部过滤器"]
E --> F["回到原生 Servlet FilterChain"]
F --> G["DispatcherServlet"]第一层由 Servlet 容器管理,成员实现 javax.servlet.Filter;第二层由 Spring Security 的 FilterChainProxy 管理,成员也实现 Filter,但它们的匹配、顺序和生命周期由安全框架组织。
为什么不把每个安全 Filter 都直接注册到 Tomcat:
- Spring Bean 依赖注入和生命周期更容易管理。
- 可以为不同 URL 使用不同安全链。
- Security 可以统一清理上下文、防火墙包装和异常语义。
- 避免每个内部 Filter 都成为全局 Servlet Filter。
三、DelegatingFilterProxy:Servlet 与 Spring 的桥
Tomcat 只认识 Servlet Filter,不会直接去 Spring 容器寻找 Bean。DelegatingFilterProxy 本身由 Servlet 容器调用,再从 WebApplicationContext 中找到目标 Filter Bean 并委托。
Boot 自动配置通常注册名为 springSecurityFilterChain 的代理目标。这个 Bean 实际常是 FilterChainProxy。
flowchart TD
A["Tomcat 调用 DelegatingFilterProxy.doFilter"] --> B["获取 WebApplicationContext"]
B --> C["按名称寻找 springSecurityFilterChain"]
C --> D["缓存目标 Filter Bean"]
D --> E["调用目标 Filter.doFilter"]典型失败:
- 应用没有创建
springSecurityFilterChain,代理找不到目标 Bean。 - 自己重复注册代理,安全链执行两次。
- Filter Bean 被同时作为 Servlet Filter 和安全内部 Filter 注册,也可能执行两次。
四、FilterChainProxy:安全链总调度器
FilterChainProxy 内部持有多条 SecurityFilterChain。每条链包含:
- 一个
RequestMatcher,判断当前请求是否属于该链。 - 一组有顺序的安全 Filter。
简化模型:
public interface SecurityFilterChain {
boolean matches(HttpServletRequest request);
List<Filter> getFilters();
}执行逻辑可理解为:
for (SecurityFilterChain chain : securityFilterChains) {
if (chain.matches(request)) {
List<Filter> filters = chain.getFilters();
execute(filters);
return;
}
}
continueOriginalServletChain();它不是把所有匹配链合并执行。通常选择配置顺序中第一条匹配链,因此更具体的链必须放在通用链前面。
五、多条安全链:第一条匹配原则
例如需要:
/actuator/health公开。/api/**使用 HTTP Basic 演示认证。- 其他资源全部拒绝。
Boot 2.7/JDK 8 组件式配置:
@Bean
@Order(1)
public SecurityFilterChain actuatorChain(HttpSecurity http)
throws Exception {
http
.requestMatcher(new AntPathRequestMatcher("/actuator/health"))
.authorizeRequests().anyRequest().permitAll();
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http)
throws Exception {
http
.requestMatcher(new AntPathRequestMatcher("/api/**"))
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic()
.and()
.csrf().disable();
return http.build();
}
@Bean
@Order(3)
public SecurityFilterChain fallbackChain(HttpSecurity http)
throws Exception {
http.authorizeRequests().anyRequest().denyAll();
return http.build();
}两个层次不要混淆:
| 配置 | 作用 |
|---|---|
| 链级 RequestMatcher | 决定请求是否使用整条 SecurityFilterChain |
| 链内 authorizeRequests 规则 | 在已经选中的链里决定具体资源权限 |
如果把“匹配所有请求”的 fallback 放到 @Order(1),后面的 API 链永远没有机会执行。
六、内部过滤器如何一个接一个执行
FilterChainProxy 会构造内部的虚拟 FilterChain。每个安全 Filter 调用 chain.doFilter 时,虚拟链推进到下一个安全 Filter;内部列表耗尽后,才回到原始 Servlet FilterChain。
flowchart TD
A["VirtualFilterChain 索引为 0"] --> B["调用安全 Filter 1"]
B --> C["Filter 1 调用 chain.doFilter"]
C --> D["索引推进,调用安全 Filter 2"]
D --> E["继续推进到最后一个安全 Filter"]
E --> F["回到原始 Servlet FilterChain"]
F --> G["DispatcherServlet"]某个 Filter 如果直接写响应并且不再调用 chain.doFilter,链就在这里终止。这对认证失败是正常行为,但自定义日志过滤器忘记放行会让所有请求都到不了 Controller。
七、典型过滤器顺序解决什么问题
实际列表由配置决定,不同 Security 版本也会变化。不要死背一张固定的完整列表,应理解关键相对顺序:
| 阶段 | 典型 Filter | 作用 |
|---|---|---|
| 请求包装与跨域 | CorsFilter | 先处理预检和 CORS 响应头 |
| CSRF | CsrfFilter | 对需要保护的修改请求校验 Token |
| 上下文 | SecurityContextPersistenceFilter 或新式上下文 Filter | 加载、建立、保存上下文 |
| 退出 | LogoutFilter | 匹配退出请求并清理认证状态 |
| 凭证认证 | Basic、表单、Bearer、自定义 JWT Filter | 解析凭证并尝试认证 |
| 请求缓存 | RequestCacheAwareFilter | 登录前保存目标请求等 |
| 匿名身份 | AnonymousAuthenticationFilter | 没有身份时放入匿名 Authentication |
| 异常翻译 | ExceptionTranslationFilter | 翻译认证和访问拒绝异常 |
| 最终授权 | FilterSecurityInterceptor 或 AuthorizationFilter | 判断当前请求是否允许 |
过滤器顺序不只是性能问题:授权发生前必须建立 Authentication;异常翻译要包围可能抛出安全异常的下游;CORS 预检应在要求业务认证前得到正确处理。
八、SecurityContext 全生命周期
Session 模式下可理解为:
flowchart TD
A["请求进入"] --> B["SecurityContextRepository 加载上下文"]
B --> C["放入 SecurityContextHolder"]
C --> D["认证 Filter 可能替换 Authentication"]
D --> E["授权与业务读取当前身份"]
E --> F["保存上下文到 Session 或 Repository"]
F --> G["finally 清理 SecurityContextHolder"]关键点:
- SecurityContextHolder 默认常使用 ThreadLocal 策略。
- Session 保存的是跨请求恢复身份的一个来源,不是 SecurityContextHolder 本身。
- 请求结束必须清理当前线程上下文。
- Security 5.7/6 对显式保存策略有演进,升级时要确认是否仍自动保存。
- 无状态 JWT 模式通常每次请求重新验证 Token,不应无意创建 Session。
如果自定义 JWT Filter 写入上下文后仍然出现匿名用户,检查 Filter 是否执行、是否在授权前、是否被后续逻辑清空、是否匹配了另一条链。
九、AnonymousAuthenticationFilter 为什么存在
没有登录信息时,Security 可以放入一个匿名 Authentication,而不是始终保持 null。这样授权规则可以统一表达 anonymous、authenticated 等状态。
匿名 Authentication 不等于用户已经通过真实认证。它通常由特定匿名 Token 和 authority 表示。访问受保护资源被拒绝后,异常翻译逻辑会根据当前是否属于匿名/未充分认证,决定启动认证入口而不是直接返回普通 403。
这解释了为什么“上下文里有 Authentication”不一定意味着用户真实登录,代码还要正确理解 authentication 类型和状态。
十、ExceptionTranslationFilter 如何决定 401 与 403
flowchart TD
A["下游授权抛出安全异常"] --> B{"异常类型"}
B -- "AuthenticationException" --> C["清理或处理认证状态"]
C --> D["调用 AuthenticationEntryPoint"]
D --> E["返回 401 或启动登录流程"]
B -- "AccessDeniedException" --> F{"当前是否匿名或认证不足"}
F -- "是" --> D
F -- "否" --> G["调用 AccessDeniedHandler"]
G --> H["通常返回 403"]注意边界:
- 自定义 JWT Filter 如果放在 ExceptionTranslationFilter 之前,并在自身直接抛出异常,异常未必由后面的翻译 Filter 捕获;该 Filter 应自己调用统一失败处理,或选择正确位置和异常传播设计。
@RestControllerAdvice通常处理 DispatcherServlet 内部异常,不能假设它会接住所有安全 Filter 异常。- CSRF 失败也可能表现为 AccessDeniedException 和 403,不应看到 403 就只检查角色。
十一、认证 Filter 的职责边界
认证 Filter 通常负责:
- 判断当前请求是否携带本机制凭证。
- 提取用户名密码、Basic Header、Bearer Token 等。
- 构造“未认证”的 Authentication Token。
- 调用 AuthenticationManager,或对已验证 JWT 构造可信身份。
- 成功后写入 SecurityContext,并触发成功策略。
- 失败后清理上下文,调用失败处理。
- 决定继续过滤器链还是直接结束响应。
认证 Filter 不应:
- 编写订单、审批等业务逻辑。
- 只 Base64 解码 JWT 就认为可信。
- 把客户端提交的 authorities 原样放进已认证对象。
- 捕获所有异常后伪装成匿名请求,导致过期 Token 与未携带 Token 无法区分。
十二、自定义 Filter 放在哪里
常见 API:
http.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(auditFilter,
SecurityContextPersistenceFilter.class);
http.addFilterAt(customAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);选择位置前先回答:
| 需求 | 应满足的相对位置 |
|---|---|
| CORS 预检 | 在要求认证授权之前 |
| JWT 建立身份 | 在最终授权之前 |
| 读取已认证用户做审计 | 在认证 Filter 之后 |
| 捕获安全访问异常 | 异常翻译机制要包围可能抛出异常的下游 |
| traceId | 通常尽量靠前,并在 finally 清理 MDC |
OncePerRequestFilter 表示在一次请求分派语义下提供一次执行控制,但异步 dispatch 和 error dispatch 是否再次执行要看重写策略。不要把它简单理解为“整个网络请求永远只执行一次”。
十三、为什么自定义 Filter 会执行两次
常见原因:
- Filter 标注
@Component,被 Boot 注册为 Servlet Filter。 - 同一个实例又通过
http.addFilterBefore加入 SecurityFilterChain。 - 于是它在原生 Servlet 链执行一次,在安全内部链又执行一次。
解决方式是明确它只属于哪一层。纯安全内部 Filter 不要无意成为全局 Servlet Filter;如果必须是 Spring Bean,可通过注册配置禁用其 Servlet 自动注册,再手动加入安全链。
十四、JDK 8 + Boot 2.7 可运行 Demo
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeRequests()
.antMatchers("/public").permitAll()
.antMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.httpBasic()
.and()
.csrf().disable();
return http.build();
}
@Bean
public UserDetailsService users(PasswordEncoder encoder) {
UserDetails admin = User.withUsername("admin")
.password(encoder.encode("secret"))
.roles("ADMIN")
.build();
UserDetails reader = User.withUsername("reader")
.password(encoder.encode("secret"))
.roles("READER")
.build();
return new InMemoryUserDetailsManager(admin, reader);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}Controller:
@RestController
public class DemoController {
@GetMapping("/public")
public String publicApi() {
return "PUBLIC";
}
@GetMapping("/profile")
public String profile(Authentication authentication) {
return authentication.getName();
}
@GetMapping("/admin")
public String admin() {
return "ADMIN";
}
}预期行为:
| 请求 | 结果 |
|---|---|
无凭证访问 /public | 200 |
无凭证访问 /profile | 401 |
reader 访问 /profile | 200,返回 reader |
reader 访问 /admin | 403 |
admin 访问 /admin | 200 |
十五、用 MockMvc 验证链路
@SpringBootTest
@AutoConfigureMockMvc
class SecurityChainTest {
@Autowired
private MockMvc mockMvc;
@Test
void publicApiIsOpen() throws Exception {
mockMvc.perform(get("/public"))
.andExpect(status().isOk());
}
@Test
void profileRequiresAuthentication() throws Exception {
mockMvc.perform(get("/profile"))
.andExpect(status().isUnauthorized());
}
@Test
void readerCannotAccessAdmin() throws Exception {
mockMvc.perform(get("/admin")
.with(httpBasic("reader", "secret")))
.andExpect(status().isForbidden());
}
@Test
void adminCanAccessAdmin() throws Exception {
mockMvc.perform(get("/admin")
.with(httpBasic("admin", "secret")))
.andExpect(status().isOk());
}
}安全测试不能只断言状态码,还应验证响应错误码、WWW-Authenticate、CORS Header、CSRF 行为、SecurityContext 身份和业务方法是否未被错误执行。
十六、五个失败实验
16.1 把 fallback 链放第一位
所有请求先匹配 fallback,后续链永远不执行。证明链顺序是安全规则的一部分。
16.2 自定义 JWT Filter 放到授权之后
授权时上下文仍是匿名,合法 Token 也得到 401/403。证明认证必须在授权前建立身份。
16.3 认证成功后不写 SecurityContext
AuthenticationManager 返回成功对象,但后续授权仍看不到身份。证明认证结果不会凭空成为当前上下文。
16.4 Filter 不调用 chain.doFilter
请求停在该 Filter,Controller 永远不执行。如果响应也没写完整,客户端可能得到空响应或超时。
16.5 Filter 同时自动注册和加入安全链
日志、Token 解析或请求体读取执行两次,可能产生重复审计、流被消费或上下文覆盖。
十七、生产排查 Runbook
17.1 先确认请求匹配哪条链
检查请求方法、URI、context-path、网关改写路径和 RequestMatcher。短时开启特定包 DEBUG/TRACE,确认 Securing ...、匹配链和过滤器列表;不要在生产长期全量 TRACE。
17.2 再确认 Authentication 在何时变化
在不记录凭证的前提下,观察认证 Filter 前后:是否匿名、principal 类型、authority 数量、认证状态。禁止记录密码、完整 Token、SessionId 和患者信息。
17.3 区分 401 和 403 的真实来源
- 401:没有凭证、凭证无效、Session 未恢复、Provider 不支持。
- 403:真实权限不足、CSRF、方法安全拒绝、数据权限拒绝。
- 浏览器显示 CORS:可能服务端实际返回 401/403 但缺少允许跨域响应头。
17.4 检查过滤器是否重复或顺序错误
输出启动后的安全 Filter 列表,检查自定义 Filter 是否同时出现在 Servlet 注册和安全链;确认 JWT 在授权前、审计在认证后、异常处理覆盖正确范围。
17.5 检查上下文保存与清理
Session 登录成功但后续丢失身份时检查 Cookie、Session Repository 和保存策略;用户串号时优先检查自定义线程、异步任务、MDC 和 SecurityContextHolder 是否在 finally 清理。
十八、源码调试入口
建议断点:
DelegatingFilterProxy#doFilterFilterChainProxy#doFilterInternalFilterChainProxy#getFilters- 内部 VirtualFilterChain 的
doFilter ProviderManager#authenticateExceptionTranslationFilter#doFilter- 当前版本的授权 Filter
- SecurityContext 加载和保存实现
调试时记录每一步的 request URI、选中链、当前 Filter 类、Authentication 类型和异常类型,不要只在 Controller 打断点。
十九、面试标准回答
请求先进入 Servlet 容器的 FilterChain,DelegatingFilterProxy 将调用委托给 Spring 容器中的 FilterChainProxy。FilterChainProxy 按顺序遍历 SecurityFilterChain,选择第一条 RequestMatcher 匹配的链,通过内部 VirtualFilterChain 依次执行上下文、认证、匿名、异常翻译和授权过滤器。认证成功后 Authentication 写入 SecurityContext;授权拒绝时,ExceptionTranslationFilter 根据异常类型和当前认证状态调用 AuthenticationEntryPoint 或 AccessDeniedHandler。安全链执行完再回到原始 Servlet 链进入 DispatcherServlet,请求结束时保存需要持久化的上下文并清理 ThreadLocal。
二十、关联知识点
本章小结
Spring Security 过滤器链的核心不是背 Filter 名单,而是理解两个链层次、代理委托、第一条匹配规则、内部虚拟链推进、SecurityContext 生命周期、认证发生在授权之前,以及异常翻译只覆盖正确的下游范围。
遇到 401/403 时,应先定位请求选中了哪条链、执行到哪个 Filter、Authentication 在何时建立或丢失,再判断凭证、权限、CSRF 或 CORS,而不是盲目把路径加入白名单。
