
1. 新版本配置到底改了什么先说结论Spring Security 6 之后最大的变化不是新增了多少功能而是把以前那一套“继承 WebSecurityConfigurerAdapter 重写 configure”的写法彻底废掉了。如果你是从 Spring Boot 2.x 时代过来的第一次打开 Spring Boot 3.x 项目的 Security 配置类大概率会一脸懵WebSecurityConfigurerAdapter没了antMatchers报错了and()也找不到了。这其实是 Spring Security 团队蓄谋已久的一次大清洗。他们从 5.x 中期就开始铺垫引入 SecurityFilterChain 和 lambda 风格配置6.0 直接把旧 API 移除。为什么要这么干最直接的原因是老写法太容易踩坑——WebSecurityConfigurerAdapter是一个抽象类里面默认把过滤链路全部配好了你继承它再重写其中一部分方法实际上是在“修改默认链路”。问题在于这个隐式链路太黑盒了很多开发者根本搞不清某个过滤器什么时候加进来的出问题只能靠猜。新写法把“构建过滤链”这件事完全显式化每一步都是你自己拼装配置类只是一个工厂方法可读性和可控性都上了一个台阶。另一个核心变化是授权规则的写法全面转向“请求匹配器 权限表达式”。旧版里authorizeRequests()搭配antMatchers(/admin/**).hasRole(ADMIN)是标配新版变成了authorizeHttpRequests()搭配requestMatchers(/admin/**).hasRole(ADMIN)。看着只是换个方法名实际上匹配器的实现从 AntPathMatcher 换成了更规范的路径匹配体系对**、*、?这些通配符的处理更严格也支持了 MVC 的路径语义。比如/api/**这种写法在旧版会有歧义新版里语义更明确避免了很多“明明路径能访问却 403”的诡异问题。还有一点容易被忽略csrf、cors、session、logout 这些配置全部改成了 lambda 风格链式写法。以前http.csrf().disable() .cors().and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) ...现在http.csrf(csrf - csrf.disable()) .cors(cors - cors.configurationSource(corsConfigurationSource())) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) ...这是从“链式对象”到“回调配置器”的转变。好处是每个模块的配置代码都封闭在自己的 lambda 里不再依赖.and()来回跳转报错时的堆栈也更好排查。一句话总结新版本配置不是“换个写法”而是把安全规则从“继承扩展”改为“组合式声明”。理解了这个底层思路后面所有改动就都能顺下来。2. 配置的核心骨架新版本里不再需要任何类去继承 SecurityConfigurerAdapter只需要一个普通的Configuration类在里面声明两个 Bean 即可。2.1 核心骨架示例先看一个最常用的全量配置骨架这是我在实际项目中用的模板基本涵盖了大部分常规需求Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(cors - cors.configurationSource(corsConfigurationSource())) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /public/**, /actuator/health).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/user/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() ) .exceptionHandling(ex - ex .authenticationEntryPoint(restAuthenticationEntryPoint()) .accessDeniedHandler(restAccessDeniedHandler()) ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } // 其他 Bean ... }这段配置一共干了几件事关闭 csrf无状态接口的标准操作启用自定义 cors把 session 策略设为 STATELESS声明接口的访问规则自定义 401 和 403 的处理逻辑把 JWT 过滤器插到用户名密码过滤器前面关键点是SecurityFilterChain这个 Bean。它是整个配置的核心产物Spring Boot 自动配置会收集所有SecurityFilterChain类型的 Bean按Order排序后逐个匹配请求。每个请求只会走第一个匹配的过滤链所以可以定义多条链比如/api/**走 JWT 链/ws/**走 WebSocket 链实现“一个应用多种安全策略”。2.2 Bean 的装配逻辑在新版本里你需要显式声明必要的 Bean比如 PasswordEncoder、AuthenticationManager、UserDetailsService 等。Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } Bean public UserDetailsService userDetailsService() { // 以内存用户为例实际项目中通常替换为数据库查询 UserDetails user User.withUsername(admin) .password(passwordEncoder().encode(admin123)) .roles(ADMIN) .build(); return new InMemoryUserDetailsManager(user); }这里有个容易踩的坑如果自己定义了UserDetailsService必须同时定义PasswordEncoder否则启动时会抛异常。因为 Spring Security 初始化时会检查密码编码器找不到就报错。如果你的密码是明文的比如遗留系统至少也要用NoOpPasswordEncoder实例占位但生产环境不建议这么干。AuthenticationManager是个很有意思的细节。旧版里直接注入一个全局的 AuthenticationManager 就能用新版必须通过AuthenticationConfiguration来获取。这是因为新版把 AuthenticationManager 的构建过程完全交给了自动配置内部你只能通过这个配置类来拿实例。如果项目里用了多种认证方式比如用户名密码 短信验证码 第三方授权可以自己定义AuthenticationProvider并注入到AuthenticationManager灵活性比旧版高很多。3. 关键配置逐项拆解新版本配置看着不复杂真正写起来全是细节。下面逐项讲一讲最容易出问题的几个配置。3.1 路径匹配规则requestMatchers 的正确用法requestMatchers已经取代了antMatchers和mvcMatchers但它内部支持多种匹配模式需要分清楚// 按 MVC 语义匹配推荐 .requestMatchers(/admin/**).hasRole(ADMIN) // 按正则匹配 .requestMatchers(RegexRequestMatcher.regexMatcher(/api/v[0-9]/.*)).permitAll() // 按请求方法限定 .requestMatchers(HttpMethod.DELETE, /api/**).hasRole(ADMIN)我在实际项目里常用的套路是先定义公共匹配再定义精细匹配最后用anyRequest().authenticated()兜底。注意区分permitAll()和anonymous()permitAll()是“不拦截谁都能访问”anonymous()是“只允许匿名用户访问登录了反而拒绝”。比如/login页面如果只允许未登录用户看用anonymous()更合适。新版匹配器对路径**的处理更严格。/admin/**匹配/admin/xxx和/admin/xxx/yyy但不会匹配/admin本身。如果你需要同时匹配/admin和/admin/**可以写两个requestMatchers(/admin, /admin/**)。3.2 CSRF 与 CORS 没有固定答案新版本默认是开启 CSRF 的这一点很多人忽略。如果你做的是前后端分离的无状态接口一定记得关掉.csrf(csrf - csrf.disable())但如果你的应用是服务端渲染的传统 MVC或者有表单登录CSRF 必须保留而且要配置csrfTokenRepository让前端能拿到 token。很多时候接口被莫名 403就是 CSRF 校验没过。CORS 配置则要区分场景。如果是纯后端接口让网关或 Nginx 处理跨域更合适如果要直接在应用里配推荐用CorsConfigurationSource而不是在过滤器链里写死Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(List.of(https://example.com)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }然后链里引用.cors(cors - cors.configurationSource(corsConfigurationSource()))注意setAllowCredentials(true)是允许携带 Cookie 用的这时候setAllowedOrigins不能是*必须写具体域名否则浏览器会拒绝。很多人在这个细节上踩坑——明明 CORS 配置看起来没毛病跨域请求却一直报错十有八九是通配符和 allowCredentials 冲突。3.3 无状态会话与 JWT 过滤器无状态接口的标准配置就是SessionCreationPolicy.STATELESS。这行配置的意思是Spring Security 不在 Session 里保存用户信息每次请求都当独立请求处理。但这不代表它不创建 Session——如果后面有代码主动调用了request.getSession()Session 照样会创建只是 Security 不依赖它。所以排查 Session 问题时别只看 Security 配置。JWT 过滤器是典型的核心扩展点。新版本里OncePerRequestFilter仍然是首选因为 Spring 无法保证 Servlet 容器不会重复调用过滤器。一次请求可能经过多次过滤链这个类能保证每个请求只执行一次Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { if (jwtTokenProvider.validateToken(token)) { AuthenticatedUser user jwtTokenProvider.getUserFromToken(token); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }过滤器插入的位置一般是UsernamePasswordAuthenticationFilter之前。这样做的目的是在标准认证流程之前先检查 JWT 是否有效。如果有效就把认证信息放进SecurityContext后续的授权判断就在这个上下文里读取权限。这里有个非常重要的细节过滤器只负责认证不负责抛异常。如果 JWT 无效不要直接在过滤器里返回 401因为下游的ExceptionTranslationFilter和自定义AuthenticationEntryPoint已经处理了这类逻辑。你在过滤器里直接返回响应会绕过这些标准流程结果就是异常行为非常难排查。正确做法是“验证失败就当没看到”继续调用filterChain.doFilter让后续过滤器发现没有认证信息后触发 401 流程。3.4 自定义认证流程如果你用的是短信验证码、扫码登录这类非标准认证方式AuthenticationProvider是正确切入点Component public class SmsAuthenticationProvider implements AuthenticationProvider { Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String phone authentication.getName(); String code (String) authentication.getCredentials(); if (!smsService.validateCode(phone, code)) { throw new BadCredentialsException(验证码错误或已过期); } UserDetails user userDetailsService.loadUserByUsername(phone); return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); } Override public boolean supports(Class? authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); } }然后在配置类里把 provider 共享给 AuthenticationManagerBean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }如果你想把这个 provider 应用到全局可以注入到AuthenticationManagerBuilder。新版本里AuthenticationConfiguration会自动收集容器中的AuthenticationProviderBean所以直接把SmsAuthenticationProvider声明为Component并被Configuration类扫描到即可无需手动注册。4. 实操中的问题与排查4.1 新版本里 antMatchers 突然报错在 Spring Security 6 中antMatchers已经被彻底移除。如果代码还在用编译器会直接标红。迁移时建议做一次全局替换把所有antMatchers改成requestMatchers注意路径通配符的差异。在旧版 Ant 语义里/admin/**能匹配到/admin本身新版中不一定所以遇到地址匹配不到的情况优先检查这些通配的边界。4.2 配置了放行路径但仍然被拦截这是最常见的问题绝大多数原因是顺序不对。authorizeHttpRequests里的匹配规则是从上到下执行的第一条匹配到了就结束后面的不会再判断。所以.authorizeHttpRequests(auth - auth .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/**).permitAll() .anyRequest().authenticated() )这种情况下/api/admin/login会被第一条规则拦截要求 ADMIN 角色。如果你真的希望这个接口放行得把它放在更靠前的位置.requestMatchers(/api/admin/login).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN)放行规则永远写在限制规则前面这是铁律。4.3 过滤器链被跳过如果定义了多个SecurityFilterChainBeanSpring 会按Order从小到大匹配第一个匹配到的链生效。匹配不到的请求会落到最后一条默认链。如果发现某个请求总是跳过你的过滤器先确认是不是Order加错了。另一个可能的原因是过滤器加到了不该加的链上。比如你的 JWT 过滤器只想对/api/**生效那就在那个特定链里添加。如果配置到默认链所有请求都会经过它可能导致静态资源也走 JWT 过滤性能白白损耗。4.4 返回 401 还是 403 傻傻分不清这个在配置异常处理时特别容易搞混401AuthenticationEntryPoint用户未认证没有凭证让它去登录或返回统一 JSON{code: 401, msg: 未登录}403AccessDeniedHandler用户已认证但权限不够返回{code: 403, msg: 无权限}如果你只配置了accessDeniedHandler但没配置authenticationEntryPoint未登录访问时会落入默认逻辑——重定向到/login或者返回一个奇怪的 HTML 页面。前后端分离项目最好两个都自定义保持接口错误格式统一。4.5 刷新令牌与安全上下文的坑用 JWT 刷新令牌的场景中常见的问题是在过滤器中提前把用户权限从数据库加载并放进SecurityContext而忽略了刷新令牌可能已经过期。新版 Spring Security 对SecurityContext的并发处理有改进但你仍然需要自己判断 “认证信息是否仍然有效”。一个常用的套路是过滤器里只做“令牌是否合法 用户是否存在”的检查权限实时从数据库查保证权限变化能即时生效。这种做法性能稍差但可控性强。如果追求性能可以引入缓存但一定不要缓存太久否则权限变更不生效。5. 迁移与组件化配置5.1 迁移路径从旧版迁移到新版我建议按以下顺序操作先把 Spring Boot 升级到 2.7.x这是最后一个兼容旧写法的大版本期间可以先把部分WebSecurityConfigurerAdapter迁移到SecurityFilterChain再把 Spring Boot 升级到 3.0.x此时旧 API 已经删除必须全面使用新写法全局搜索antMatchers、authorizeRequests、and()等旧 API逐个替换处理spring.factories中WebSecurityConfigurerAdapter的依赖改为配置类中的 Bean检查自定义过滤器链的Order确保多条链顺序正确整个迁移过程最耗时的不是写新代码而是排查那些“静态资源 403”“接口放行失效”这类隐藏问题。建议迁移阶段开启 Security 的 debug 日志logging: level: org.springframework.security: DEBUG日志会打印每个请求匹配了哪条过滤器链、哪些规则匹配成功、拒绝了什么。这对排查顺序问题几乎是一锤定音。5.2 多链配置实战实际项目中WebSocket、短信验证码、第三方回调经常需要不同的安全策略。多链配置非常方便Bean Order(1) public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http .securityMatcher(/api/**) .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } Bean Order(2) public SecurityFilterChain wsFilterChain(HttpSecurity http) throws Exception { http .securityMatcher(/ws/**) .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth.anyRequest().authenticated()) .exceptionHandling(ex - ex.authenticationEntryPoint(wsAuthenticationEntryPoint())); return http.build(); }注意这里用的是securityMatcher它是整条链的入口匹配和授权规则里的requestMatchers不是一个维度。我见过不少人把两者混用结果链匹配不出意外授权规则却一脸懵。多链配置有两点要重点把控每条链的securityMatcher不能重叠否则会按Order走前面的链后面的链形同虚设不同链的 Match 规则不必重复声明每条链只关心自己范围内的接口即可5.3 组件化把配置拆分成小模块新版本最大的优点是配置可以拆得特别细。我习惯把配置拆成“通用配置 业务配置”两层通用配置CSRF、CORS、Session 策略、异常处理、密码编码器业务配置路径匹配规则、JWT 过滤器、短信过滤器比如把 JWT 相关的东西封装成一个配置类Configuration EnableWebSecurity public class JwtSecurityConfig { Bean public SecurityFilterChain jwtFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /public/**).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }然后另一个模块的配置就只管自己的业务过滤器配置类之间通过Order区分职责。这样下去修改某条链的规则不会影响其他链代码可读性和可维护性都大幅提升。6. 说几个我自己踩过的坑6.1 静态资源被拦截新版本默认会拦截所有请求静态资源也不例外。最稳妥的方式是在前面放行资源路径.authorizeHttpRequests(auth - auth .requestMatchers(/css/**, /js/**, /images/**, /favicon.ico).permitAll() .anyRequest().authenticated() )另一个坑是路径匹配规则里写了/resources/**但静态资源实际放在/static/**结果一样被拦。写配置前先确认资源路径别依赖默认约定。6.2 lambda 表达式里不能用整段 if-elseauthorizeHttpRequests的 lambda 里看起来能写代码但它最终会被解析成一组规则集合不能在里面做复杂的业务判断。如果你要按权限动态放行建议在服务层处理或者在规则里用access(hasRole(ADMIN) or hasIpAddress(192.168.1.1))这类表达式完成。实际操作中我见过有人试图在 lambda 里if (config.isEnableXxx())动态决定规则结果启动时一切正常运行时某些接口突然失效。调试半天才发现 lambda 里写逻辑太黑后来全部改成在代码逻辑里提前构建好规则再传进去。6.3 自定义过滤器的顺序不要乱搞过滤器顺序直接影响很多诡异问题的结果。JWT 过滤器放在UsernamePasswordAuthenticationFilter前面是标准做法但如果同时有日志审计、接口幂等性检查之类的过滤器建议参考 Spring Security 的过滤器顺序表不要凭感觉乱排。过滤器的执行顺序是AuthorizationFilter→ExceptionTranslationFilter→FilterSecurityInterceptor→ ...如果你把业务逻辑过滤器插到FilterSecurityInterceptor之后它可能拿不到安全上下文因为认证信息已被清空。6.4 测试时 CORS 明明配置了还报跨域这个问题大概率是把 CORS 配置写到了某个不被执行的配置类里。多链场景中如果/api/**请求走的是第一个SecurityFilterChain但 CORS 配置写在了第二个链里那它永远不会生效。每条需要跨域的链都得配上 cors。还有一个原因是浏览器预检请求没走完整个链路。OPTIONS预检请求在 CORS 配置正确的情况下会在 CORS 处理器被处理不需要经过授权规则。但如果在authorizeHttpRequests里把 OPTIONS 给限了预检就可能被拒。一个通用做法就是把HttpMethod.OPTIONS放行.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()7. 后续还能加点什么新版本配置做熟之后可以顺手把这几件事一起做了集成 Spring Authorization Server把授权码模式玩起来自定义AuthenticationSuccessHandler/AuthenticationFailureHandler把登录成功的 token 返回、失败的信息提示都统一风格结合 Actuator Security 做运行时安全状态监控比如在线用户数、认证失败次数、最近认证趋势把安全配置固化成 Starter多项目复用我个人最推荐的是“统一认证结果输出”。前端对接时最怕的就是不同接口返回不同结构的错误信息。把认证成功、失败、无权限、未登录这四类情况的响应体统一格式无论是自测还是联调都能省下大量时间。最后再分享一个小技巧新版本里HttpSecurity的 lambda 回调其实是支持调试的。你可以为每个模块单独写一个CustomerConfigurer实现SecurityConfigurer接口把一坨配置收拢到一个类里。这样配置类从几百行缩到几十行改起来也更有针对性。我当初就是从这一步开始才真正摸清了 Spring Security 6 的脾气。如果你正在迁移或者刚接触新版本按上面这些坑逐个排查基本能一次跑通。等你真的把过滤器链玩明白了会发现这套组合式配置比旧版爽太多了。