做架构这一行,很多人对Spring Security的印象是"能用,但不敢动"。默认配置一把梭,登录认证能跑通,一旦要接多租户、OAuth2资源服务器、自定义权限模型,或者调一次高并发下的会话性能,问题就一个接一个冒出来。这份2026版的进阶笔记,不是讲怎么搭一个Hello World,而是把过滤器链、认证流程、授权模型、会话管理这几个"核心脏器"逐个拆开,讲清楚它们的设计逻辑和常见的生产环境翻车点。
1. 过滤器链的底层秩序:理解Spring Security的"关卡设计"
很多人用Spring Security一年半载,能配置登录、授权、接口拦截,但问到"请求从进来到出去,到底经过哪些过滤器",基本都含糊。这个含糊在平时问题不大,一旦遇到"为什么我的自定义过滤器不生效"或者"为什么CORS配置明明写了却还是跨域报错",根本无从下手。
1.1 请求穿过SecurityFilterChain的真实路径
Spring Security的核心设计不是拦截器,也不是AOP切面,而是一条合法有序的过滤器链(FilterChain)。一个HTTP请求进入Spring容器后,会先经过Servlet容器本身的Filter,然后进入Spring Security注册的FilterChainProxy,它是一个Servlet过滤器,内部代理了若干条SecurityFilterChain。
每条SecurityFilterChain内部,按严格的顺序排列着一组安全过滤器。以Spring Security 6.x为例,我在生产项目里通过日志和断点确认过,默认链路的顺序大致是:
DisableEncodeUrlFilter:禁用URL重写,避免Session ID通过URL暴露WebAsyncManagerIntegrationFilter:把SecurityContext和异步请求集成SecurityContextHolderFilter:从SecurityContextRepository加载上下文(新版已经替代了旧的SecurityContextPersistenceFilter)HeaderWriterWriter:往响应头里写安全策略,比如X-Content-Type-OptionsCorsFilter:处理跨域CsrfFilter:默认拦截所有POST/PUT/DELETE请求,校验CSRF TokenLogoutFilter:处理退出登录UsernamePasswordAuthenticationFilter:处理表单登录,构建认证请求DefaultLoginPageGeneratingFilter:如果开启了表单登录且没写登录页,生成默认页BasicAuthenticationFilter:处理HTTP Basic认证RequestCacheAwareFilter:认证成功后跳转回缓存的原目标地址SecurityContextHolderAwareRequestFilter:包装Request,提供isUserInRole等方法AnonymousAuthenticationFilter:为未认证用户填充匿名Authentication对象SessionManagementFilter:处理会话固定、并发控制等ExceptionTranslationFilter:捕获安全异常,决定是重定向到登录页还是返回403AuthorizationFilter:做最终的URL级授权判定
这个顺序不是随机的。比如CsrfFilter要在UsernamePasswordAuthenticationFilter之前,因为表单登录的POST请求本身也要校验CSRF Token。再比如AnonymousAuthenticationFilter要放在授权过滤器之前,否则AuthorizationFilter看到完全没有Authentication对象,不知道该怎么处理匿名用户,就干脆抛异常了。
1.2 自定义过滤器应该插在哪
理解了顺序,就能解释一个经典的坑:我见过一个项目,团队想往链路里插入一个日志过滤器,记录每一个请求的当前登录用户,结果放在AuthorizationFilter之后,导致匿名用户访问公开接口时,日志里永远打印"anonymousUser",而授权失败的请求根本走不到他们的过滤器。
正确做法是明确你的过滤器职责,然后放到对应的位置。记录登录用户,应该放在AnonymousAuthenticationFilter之后,此时SecurityContext必然有值(至少会有匿名Authentication对象);如果是全局日志不想依赖认证,可以放链路的开头,或者干脆用Servlet层面的Filter。
插入方式有两类:
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterAfter(customFilter, HeaderWriterFilter.class); http.addFilterAt(customFilter, CsrfFilter.class);addFilterAt听起来像"放在同一个位置",很容易被误以为是替换原来的过滤器。实际上addFilterAt不会移除原过滤器,而是和原过滤器绑在一个CompositeFilter里,两个都会执行。如果要替换,得在Filter本身里面做逻辑判断,或者把原过滤器对应的配置整个关掉。
踩过坑的体会:写自定义过滤器时,优先想清楚它应该在"认证之前"还是"认证之后"执行。放到错误的位置,不如不放。
1.3 多SecurityFilterChain的匹配规则
大型项目里常常需要不同路径走不同规则,比如/internal/**不经过Session管理,/api/**走JWT校验。Spring Security允许通过SecurityFilterChain的securityMatcher指定请求匹配规则:
@Bean @Order(1) SecurityFilterChain internalChain(HttpSecurity http) { http.securityMatcher("/internal/**", "/actuator/**"); // 内部请求的配置 return http.build(); } @Bean @Order(2) SecurityFilterChain apiChain(HttpSecurity http) { http.securityMatcher("/api/**"); // API请求的配置 return http.build(); }这里@Order非常重要,Spring Security会从上到下按顺序找第一个匹配的过滤器链,一旦匹配就不再往后看。所以更具体的规则要放在前面,兜底的anyRequest放在最后。我见过有人把/**放在第一个,后面的/internal/**、/admin/**全部作废,排查了半天才反应过来是顺序问题。
2. 认证流程的深层架构:AuthenticationManager的分工逻辑
过滤器链只是"门卫巡逻",真正的认证动作发生在一个叫AuthenticationManager的组件里。它是整个认证体系的入口,职责一句话:接收一个未认证的Authentication对象,返回一个已认证的Authentication对象,或者抛出异常。
2.1 ProviderManager与AuthenticationProvider的分工
AuthenticationManager本身是接口,默认实现是ProviderManager。它的核心工作方式叫"遍历委托":把自己持有的所有AuthenticationProvider排成队列,逐个问"你能处理这个类型的认证吗?"能,就执行认证逻辑;所有都不能,抛ProviderNotFoundException。
这套设计解决了一个非常现实的问题:同一个应用里,可能同时存在表单登录、手机验证码登录、OAuth2登录、内部Token登录。每种登录方式需要的校验逻辑天差地别,如果全部塞进一个AuthenticationManager里,类会越来越肥,最终变成人人不敢动的巨型泥球。
AuthenticationProvider接口有三个方法:
public interface AuthenticationProvider { Authentication authenticate(Authentication authentication) throws AuthenticationException; boolean supports(Class<?> authentication); }supports方法决定了这个Provider处理哪种Authentication类型。比如DaoAuthenticationProvider支持的是UsernamePasswordAuthenticationToken,OAuth2LoginAuthenticationProvider支持它的OAuth2LoginAuthenticationToken。ProviderManager拿着登录请求传进来的Token类型,先问所有Provider谁能处理,再让能处理的Provider执行。
2.2 DaoAuthenticationProvider的内部处理链路
最常用到的DaoAuthenticationProvider,内部顺序大致是这样的:
- 从
Authentication里取出用户名和凭证(密码) - 调用
UserDetailsService.loadUserByUsername(username)加载用户 - 如果找不到用户,延迟抛出
BadCredentialsException——注意"延迟"这两个字 - 对密码进行编码比对,由
PasswordEncoder.matches(rawPassword, encodedPassword)完成 - 如果密码匹配,创建新的已认证Token,塞入权限和UserDetails对象
- 如果有
UserDetailsChecker(比如AccountStatusUserDetailsChecker),检查用户有没有被锁定、禁用
这里的"延迟抛出"是一个安全设计。如果用户不存在时直接抛UsernameNotFoundException,攻击者可以通过响应差异判断系统里有没有这个用户名。框架的默认做法是把用户不存在也伪装成密码错误,让两种失败看起来毫无区别。很多项目在这个地方"优化"过,反而破坏了安全边界。
2.3 UserDetailsService与UserDetails的定制策略
高级项目里,UserDetailsService是最容易踩坑的地方之一。它是从"某种数据源"加载用户信息的适配器接口,但很多人误以为它就只能查数据库。
我做过一个项目,用户体系在另一个团队的平台上,通过内部HTTP接口获取。我们的做法是写了一个RemoteUserDetailsService,实现loadUserByUsername时去调远程接口,返回一个实现了UserDetails的DTO。这个接口隔离的好处是,认证逻辑不关心你的用户数据存哪里、怎么存,只关心能不能拿到一个"带权限的用户"。
定制时有一个建议:不要在getAuthorities()方法里每次去查数据库权限。这个方法在每次请求的认证解析、授权判断里会被频繁调用,一旦里面藏了数据库查询,性能雪崩只是时间问题。我见过一台机器上因为UserDetails.getAuthorities()里动态查权限,导致每秒几百次SQL查询,直接把库打爆的案例。正确做法是把权限快照放在用户对象里,做权限变更时主动刷新用户缓存,而不是让每次请求都去现查。
2.4 记住我(Remember-Me)认证的隐藏分支
很多人不知道,RememberMeAuthenticationFilter在过滤器链里紧跟在AnonymousAuthenticationFilter之前。它的逻辑是:如果当前SecurityContext里还没有认证信息,就检查Cookie里的Remember-Me Token,尝试用Token认证。
它是独立于用户名密码认证的一整套链路,常见实现有两类:简单的TokenBasedRememberMeServices和基于Persistent Token的PersistentTokenBasedRememberMeServices。后者在Token泄露后可以从服务端撤销,安全性更好,适合安全要求高的项目。生产上Remember-Me Token的密钥必须独立配置,并且定期轮换,这个点很容易被忽略。
3. 授权粒度控制:从URL规则到方法级表达式
过滤器链里的AuthorizationFilter只负责URL层面的粗粒度授权,真正的细粒度控制靠的是方法级安全。两者配合,才称得上完整的授权体系。
3.1 URL授权与路径匹配的细节
URL授权配置看起来简单,但有几个细节很容易出错:
http.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/{id}").access(new WebExpressionAuthorizationManager("#id == authentication.name")) .anyRequest().authenticated() );第一,requestMatchers的匹配规则在Spring Security 6.x里有多种选择:MvcRequestMatcher(默认,遵循Spring MVC的路径匹配规则)、AntPathRequestMatcher、RegexRequestMatcher。默认的MVC匹配比Ant匹配更严格,/admin/和/admin会被视为同一资源。这个差异在某些老项目迁移时会引发莫名其妙的403问题。
第二,hasRole("ADMIN")底层会自动加ROLE_前缀,即实际校验的是ROLE_ADMIN权限。而hasAuthority("ADMIN")不加前缀。这个区别导致的坑很多:数据库权限表里存的是ADMIN,代码里用了hasRole("ADMIN"),结果所有用户都被拒绝访问,因为框架找的是ROLE_ADMIN。
第三,anyRequest()必须放在最后,且在一个过滤器链里只能有一个。Spring Security会在启动时校验配置的顺序,如果你把anyRequest()写在了前面,直接启动失败。
3.2 用@EnableMethodSecurity激活方法级授权
从Spring Security 6开始,原来旧版的@EnableGlobalMethodSecurity已经被新版的@EnableMethodSecurity取代。新版默认开启了@PreAuthorize和@PostAuthorize,可以配置是否启用@Secured和JSR-250的@RolesAllowed:
@Configuration @EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class MethodSecurityConfig { }启用之后,就可以在Service或者Controller方法上做细粒度授权:
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')") public void updateUser(Long userId, UserUpdateRequest request) { } @PreAuthorize("hasPermission(#orderId, 'Order', 'update')") public void updateOrder(Long orderId, OrderUpdateRequest request) { } @PostAuthorize("returnObject.ownerId == authentication.principal.id") public Order getOrderById(Long orderId) { }@PreAuthorize在方法执行前校验,适合做"能不能调用这个动作"的判断;@PostAuthorize在方法返回后校验,可以访问返回值,适合做"数据是否属于当前用户"的隔离判断。
3.3 SpEL表达式与PermissionEvaluator的扩展
方法级安全里最强大的其实是SpEL表达式。它不只是hasRole和hasAuthority,还能组合逻辑、访问方法参数、调用Bean方法:
@PreAuthorize("@orderService.isOwner(#orderId, authentication.name)") public void deleteOrder(Long orderId) { }这种方式把复杂的授权逻辑挂在任意的Spring Bean方法上,逻辑复用性最强。但它有一个性能警告:授权表达式里的Bean方法,会在每次调用被保护的方法时执行一次。如果你的isOwner方法里查了数据库,每个请求的多层方法调用会产生大量重复SQL。建议把这类查询结果放入Redis缓存,或者通过AOP缓存会话级别的结果。
真正复杂的权限模型——比如"部门主管可以修改本部门所有订单,但是金额超过十万的要财务总监审批"——用内置的表达式就不够用了。这时候可以实现PermissionEvaluator:
@Component public class CustomPermissionEvaluator implements PermissionEvaluator { @Override public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { // 复杂的权限判断逻辑 } @Override public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) { // 按ID和类型判断 } }然后在配置里注册:
@Bean DefaultMethodSecurityExpressionHandler expressionHandler() { DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler(); handler.setPermissionEvaluator(customPermissionEvaluator); return handler; }这里要注意,如果项目里有多个MethodSecurityExpressionHandler,注册顺序很重要。否则你自定义的PermissionEvaluator可能根本不会被调用,SpEL里的hasPermission会走默认实现返回false,表现为"所有带hasPermission的接口全部403",而且没有任何日志提示。
4. 会话管理:无状态时代依然绕不开的雷区
很多人觉得"我们做前后端分离,用JWT,根本不用Session",然后彻底忽略Spring Security的会话管理。这是一个危险的想法。哪怕不用Session存登录态,Spring Security的SecurityContextRepository、会话固定攻击、并发登录控制这些机制,依然会影响系统的安全边界。
4.1 SecurityContext的存在方式
Spring Security不会直接在本地变量里保存"当前用户"——它把认证结果放在SecurityContextHolder里。默认策略是MODE_THREADLOCAL,也就是每个线程一份,在线程池复用时会带来一个经典Bug:线程处理完请求后,SecurityContext如果不清理,下一个复用到这个线程的请求会"继承"上一个用户的身份。
官方其实已经处理了这个问题。SecurityContextHolderFilter在请求完成后会自动清理上下文。但如果你自己起了线程,比如在CompletableFuture里调用服务,那么这个新线程的SecurityContext是空的,根本拿不到当前用户。解决方案有两种:一种是从主线程获取SecurityContext对象,显式塞给子线程:
SecurityContext context = SecurityContextHolder.getContext(); CompletableFuture.supplyAsync(() -> { SecurityContextHolder.setContext(context); // 业务逻辑 return result; }, executor);另一种是用DelegatingSecurityContextCallable包装任务,由框架自动传递上下文。
4.2 会话固定攻击与防御
会话固定攻击是这样一个场景:攻击者先自己登录系统,拿到一个Session ID,然后想方设法让受害者使用这个Session ID(比如通过URL重写或者脚本注入)。受害者登录后,如果系统没有变更Session ID,攻击者就能通过自己手里的Session ID直接冒充受害者。
Spring Security默认的防御策略是changeSessionId,也就是用户登录成功后,原Session内容保留,但Session ID重新生成,让攻击者手里的旧ID失效。三种策略的取舍如下:
| 策略 | 行为 | 适用场景 |
|---|---|---|
changeSessionId | 保留Session属性,只换ID | 默认,最佳平衡 |
migrateSession | 创建新Session并拷贝属性 | 兼容老Servlet容器 |
none | 不处理 | 不推荐,相当于裸奔 |
我见过有团队为了"减少一次Session操作"把策略设为none,后来在做安全测试时被直接标记为高危漏洞。这类优化省下的那点性能,跟安全风险完全不成比例。
4.3 并发登录控制:踢人还是拒绝
一个账号允许同时在几个终端登录?这是产品经理和架构师经常吵架的问题。Spring Security提供了SessionRegistry来控制同一账号的并发会话数:
http.sessionManagement(session -> session .maximumSessions(1) .maxSessionsPreventsLogin(true) );maximumSessions(1)表示一个账号最多一个活跃会话。maxSessionsPreventsLogin决定策略:
true:新登录直接拒绝false:新登录踢掉旧会话
这里的坑在于,默认的SessionRegistry是基于内存的,在集群部署下,每个节点各有一份注册表,并发控制就失效了。生产环境如果要做多实例部署的并发控制,需要自定义SessionRegistry,比如把会话数据放到Redis里统一管理。Spring Session的FindByIndexNameSessionRepository可以帮我们按用户名查活动会话,但要注意版本兼容性,不同版本的Spring Session API有变动。
4.4 SessionCreationPolicy的选择哲学
配置里几乎必有这一行:
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));这个配置的语义是:Spring Security在请求处理中不创建、不读取HttpSession。很多人以为加上它"就是无状态了",实际上它只是让框架不碰Session,不代表你的应用完全无状态——前提是应用自身也不依赖Session。如果你的Controller里还有HttpSession相关的代码,建议做一次全面排查,因为无状态应用出现"用户登录态时有时无"的诡异问题,多半是有地方偷偷往Session里写了东西。
具体策略对比:
IF_REQUIRED:需要时才创建Session,默认策略STATELESS:不创建、不读取Session,适合纯API接口ALWAYS:每次请求都确保有Session,适合老系统兼容NEVER:框架自己不用Session,但如果有现成的Session不主动拒绝,因为NEVER不会阻止应用其他代码创建会话,所以在代码审查时它比STATELESS难把控
5. OAuth2资源服务器与JWT:从JWK联网到本地验签的完整落地
2026年了,微服务基本都用JWT做资源服务器的鉴权方案。JWT看起来只是一个"解析Base64,验签,取claim"的简单过程,实际落地的细节能把人折磨到头秃。
5.1 JwtDecoder的两种典型配置
资源服务器的核心是JwtDecoder,Spring Boot下有两种典型配置方式。
方式一:基于JWK Set的远程读取,适用于多点签发、密钥自动轮换的场景:
@Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder .withJwkSetUri("https://auth-server.example.com/.well-known/jwks.json") .jwsAlgorithm(RS256) .build(); }这行的意思是:资源服务器启动后,会去auth-server拉取JWK Set(JSON Web Key Set),里面包含了签验JWT的公钥。之后密钥轮换时,认证服务器更新JWK Set,资源服务器按一定间隔重新拉取,就能识别新签发的Token。
方式二:本地配置公钥,适用于单认证服务器、密钥不频繁变动的场景:
@Bean JwtDecoder jwtDecoder() { try { RSAPublicKey publicKey = loadPublicKey("public-key.pem"); return NimbusJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.RS256) .build(); } catch (Exception e) { throw new IllegalStateException("Failed to create JwtDecoder", e); } }5.2 JWK联网的故障放大效应
基于withJwkSetUri的配置最舒服,但它的故障模式很恐怖。我曾经在一个高并发项目里遇到过:认证服务器某次发布变更时临时不可用,资源服务器在拉取JWK Set时全部阻塞,所有携带JWT的请求需要验签,验签流程又拿不到新的公钥,导致全站接口大面积超时。
排查后发现两个叠加问题:
第一,NimbusJwtDecoder默认在启动时就远程拉取一次JWK,如果启动时认证服务不可用,资源服务器会启动失败。因为配置阶段build()直接抛异常,而且这个异常没有重试机制。解决方式是在build()外面包一层try-catch,第一次拉取失败时先用本地兜底公钥创建一个Decoder,后面再靠定时任务刷新。
第二,运行期间JWK刷新策略是"按需拉取+本地缓存",如果在高并发下缓存失效,会有大量请求同时触发远程拉取,形成缓存击穿。生产上建议在JwtDecoder前面再加一层redis缓存,把JWK Set缓存起来,并主动设置较短过期时间,比如5分钟,再配合认证服务端的推送失效事件。
这个坑给组的教训是:资源服务器的JwtDecoder是核心依赖,它依赖的远程资源必须有降级方案,不能让它成为一个单点故障源。
5.3 JWT Claim到权限的映射
默认情况下,JWT里的scopeclaim会转换成SCOPE_前缀的权限。很多项目的认证服务器返回的是authorities或者roles,资源服务器直接认证成功后,会发现hasRole("ADMIN")永远返回false,因为根本没有ROLE_ADMIN权限。
解决办法是实现自定义的JwtAuthenticationConverter:
@Bean Converter jwtAuthenticationConverter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { Collection authorities = new ArrayList<>(); if (jwt.hasClaim("roles")) { jwt.getClaimAsStringList("roles") .forEach(role -> authorities.add("ROLE_" + role)); } if (jwt.hasClaim("scopes")) { jwt.getClaimAsStringList("scopes") .forEach(scope -> authorities.add("SCOPE_" + scope)); } return authorities.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); }); return converter; }一个容易踩的细节是JWT里嵌套的claim。JWT claim支持嵌套对象,比如"permissions": {"orders": ["read", "write"]}。自定义解析时别用jwt.getClaimAsStringList("permissions"),这拿不到嵌套结构,需要把claim转成Map再遍历。我在项目里踩过这个坑,当时已经测出了"read权限缺失"的问题,但日志只显示了原始JSON,花了半天才定位到是嵌套解析的问题。
5.4 Token撤销与过期时间校验
很多人以为JWT签发后只能等它自然过期,因为JWT是无状态的。这句话对也不对。如果认证服务器持有一个可查询的会话存储,是可以在特定场景下把JWT"拉黑"的。
做法通常是维护一个Redis Set,存储已注销的jti(JWT的唯一ID)。然后在资源服务器的JwtDecoder上做一层包装:验签通过后,检查jti是否在黑名单集合里。这一步的成本是每次请求会多一次Redis查询,但它带来的收益是"用户被踢下线后,旧Token在几分钟内彻底失效",而不是眼睁睁地看着被盗的Token用到过期时间。
NimbusJwtDecoder的过期时间校验是默认开启的,只要expclaim存在且已过期,会抛JwtExpiredException。但要注意时钟偏移问题,如果资源服务器和认证服务器之间的时钟偏差过大,会出现"Token刚签发就过期"或者"已过期Token还能再撑一会儿"的现象。解决办法是用JwtValidators.createDefault()然后自己替换成带clockSkew的validator:
OAuth2TokenValidator validator = new DelegatingOAuth2TokenValidator<>( new JwtTimestampValidator(Duration.ofSeconds(30)), new JwtIssuerValidator("https://auth-server.example.com") );6. 生产环境排查实录:过滤器链配置叠加引发的"全接口401"
最后分享一个我近期带团队排查的真实案例,因为它几乎集中了前半篇讲到的所有知识点。
6.1 问题现象与初步定位
系统是一个Spring Boot 3.3 + Spring Security 6.3的微服务,某天安全加固后,测试反馈"所有接口都401了"。登录接口本身正常,拿到Token后调用其他接口,全部返回"Full authentication is required to access this resource"。
第一反应是排查Token解析链路。我们直接打开调试日志,加上logging.level.org.springframework.security=DEBUG,立刻在日志里看到了关键差异:请求走到了AnonymousAuthenticationFilter之后,但SecurityContext里没有认证信息,所以AuthorizationFilter直接抛了AccessDeniedException。问题不在授权规则,而是Token的认证过程根本没有执行。
6.2 一步步缩小范围
排查过程中,我们做了几件事:
- 确认
SecurityFilterChain里是否配置了oauth2ResourceServer。结果是有,而且配置看起来正常。 - 检查请求头。发现测试工具传的是
Authorization: Bearer,头格式没问题。 - 打开DEBUG日志确认
BearerTokenAuthenticationFilter是否执行。结果日志里完全没有这个Filter的任何输出,说明它压根不在当前请求的过滤器链上。
到这一步,怀疑点只剩两个:要么securityMatcher把请求匹配到了别的过滤器链,要么过滤器链的Order配置有问题。
6.3 根因:两条SecurityFilterChain的Order覆盖
看代码发现,项目里有两条过滤器链:
@Bean @Order(1) SecurityFilterChain publicChain(HttpSecurity http) { http.securityMatcher("/auth/**", "/health/**"); // 关闭安全,permitAll return http.build(); } @Bean SecurityFilterChain apiChain(HttpSecurity http) { http.securityMatcher("/**"); // 配置了oauth2ResourceServer return http.build(); }按@Order排序,/auth/**和/health/**走第一条,其余走第二条,逻辑上没问题。问题出在安全加固时,有人给publicChain加了一个requestMatchers("/api/webjars/**").permitAll()的配置,顺手把securityMatcher改宽了:
http.securityMatcher("/auth/**", "/health/**", "/api/webjars/**");结果出现了路径匹配的真空区:/api/any-endpoint既不完全匹配第一条链的securityMatcher,又会先于第二条链被匹配尝试,最终被第一条链捕获。而第一条链没有配置oauth2ResourceServer,于是过滤器链上压根没有BearerToken校验过滤器,请求以匿名身份走到授权过滤器,所有接口全部401。
6.4 修复方案与复盘心得
修复很简单:把/api/webjars/**从securityMatcher里去掉,改成在publicChain的内部authorizeHttpRequests里配置放行;同时在apiChain上把oauth2ResourceServer的配置独立出来。更稳妥的写法是在每条链里都明确写出authorizeHttpRequests的兜底策略,避免"这条链没有匹配到资源规则"时的真空状态。
复盘时我们总结了三个经验:
第一,多条SecurityFilterChain并存的系统,securityMatcher必须路径严格、互斥,不要有交集和真空区,否则请求落到哪条链上完全取决于@Order,极易出现"看起来配置了实际没走"的情况。
第二,排查认证类问题,先看DEBUG日志,确认"哪个过滤器处理了当前请求、处理结果是成功还是失败"。比瞎看代码效率高得多。Spring Security的FilterChainProxy日志会打印出匹配到的过滤器链,这一条信息能帮你快速定位问题链路。
第三,安全配置变更必须走代码审查和回归测试。这次事故就是一个看似无关紧要的路径匹配改动触发的。哪怕只是加一个permitAll,也要审视它对整个过滤器链匹配体系的影响。
Spring Security是一个被低估的复杂框架。它不只是一个登录框架,而是一整套完整的请求安全处理体系,从过滤器链的顺序,到认证对象在多个组件间流转,再到授权表达式的求值,每个环节都有值得深挖的设计逻辑。2026年的Spring Security在默认配置上已经做了大量简化,但简化不等于没门槛,生产环境踩过的那些坑,往往是默认配置帮我们掩盖了底层机制的复杂性。希望这份笔记,能帮大家省一点跟过滤器链和各种SecurityException搏斗的时间。