Skip to content

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 → 执行安全过滤器”的主结构不变。

一、学习目标

  1. 能区分原生 Servlet Filter 链与 Spring Security 内部链。
  2. 解释 DelegatingFilterProxy 为什么需要委托 Spring Bean。
  3. 解释 FilterChainProxy 如何选择第一条匹配的 SecurityFilterChain
  4. 知道过滤器顺序为什么属于安全语义,而不是代码风格。
  5. 追踪 SecurityContext 的加载、使用、保存和清理。
  6. 解释认证过滤器、匿名过滤器、异常翻译和授权过滤器的协作。
  7. 正确插入 JWT、traceId 或租户过滤器。
  8. 定位错误链匹配、重复注册、上下文为空、401/403 错误和异步上下文问题。

二、应用里其实有两层 Filter 链

mermaid
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

mermaid
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。每条链包含:

  1. 一个 RequestMatcher,判断当前请求是否属于该链。
  2. 一组有顺序的安全 Filter。

简化模型:

java
public interface SecurityFilterChain {
    boolean matches(HttpServletRequest request);
    List<Filter> getFilters();
}

执行逻辑可理解为:

java
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 组件式配置:

java
@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。

mermaid
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 响应头
CSRFCsrfFilter对需要保护的修改请求校验 Token
上下文SecurityContextPersistenceFilter 或新式上下文 Filter加载、建立、保存上下文
退出LogoutFilter匹配退出请求并清理认证状态
凭证认证Basic、表单、Bearer、自定义 JWT Filter解析凭证并尝试认证
请求缓存RequestCacheAwareFilter登录前保存目标请求等
匿名身份AnonymousAuthenticationFilter没有身份时放入匿名 Authentication
异常翻译ExceptionTranslationFilter翻译认证和访问拒绝异常
最终授权FilterSecurityInterceptorAuthorizationFilter判断当前请求是否允许

过滤器顺序不只是性能问题:授权发生前必须建立 Authentication;异常翻译要包围可能抛出安全异常的下游;CORS 预检应在要求业务认证前得到正确处理。

八、SecurityContext 全生命周期

Session 模式下可理解为:

mermaid
flowchart TD
    A["请求进入"] --> B["SecurityContextRepository 加载上下文"]
    B --> C["放入 SecurityContextHolder"]
    C --> D["认证 Filter 可能替换 Authentication"]
    D --> E["授权与业务读取当前身份"]
    E --> F["保存上下文到 Session 或 Repository"]
    F --> G["finally 清理 SecurityContextHolder"]

关键点:

  1. SecurityContextHolder 默认常使用 ThreadLocal 策略。
  2. Session 保存的是跨请求恢复身份的一个来源,不是 SecurityContextHolder 本身。
  3. 请求结束必须清理当前线程上下文。
  4. Security 5.7/6 对显式保存策略有演进,升级时要确认是否仍自动保存。
  5. 无状态 JWT 模式通常每次请求重新验证 Token,不应无意创建 Session。

如果自定义 JWT Filter 写入上下文后仍然出现匿名用户,检查 Filter 是否执行、是否在授权前、是否被后续逻辑清空、是否匹配了另一条链。

九、AnonymousAuthenticationFilter 为什么存在

没有登录信息时,Security 可以放入一个匿名 Authentication,而不是始终保持 null。这样授权规则可以统一表达 anonymousauthenticated 等状态。

匿名 Authentication 不等于用户已经通过真实认证。它通常由特定匿名 Token 和 authority 表示。访问受保护资源被拒绝后,异常翻译逻辑会根据当前是否属于匿名/未充分认证,决定启动认证入口而不是直接返回普通 403。

这解释了为什么“上下文里有 Authentication”不一定意味着用户真实登录,代码还要正确理解 authentication 类型和状态。

十、ExceptionTranslationFilter 如何决定 401 与 403

mermaid
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 通常负责:

  1. 判断当前请求是否携带本机制凭证。
  2. 提取用户名密码、Basic Header、Bearer Token 等。
  3. 构造“未认证”的 Authentication Token。
  4. 调用 AuthenticationManager,或对已验证 JWT 构造可信身份。
  5. 成功后写入 SecurityContext,并触发成功策略。
  6. 失败后清理上下文,调用失败处理。
  7. 决定继续过滤器链还是直接结束响应。

认证 Filter 不应:

  • 编写订单、审批等业务逻辑。
  • 只 Base64 解码 JWT 就认为可信。
  • 把客户端提交的 authorities 原样放进已认证对象。
  • 捕获所有异常后伪装成匿名请求,导致过期 Token 与未携带 Token 无法区分。

十二、自定义 Filter 放在哪里

常见 API:

java
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 会执行两次

常见原因:

  1. Filter 标注 @Component,被 Boot 注册为 Servlet Filter。
  2. 同一个实例又通过 http.addFilterBefore 加入 SecurityFilterChain。
  3. 于是它在原生 Servlet 链执行一次,在安全内部链又执行一次。

解决方式是明确它只属于哪一层。纯安全内部 Filter 不要无意成为全局 Servlet Filter;如果必须是 Spring Bean,可通过注册配置禁用其 Servlet 自动注册,再手动加入安全链。

十四、JDK 8 + Boot 2.7 可运行 Demo

java
@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:

java
@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";
    }
}

预期行为:

请求结果
无凭证访问 /public200
无凭证访问 /profile401
reader 访问 /profile200,返回 reader
reader 访问 /admin403
admin 访问 /admin200

十五、用 MockMvc 验证链路

java
@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 清理。

十八、源码调试入口

建议断点:

  1. DelegatingFilterProxy#doFilter
  2. FilterChainProxy#doFilterInternal
  3. FilterChainProxy#getFilters
  4. 内部 VirtualFilterChain 的 doFilter
  5. ProviderManager#authenticate
  6. ExceptionTranslationFilter#doFilter
  7. 当前版本的授权 Filter
  8. 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,而不是盲目把路径加入白名单。