简介:这份资源是围绕 SpringSecurity 自定义认证过滤器编写的完整项目源码,面向已掌握 Spring Boot 与 SpringSecurity 基础、希望深入理解认证流程扩展机制的 Java 后端开发者。项目以 JwtAuthenticateFilter 为核心示例,演示如何将自定义过滤器接入过滤器链,完成基于 JWT 的认证逻辑,适合用于登录鉴权模块的学习与二次开发参考。压缩包共 78 个文件,约 85KB,包含 14 个 java 源文件与对应 class 文件、15 个 xml 配置、17 个 json 数据文件,以及 yml、properties、http 请求脚本和 Maven 包装脚本等,覆盖源码、配置、测试与构建环节,目录结构清晰,便于按模块阅读。目前已有 140 人学习下载。通过该源码可掌握自定义过滤器的注册方式、认证信息封装与校验流程,并借助 http 请求文件快速验证接口行为,是理解 SpringSecurity 认证扩展的实用参考。
1. 自定义认证过滤器:为什么你的 Spring Security 总是“差一点”
很多开发者第一次接触 Spring Security 自定义认证过滤器,都是在项目里遇到一个绕不过去的需求:登录请求里带了加密的签名头,或者 token 放在 cookie 的某个自定义字段里,又或者认证前需要先校验设备指纹。默认的UsernamePasswordAuthenticationFilter只认表单里的 username 和 password,完全对不上业务。这时候你搜到“自定义认证过滤器”,照着几篇博客抄了一个OncePerRequestFilter,注册进链里,结果要么过滤器根本不执行,要么执行了但SecurityContext里始终是匿名用户,要么认证成功后下一个请求又变回 401。这个标题讲的就是怎么把这件事从头做对:从过滤器在 Spring Security 过滤链中的位置、认证对象的构造、SecurityContext 的写入时机,到注册方式和常见翻车点。适合已经能跑通默认表单登录、但需要接入自定义凭证体系的后端开发者,也适合想读懂框架认证入口的进阶读者。下面按“先立住原理、再动手复现、最后排坑”的顺序展开。
2. 认证过滤器在过滤链里到底站在哪:先搞清 DelegatingFilterProxy 到 FilterChainProxy 的调用顺序
2.1 从 Servlet 容器到 Security 过滤链的两次委托
Servlet 容器启动时,DelegatingFilterProxy作为一个标准的Filter注册进容器,但它本身不干活,而是把doFilter委托给 Spring 容器里名为springSecurityFilterChain的 Bean。这个 Bean 的真实类型是FilterChainProxy,它内部维护一个SecurityFilterChain列表,每个链匹配一组 URL,链里按顺序排列着十几个 Security 过滤器。请求进来后,FilterChainProxy根据RequestMatcher选中一条链,然后依次调用链中的过滤器。理解这个双层委托很关键,因为很多人把自定义过滤器注册成普通 Spring Bean 就以为生效了,实际上它根本没被加进SecurityFilterChain,自然不执行。
默认的过滤器顺序里,UsernamePasswordAuthenticationFilter大致排在LogoutFilter之后、DefaultLoginPageGeneratingFilter之前。如果你要替换表单登录的认证逻辑,最稳的做法是继承UsernamePasswordAuthenticationFilter并覆盖attemptAuthentication,然后通过HttpSecurity.addFilterAt把它放在原位置。如果你只是想在认证之前做一层预处理,比如解析自定义头并构造一个未认证的Authentication,那就用OncePerRequestFilter,通过addFilterBefore放在UsernamePasswordAuthenticationFilter前面。
2.2 认证对象、ProviderManager 与 SecurityContext 的三角关系
过滤器本身不负责“认证”,它只负责从请求里提取凭证,组装成一个未认证的Authentication对象,然后交给AuthenticationManager。AuthenticationManager的默认实现是ProviderManager,它持有一组AuthenticationProvider,逐个调用supports判断谁能处理这个类型的Authentication,再调用authenticate做真正的校验。校验通过后返回一个已认证的Authentication,过滤器再把它写入SecurityContextHolder。
这里有个容易忽略的点:SecurityContextHolder默认策略是MODE_THREADLOCAL,也就是说认证结果只对当前线程可见。请求结束后,SecurityContextPersistenceFilter(新版本叫SecurityContextHolderFilter)负责在请求开始时从HttpSession或自定义仓库恢复上下文,在请求结束时清空。如果你的自定义过滤器在SecurityContextPersistenceFilter之前执行,写入的上下文可能在后续被覆盖;如果在它之后执行但没手动保存,下一个请求就恢复不出来。这就是“认证成功但下一个请求又 401”的根因。
2.3 一个最小可跑的自定义过滤器骨架
下面这段代码演示一个从自定义请求头X-Auth-Token中读取凭证、构造Authentication并交给AuthenticationManager的过滤器。它继承OncePerRequestFilter,保证一次请求只执行一次。
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class TokenAuthFilter extends OncePerRequestFilter { private final AuthenticationManager authenticationManager; public TokenAuthFilter(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 从自定义头提取凭证,没有则直接放行,交给后续过滤器处理 String token = request.getHeader("X-Auth-Token"); if (token == null || token.isEmpty()) { chain.doFilter(request, response); return; } try { // 2. 构造未认证的 Authentication,token 作为 principal 或 credentials TokenAuthentication authRequest = new TokenAuthentication(token); // 3. 交给 ProviderManager 认证 Authentication authResult = authenticationManager.authenticate(authRequest); // 4. 认证成功,写入 SecurityContext SecurityContextHolder.getContext().setAuthentication(authResult); } catch (AuthenticationException ex) { // 5. 认证失败,清空上下文,不抛出,让后续入口处理 401 SecurityContextHolder.clearContext(); } chain.doFilter(request, response); } }逻辑说明:第 1 步的“没有凭证就放行”是刻意为之,因为过滤器链后面可能还有匿名认证或其他认证方式,直接拒绝会破坏链的协作。第 2 步构造的TokenAuthentication需要实现Authentication接口,并且isAuthenticated()返回 false。第 3 步的authenticate会触发ProviderManager找到支持TokenAuthentication的 Provider。第 4 步写入上下文后,后续的FilterSecurityInterceptor才能看到已认证用户。第 5 步捕获异常后不抛出,是为了让ExceptionTranslationFilter有机会把未认证请求重定向到登录入口或返回 401,而不是直接 500。
参数说明:authenticationManager必须是从HttpSecurity共享出来的那个实例,不能自己 new 一个ProviderManager,否则你注册的 Provider 和框架用的不是同一套。OncePerRequestFilter的shouldNotFilter方法可以覆盖,用来跳过静态资源或特定路径,避免无谓的 token 解析。
3. 把过滤器接进 HttpSecurity:注册位置、Provider 绑定与 SecurityContext 保存
3.1 用 addFilterBefore 还是 addFilterAt:位置决定行为
HttpSecurity提供了addFilterBefore、addFilterAfter、addFilterAt三个方法。addFilterAt是“替换”语义,如果目标位置已有同类型过滤器,会替换掉它;addFilterBefore和addFilterAfter是“插入”语义。对于自定义 token 认证,常见做法是addFilterBefore(tokenAuthFilter, UsernamePasswordAuthenticationFilter.class),让它在表单认证之前尝试处理。如果你要完全接管表单登录,就继承UsernamePasswordAuthenticationFilter并用addFilterAt放在原位置。
注册时还要注意过滤器的顺序号。Spring Security 内部给每个标准过滤器分配了Order常量,比如UsernamePasswordAuthenticationFilter的 order 是 1900 左右。用addFilterBefore时框架会自动计算一个相对顺序,但如果你同时插入多个自定义过滤器,最好显式用addFilterBefore的链式调用明确先后,避免顺序玄学。
3.2 自定义 Authentication 与 AuthenticationProvider 的配套写法
过滤器只负责搬运,真正的校验逻辑在AuthenticationProvider里。下面是一个配套的 Provider 示例,它校验 token 的合法性并返回已认证对象。
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.authentication.BadCredentialsException; public class TokenAuthenticationProvider implements AuthenticationProvider { private final TokenService tokenService; public TokenAuthenticationProvider(TokenService tokenService) { this.tokenService = tokenService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String token = (String) authentication.getPrincipal(); // 1. 校验 token 是否有效,无效直接抛异常 if (!tokenService.isValid(token)) { throw new BadCredentialsException("invalid token"); } // 2. 根据 token 解析出用户身份和权限 UserDetails user = tokenService.loadUser(token); // 3. 返回已认证的 Authentication,credentials 通常置空 TokenAuthentication result = new TokenAuthentication(user, user.getAuthorities()); result.setAuthenticated(true); return result; } @Override public boolean supports(Class<?> authentication) { // 4. 只处理 TokenAuthentication 类型 return TokenAuthentication.class.isAssignableFrom(authentication); } }逻辑说明:第 1 步的校验要区分“token 格式错误”和“token 过期”,前者抛BadCredentialsException,后者可以抛CredentialsExpiredException,方便前端区分处理。第 2 步加载用户时建议走缓存,否则每个请求都查库会成为瓶颈。第 3 步返回的已认证对象里,credentials一般设为 null,避免敏感信息留在内存。第 4 步的supports必须和过滤器构造的Authentication类型严格对应,否则ProviderManager找不到 Provider,会抛ProviderNotFoundException。
参数说明:TokenService是自定义的服务类,负责 token 的签发、校验和用户加载。TokenAuthentication需要提供两个构造器:一个用于未认证请求(只传 token),一个用于已认证结果(传 UserDetails 和权限列表)。getAuthorities在未认证时返回空列表,已认证时返回真实权限。
3.3 配置类里把过滤器、Provider、AuthenticationManager 串起来
下面是一个基于 Java 配置的完整装配示例,展示如何把自定义过滤器注册进链,并把 Provider 绑定到AuthenticationManager。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.util.Collections; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, TokenService tokenService) throws Exception { // 1. 手动构造 Provider 和 AuthenticationManager TokenAuthenticationProvider provider = new TokenAuthenticationProvider(tokenService); AuthenticationManager authManager = new ProviderManager(Collections.singletonList(provider)); // 2. 构造自定义过滤器,注入 AuthenticationManager TokenAuthFilter tokenFilter = new TokenAuthFilter(authManager); http .csrf().disable() .authorizeRequests() .antMatchers("/public/**").permitAll() .anyRequest().authenticated() .and() // 3. 把过滤器插到表单认证之前 .addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }逻辑说明:第 1 步手动构造ProviderManager是为了让自定义 Provider 生效。如果你用http.authenticationProvider(provider)也可以,但那样AuthenticationManager由框架构建,过滤器里注入的必须是同一个实例,所以更推荐显式构造再共享。第 2 步把authManager传给过滤器,保证过滤器和框架用同一套 Provider。第 3 步的addFilterBefore确保 token 认证先于表单认证执行,如果 token 存在且有效,后续表单认证不会覆盖已认证上下文。
参数说明:csrf().disable()在纯 token 认证场景下常见,因为 token 本身防跨站,但如果是 cookie 携带 token,建议保留 CSRF 防护。antMatchers的放行路径要按实际业务调整,避免把需要认证的接口误放行。ProviderManager的构造参数是List<AuthenticationProvider>,多个 Provider 时按顺序尝试,前一个抛异常不会自动尝试下一个,除非异常类型是AuthenticationException且下一个 Provider 的supports返回 true。
4. 避坑与排查:认证过滤器最常见的五类翻车现场
4.1 过滤器不执行:现象是断点不进、日志不打印
现象:自定义过滤器里的doFilterInternal完全不被调用,日志里也看不到任何输出。原因通常是过滤器没有真正加入SecurityFilterChain,只是被声明成了一个普通 Spring Bean。DelegatingFilterProxy只认springSecurityFilterChain这个 Bean,其他Filter类型的 Bean 不会自动进入安全链。解决:检查配置类里是否调用了addFilterBefore或addFilterAt,而不是只写了@Bean。另外,如果用了WebSecurityCustomizer的ignoring方法忽略了某些路径,那些路径上的所有安全过滤器都不会执行,包括自定义的。
4.2 认证成功但下一个请求又 401:SecurityContext 没保存
现象:当前请求里SecurityContextHolder.getContext().getAuthentication()有值,但下一个请求进来又变成匿名。原因是SecurityContext没有持久化到HttpSession或自定义仓库。默认情况下,SecurityContextPersistenceFilter会在请求结束时把上下文保存到 session,但前提是上下文在保存时仍然存在且未被清空。如果你的过滤器在SecurityContextPersistenceFilter之前执行,写入的上下文可能在链的后续被覆盖;如果用了SessionCreationPolicy.STATELESS,框架根本不会保存上下文,每个请求都需要重新认证。解决:确认过滤器的位置在SecurityContextPersistenceFilter之后,或者显式调用SecurityContextRepository.saveContext。无状态场景下,每个请求都必须携带 token 并重新走认证流程,这是设计预期,不是 bug。
4.3 ProviderNotFoundException:supports 方法没对上类型
现象:过滤器调用authenticationManager.authenticate时抛出ProviderNotFoundException,提示找不到支持某类型的 Provider。原因是自定义Authentication的类型和 Provider 的supports返回值不匹配,或者 Provider 根本没注册进AuthenticationManager。解决:在 Provider 的supports里打印一下传入的authentication.getClass(),确认类型一致。如果用了http.authenticationProvider(provider),检查配置类是否被@EnableWebSecurity注解扫描到。另外,ProviderManager的parent属性如果指向了另一个AuthenticationManager,而父管理器里也没有对应 Provider,同样会抛这个异常。
4.4 认证异常被吞掉:ExceptionTranslationFilter 没机会处理
现象:token 无效时,接口返回 500 而不是 401,或者前端收到一个空白响应。原因是过滤器里捕获了AuthenticationException后没有重新抛出,也没有调用AuthenticationEntryPoint,导致异常被吞掉,后续的ExceptionTranslationFilter看不到。解决:在catch块里清空上下文后,可以选择直接返回 401,或者把异常重新抛给ExceptionTranslationFilter。更规范的做法是让过滤器只负责提取凭证,认证失败时抛出自定义异常,由全局异常处理器统一返回。注意不要在过滤器里写response.getWriter().write后不return,否则会继续走链导致响应重复提交。
4.5 多次执行与重复认证:OncePerRequestFilter 不是万能的
现象:同一个请求里过滤器执行了两次,日志出现两条认证记录。原因是OncePerRequestFilter只保证在同一个请求的同一个FilterChain中不重复执行,但如果请求被forward或include,或者被RequestDispatcher再次派发,它可能重新进入过滤链。另外,如果自定义过滤器同时注册成了普通 Bean 和 Security 链过滤器,也会执行两次。解决:覆盖shouldNotFilterAsyncDispatch和shouldNotFilterErrorDispatch返回 true,避免异步和错误派发时重复执行。检查FilterRegistrationBean是否把自定义过滤器又注册了一遍,如果是,去掉那个注册。
5. 进阶技巧:用 AuthenticationEntryPoint 与自定义 SecurityContextRepository 收口认证边界
5.1 认证失败时返回结构化 JSON 而不是重定向
默认的LoginUrlAuthenticationEntryPoint会把未认证请求重定向到登录页,这在前后端分离项目里很别扭。自定义AuthenticationEntryPoint可以让失败响应直接返回 JSON。下面是一个示例。
import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class JsonAuthEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 1. 设置状态码和内容类型 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); // 2. 返回统一错误结构,不暴露内部异常细节 response.getWriter().write("{\"code\":401,\"message\":\"unauthorized\"}"); } }逻辑说明:第 1 步的状态码必须是 401,不要用 200 包一层业务错误码,否则前端拦截器无法统一处理。第 2 步的响应体结构要和全局异常处理器保持一致,避免前端写两套解析逻辑。这个 EntryPoint 需要在HttpSecurity里通过exceptionHandling().authenticationEntryPoint(...)注册,否则不生效。
参数说明:commence方法里不要抛异常,直接写响应并返回。如果用了HttpServletResponse.SC_UNAUTHORIZED,注意某些网关会把它改写成 200,需要在网关层排查。
5.2 自定义 SecurityContextRepository 实现无状态与有状态混合
有些场景下,部分接口需要 session 保持登录,部分接口用 token 无状态认证。这时候可以自定义SecurityContextRepository,在loadContext里优先从 token 解析,失败再回退到 session。下面是一个简化示例。
import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class HybridContextRepository implements SecurityContextRepository { private final TokenService tokenService; public HybridContextRepository(TokenService tokenService) { this.tokenService = tokenService; } @Override public SecurityContext loadContext(HttpRequestResponseHolder holder) { // 1. 先尝试从 token 恢复 String token = holder.getRequest().getHeader("X-Auth-Token"); if (token != null && tokenService.isValid(token)) { SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(tokenService.buildAuth(token)); return context; } // 2. 回退到 session return SecurityContextHolder.createEmptyContext(); } @Override public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) { // 3. 无状态场景不保存,有状态场景可写入 session } @Override public boolean containsContext(HttpServletRequest request) { return request.getHeader("X-Auth-Token") != null; } }逻辑说明:第 1 步的 token 恢复逻辑要和过滤器里的认证逻辑保持一致,避免两处解析规则不同导致行为分裂。第 2 步的回退到 session 需要注入HttpSessionSecurityContextRepository并委托给它,这里为了简洁省略。第 3 步的saveContext如果留空,等于完全无状态,适合纯 token 场景;如果需要混合,就在 token 不存在时委托给 session 仓库。
参数说明:HttpRequestResponseHolder是框架传入的请求响应包装,不要缓存它,每次loadContext都重新获取。containsContext的返回值影响框架是否调用loadContext,返回 false 时框架会直接创建空上下文。
5.3 一个验证过滤器是否生效的排查习惯
我一般会在过滤器里加一行logger.debug("token filter hit, uri={}", request.getRequestURI()),然后在配置类里打开org.springframework.security的 DEBUG 日志。启动后访问一个受保护接口,观察日志里过滤器的执行顺序和SecurityContext的变化。如果日志里看不到自定义过滤器的输出,先检查addFilterBefore是否写在了正确的HttpSecurity链上;如果看到了但认证没生效,检查AuthenticationManager是不是同一个实例。这个习惯帮我省掉了大量“玄学”排查时间,也避免了一上来就怀疑框架的弯路。希望帮到你。
本文还有配套的精品资源,点击获取