上周接手一个老项目的安全模块升级,里面还是 Spring Security 5.6 那一套继承WebSecurityConfigurerAdapter的写法,顺手往 6.2 迁移时发现,网上大量资料还在讲老写法,照着抄连编译都过不去。这篇我把 Spring Security 新版本配置完整捋一遍,从一个能直接跑的最小配置开始,到多过滤链、用户存储、会话管理、CSRF、CORS、常见坑,一次讲清楚。适合正在升 Spring Boot 3.x、或者刚上手 Spring Security 6 的人,也适合想把老配置改成组件化新写法的同学。
1. Spring Security 新版本到底新在哪里
1.1 老写法为什么被淘汰
老写法的核心是继承WebSecurityConfigurerAdapter,然后重写两个方法,看一段教科书式代码:
// 这是 Spring Security 5.x 时代的典型写法,到 6.x 已经被彻底移除 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/public/**").permitAll() .anyRequest().authenticated() .and() .formLogin().permitAll(); } }这种写法有两个本质问题。第一是组件碎片化。UserDetailsService、PasswordEncoder、AuthenticationManager这些安全体系里的核心对象,被硬塞进一个继承结构里,组合和替换都很别扭。我想换一种密码编码器,得先看这个类在哪个位置被实例化;想加一个登录成功处理器,又要去查继承链上的回调方法。第二是职责过剩。一个安全配置类既要管 URL 授权,又要管登录表单,还要定义认证管理器,项目一复杂就膨胀成“上帝类”,改一处动全身。
Spring Security 6 把配置方式彻底改成组件装配。框架不再要求你去继承任何配置适配器,而是通过声明SecurityFilterChain类型的 Bean,把过滤器链的组装完全交给开发者。这样做的好处是安全配置可以按业务拆分成多个类,每个类只负责一个模块,比如/api/**一条链、/admin/**一条链。单元测试也更好做,因为你随时可以构造一个最小过滤链来验证某条规则。
这里有一个容易忽略的版本约束:Spring Security 6 要求 JDK 17+,同时要求 Servlet 6+。如果你用 Spring Boot 3.x,这些基础条件已经满足;如果还是传统 Servlet 容器或者旧版 Tomcat,那配置代码写得再对,启动阶段也会直接报不兼容错误。
1.2 新写法的核心:SecurityFilterChain 与组件化
新配置的核心是注册一个SecurityFilterChainBean。这个接口描述了一条完整的过滤器链:哪些请求进入这条链、链上挂哪些过滤器、每个过滤器如何行为。从架构角度看,Spring Security 的过滤器链本质上是一个责任链模式,所有请求先经过FilterChainProxy,由它决定走哪一条链。
最小配置长这样:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ); return http.build(); } }这段代码没有任何继承,完全是 Bean 声明式的。HttpSecurity由 Spring 注入,它内部维护了一组配置器,每个配置器对应一个或几个过滤器:
csrf()配置CsrfFilterformLogin()配置UsernamePasswordAuthenticationFilterlogout()配置LogoutFiltersessionManagement()配置SessionManagementFilterexceptionHandling()配置ExceptionTranslationFilter
每一类配置器只负责自己的那一段逻辑,相互之间靠HttpSecurity串联,最后build()生成一个不可变的过滤链。这种设计让新增过滤器、替换某个环节都变得很干净,也符合 Spring 一直提倡的组合优于继承。
1.3 URL 匹配器重命名背后的逻辑
老代码里的antMatchers()、mvcMatchers()在 6.x 中已经被统一为requestMatchers()。这个变化不只是改个名字,而是为了消除路径匹配语义的歧义。antMatchers用的是 Ant 风格路径表达式,比如/users/**;mvcMatchers还会去解析 Spring MVC 的映射条件,行为不完全一致。新版的requestMatchers是对RequestMatcher接口的统一入口,支持字符串、HttpMethod、正则表达式、自定义RequestMatcher,语义直观得多。
有一点必须强调:authorizeHttpRequests内部是按从上到下的顺序匹配的,一旦某个规则匹配成功,后面的规则就不会再执行。所以放行规则必须写在保护规则前面,而anyRequest()必须放在最后。
.authorizeHttpRequests(auth -> auth .requestMatchers("/public/**", "/login").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() )如果把顺序反过来,/public/**会被后面的保护规则拦截,登录页面都进不来。这个坑在升级老项目时相当常见,因为老代码里可能就有这种顺序问题,只是以前某些路径行为碰巧掩盖过去了。
另一个细节:requestMatchers支持多值参数,比如写requestMatchers("/css/**", "/js/**", "/images/**").permitAll(),一次性放行静态资源,比一条一条写清爽很多。
2. 整体设计:先画安全边界再写代码
2.1 一张表理清需求
拿到一个项目,第一步绝对不是找配置代码,而是先回答三个问题:应用形态是什么、认证方式是什么、授权模型是什么。这三个答案基本决定了 80% 的配置项。
| 关键问题 | 服务端渲染应用 | 前后端分离 / 纯 API |
|---|---|---|
| 登录方式 | 表单登录 + Session | Token 或 Basic 认证 |
| CSRF | 建议开启 | 无状态场景一般关闭 |
| 会话策略 | IF_REQUIRED | STATELESS |
| 未认证返回 | 页面重定向 | JSON 结构 |
| URL 匹配 | requestMatchers | requestMatchers + HttpMethod |
服务端渲染项目常见于传统后台管理系统,登录成功后需要保持会话,页面表单提交要带 CSRF Token,否则 POST 请求容易被拦截。前后端分离项目则更常见于移动端或 Web 前端单独部署,登录接口返回 Token,后续请求放在请求头里携带,服务器不维护 Session。这两种形态的安全配置侧重点完全不同,混在一起写往往两边都不讨好。
2.2 多条过滤链怎么分
如果一个系统里既有管理后台,又有面向用户的开放接口,两类请求的认证方式往往是冲突的。管理后台用表单登录加 Session,接口用无状态 Token 校验,硬塞进同一条过滤器链会非常难维护。最佳实践就是拆成多条SecurityFilterChain,按 URL 前缀区分。
@Configuration @EnableWebSecurity public class MultiChainSecurityConfig { @Bean @Order(1) public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .httpBasic(Customizer.withDefaults()); return http.build(); } @Bean @Order(2) public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index") .permitAll() ); return http.build(); } }这里最关键的是@Order注解。多条过滤链按注解上的数字从小到大依次尝试匹配,第一条链的securityMatcher匹配成功后,后面的链就不再处理。如果把@Order(1)给到通用链,所有请求都会先被它接管,API 链永远不生效,排查起来相当头疼。
2.3 版本与依赖基线
如果使用 Spring Boot,版本基线最简单,直接用spring-boot-starter-parent管理依赖,Boot 3.x 会帮我们锁定 Spring Security 6.x 的版本,不需要手动指定。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies>如果不是在 Spring Boot 项目里,那就需要手动引入spring-security-web和spring-security-config,并且保证这两个包版本完全一致。版本不一致最典型的症状是启动时出现NoSuchMethodError或ClassNotFoundException,而且报错位置经常不在安全模块本身,排查成本很高。
3. 核心配置逐段实战拆解
3.1 最小可运行配置
先给一份最小但完整的配置,覆盖用户、密码、过滤链三件套,能直接跑起来:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { UserDetails admin = User.withUsername("admin") .password(passwordEncoder.encode("admin123")) .roles("ADMIN") .build(); return new InMemoryUserDetailsManager(admin); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index") .permitAll() ) .logout(logout -> logout .logoutSuccessUrl("/login?logout") .permitAll() ); return http.build(); } }这段代码里有三个新版本特有的细节,值得单独说明。
UserDetails接口在 6.x 提供了流式构造器,User.withUsername(...)返回一个 builder,.roles("ADMIN")会自动加上ROLE_前缀,内部存成ROLE_ADMIN。后续写hasRole("ADMIN")时不需要再加前缀,这个设计比老版本手工拼前缀要友好得多。
InMemoryUserDetailsManager只建议在 Demo 或测试环境使用。生产环境密码放在内存里既不灵活也不安全,下一节会讲数据库方式。
formLogin里我传了loginPage("/login"),这意味着框架不再生成默认登录页,你必须自己提供一个/login页面或接口。很多人升级后遇到 404,多半就是漏了这一步。
3.2 密码存储与用户服务
实际项目里,用户信息通常放在数据库。实现UserDetailsService接口,从数据库查出用户后转成 Spring Security 认识的UserDetails:
@Service public class DatabaseUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public DatabaseUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserAccount account = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username)); return User.withUsername(account.getUsername()) .password(account.getPassword()) .roles(account.getRoles().split(",")) .disabled(!account.isEnabled()) .build(); } }光有UserDetailsService还不够,还要把PasswordEncoder和用户服务组装成一个AuthenticationProvider,否则框架不知道应该用哪种编码方式去校验密码:
@Bean public AuthenticationProvider authenticationProvider( UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; }DaoAuthenticationProvider的认证流程大致是:先调用loadUserByUsername加载用户,再用配置好的PasswordEncoder比对请求密码和存储密码,最后检查用户是否启用、未过期、未锁定。如果这些状态条件不满足,会分别抛出不同的异常,最终由AuthenticationEntryPoint转成对应响应。
这里还有一个容易被忽略的点:Spring Boot 一旦检测到自定义的UserDetailsService,启动时就不再生成默认的 user 账号和随机密码。所以刚自定义完用户服务,日志里看不到Using generated security password是正常现象。
密码编码器方面,新版本默认的PasswordEncoder是DelegatingPasswordEncoder,支持带算法前缀的密码格式,比如{bcrypt}xxxx、{noop}xxxx、{argon2}xxxx。如果老项目里的密码还是明文或者 MD5,一定要统一升级为 BCrypt。我常用的思路是:数据量小就启动时批量重新编码;数据量大就实现一个“检测到旧格式密码时登录成功后自动升级”的逻辑,在loadUserByUsername返回后判断存储密码是否以$2a$开头,如果不是就调用passwordEncoder.upgradeEncoding()重新编码并写回数据库,这样用户无感知地平滑升级。
3.3 登录会话与前后端分离适配
会话管理是安全配置里直接决定用户体验的一环。新版本推荐用sessionManagement配置器显式声明策略:
.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) .expiredUrl("/login?expired") )IF_REQUIRED表示需要时才创建 Session,适合服务端渲染项目;maximumSessions(1)可以把同一个账号的并发会话限制为一个,防止用户被反复异地登录;expiredUrl是会话过期后的跳转地址。如果是纯 API 项目,直接改成SessionCreationPolicy.STATELESS,服务器完全不创建 Session,每次请求都走 Token 认证。
前后端分离项目里,登录接口往往需要自己写 Controller 来返回 JSON,而不是让框架跳转页面。这种场景要显式暴露AuthenticationManager:
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }然后登录接口手动触发认证:
@RestController public class LoginController { private final AuthenticationManager authenticationManager; public LoginController(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } @PostMapping("/api/login") public ResponseEntity<Map<String, Object>> login(@RequestBody LoginRequest request) { UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(request.username(), request.password()); Authentication authentication = authenticationManager.authenticate(token); SecurityContextHolder.getContext().setAuthentication(authentication); return ResponseEntity.ok(Map.of("code", 0, "message", "登录成功")); } }登录成功后,SecurityContextHolder里的认证信息默认会同步到 Session。如果配合 JWT,则在登录成功后生成 Token 返回给前端;如果使用 Session,那么后续请求浏览器会自动携带 Cookie,安全上下文也会从 Session 中恢复。
3.4 CSRF、CORS 与异常响应
CSRF 防护在新版本里默认是开启的。服务端渲染的表单应用,这个必须保留,因为攻击者可以通过构造恶意页面诱导已登录用户提交表单,CSRF Token 能有效阻止这种伪造请求。但纯 API 项目如果使用无状态 Token,且 Token 放在请求头而非 Cookie 中,攻击者没有途径让浏览器自动携带这个 Token,CSRF 风险就很低,习惯上会显式关闭:
.csrf(csrf -> csrf.disable())这里要特别注意:如果前端把 JWT 存在 Cookie 里,关闭 CSRF 就是给自己挖坑。因为浏览器会自动携带 Cookie,攻击者完全可以诱导用户发请求。这种情况建议保留 CSRF,或者至少把 Cookie 的SameSite设置成Lax,两者结合更稳妥。
CORS 配置在新版本里推荐通过CorsConfigurationSource统一管理,而不是在每个接口上写@CrossOrigin:
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("https://app.example.com")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }然后在HttpSecurity上把它挂进去:
.cors(cors -> cors.configurationSource(corsConfigurationSource()))另外,前后端分离场景下,未登录和没权限的响应要变成统一的 JSON 结构,不能让它默认重定向到登录页。一种简洁的方式是自定义AuthenticationEntryPoint和AccessDeniedHandler:
.exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("{\"code\":403,\"message\":\"抱歉,你没有权限访问该资源\"}"); }) )手写 JSON 字符串只适合演示,生产环境建议用ObjectMapper统一输出,保证和项目里的错误码体系一致。
4. 常见问题排查实录
4.1 编译通不过的三类症状
升级到 Spring Security 6 后,第一波问题几乎全都集中在编译阶段。
第一种:antMatchers()方法找不到。这是最典型的,antMatchers在 5.8 标记废弃,6.0 正式移除。替换方案是把antMatchers("/xxx").permitAll()改成requestMatchers("/xxx").permitAll()。规则本身不需要改,只是方法名变了。
第二种:authorizeRequests()方法消失。对应改成authorizeHttpRequests()。这不仅仅是改名,新方法内部使用了新的AuthorizationManager体系,对每个请求有独立的授权管理器,排查授权问题时能拿到更具体的拒绝原因。
第三种:配置类继承WebSecurityConfigurerAdapter直接报错。这个类已经被彻底移除,正确的做法是把配置类里的重写方法拆开,分别变成SecurityFilterChainBean、UserDetailsServiceBean、PasswordEncoderBean。看起来改动大,实际上逻辑完全平移,并不复杂。
4.2 运行期权限错乱与登录失效
编译通过之后,运行期的问题往往比编译期更难查。我整理了六个高频场景。
场景一:自定义loginPage("/login")后,访问/login直接 404。原因是框架不再生成默认登录页,这个路径必须由业务代码提供。不管是@Controller返回视图,还是@RestController返回 JSON,必须存在这个路由。
场景二:登录接口一直报 401。优先检查requestMatchers("/api/login").permitAll()是否真的匹配到了路径。比如接口路径是/api/v1/login,而你放行的是/api/login,那自然会被拦截。还有一种是请求方法不匹配,/api/login的 POST 被放行了,但实际框架里登录处理过滤器走的是表单登录的 POST 分支,处理逻辑串了线。
场景三:多条过滤链时,所有接口都 401。这个基本都是@Order顺序问题,参考前文的多链配置,把无状态的/api/**链放到前面。
场景四:登录成功后又立即跳回登录页,或者 JS 拿到 200 后下一个请求又 401。先检查手动登录时有没有把Authentication写入SecurityContextHolder,再检查 Session 是否丢失。服务端渲染项目里,如果配置了无状态策略STATELESS,Session 根本不会保存认证信息,表单登录的保持登录效果自然不存在。
场景五:POST 提交表单一直 403。在服务端渲染项目里,这个基本可以断定是 CSRF 校验失败。检查页面表单有没有通过th:action或类似方式带上_csrf参数。如果项目是前后端分离,且无状态 Token 放在请求头,那大概率是忘记把csrf(csrf -> csrf.disable())加上了。
场景六:自定义UserDetailsService后,登录依然走默认用户。这种情况要先确认 Bean 是否真的被 Spring 管理。如果配置类和方法都正确,但UserDetailsService接口存在多个实现,可以用@Primary标记其中一个,或者用@Qualifier在注入点明确指定。
排查这类问题时,我一般按“过滤链顺序 → URL 匹配 → 认证入口 → CSRF/Session”这个顺序看,比到处打断点快得多。
4.3 新老配置对照速查表
| 类别 | 5.x 老写法 | 6.x 新写法 |
|---|---|---|
| 核心配置 | 继承WebSecurityConfigurerAdapter | 注册SecurityFilterChainBean |
| URL 匹配 | antMatchers/mvcMatchers | requestMatchers |
| 授权声明 | authorizeRequests() | authorizeHttpRequests() |
| DSL 风格 | and()链式(5.2 后也支持 lambda) | 官方推荐 lambda DSL |
| 登录页 | 默认生成,可配置 | 自定义loginPage后需自行提供视图或接口 |
| 默认用户 | 可通过spring.security.user.*配置 | 自定义UserDetailsService后自动失效 |
| 方法安全 | @EnableGlobalMethodSecurity | @EnableMethodSecurity |
| JDK / 容器 | 早期 5.x 支持 JDK 8 | JDK 17+ / Servlet 6+ |
这张表可以在升级时当对照清单用。把老配置逐行翻译成新写法之后,剩下的工作重点就不是语法迁移,而是行为验证。
5. 我上手之后的几点体会
最后说几个实际操作中积累的小建议。
升级老项目时,先把安全模块里所有继承WebSecurityConfigurerAdapter的类列出来,逐条把configure(HttpSecurity)里写过的规则翻译成 lambda 风格的SecurityFilterChain,翻译完再合并同类项。不要一边升级安全框架一边改业务逻辑,两者同时出问题时,症状会互相掩盖,排查难度成倍增长。
新版本 lambda 风格的配置对 IDE 很友好,方法签名和返回值都清晰。我建议不要直接复制网上的配置片段,而是逐个方法点开看注释,搞懂每个配置器干的是什么,这样遇到问题才不至于两眼一抹黑。比如http.formLogin()和直接写 Controller 登录接口是两条完全不同的路,前者适合传统项目,后者适合前后端分离,没有孰优孰劣,只有推不匹配。
安全配置改完,我习惯用五分钟跑一遍核心场景:未登录访问受保护资源、错误密码登录、正常用户登录、管理员访问受限接口、会话过期。这五个场景覆盖了 90% 的回归风险。别看流程短,很多升级后的问题都是这些基础场景里冒出来的。
还有一个必须养成的习惯:不要为了省事把规则写成requestMatchers("/**").permitAll()。任何无法明确归类到公开范围的路径,都应该默认走authenticated()。等业务方反馈“某个页面访问不了”,你再针对性地放行,比一开始放太宽然后在安全日志里追查谁的锅要省力得多。
安全配置没有一劳永逸的标准答案,每个项目的边界条件都不一样。拿到新项目先画清边界,定好认证方式,再动手写配置,基本就能避开大部分坑。