Spring Security 6.0 重构实战:从 WebSecurityConfigurerAdapter 迁移
摘要 :Spring Security 6.0 彻底移除了
WebSecurityConfigurerAdapter,全面转向基于SecurityFilterChain的组件化配置模型。本文通过实际项目的重构案例,详解新配置模型的设计哲学、迁移步骤、OAuth2/OIDC 集成升级,以及常见陷阱与解决方案。关键词:Spring Security 6.0、WebSecurityConfigurerAdapter、SecurityFilterChain、迁移指南、OAuth2、方法级安全
一、Spring Security 6.0 架构变革概述
1.1 为什么移除 WebSecurityConfigurerAdapter
自 Spring Security 2.0 起,WebSecurityConfigurerAdapter 作为配置安全的中心类,通过继承并重写 configure(HttpSecurity) 方法定义安全规则。然而,这种继承模型存在深层问题:
- 紧耦合:配置逻辑集中在单一类,违背单一职责原则
- 不易组合 :多模块项目中,多个
WebSecurityConfigurerAdapter的优先级和顺序难以控制 - 测试困难:需要实例化完整的安全上下文,单元测试成本高
- 与 Spring Boot 自动配置冲突:继承方式容易覆盖或破坏自动配置逻辑
Spring Security 5.7 开始弃用,6.0 正式移除 。替代方案是 纯 Bean 配置模式 :直接声明 SecurityFilterChain、WebSecurityCustomizer、AuthenticationManager 等 Bean。
1.2 新旧架构对比
| 维度 | 旧模式(Security 5.x) | 新模式(Security 6.0) |
|---|---|---|
| 配置方式 | 继承 WebSecurityConfigurerAdapter |
声明 @Bean 方法返回 SecurityFilterChain |
| 配置类数量 | 通常一个(上帝类) | 多个职责分离的 @Configuration 类 |
| 扩展性 | 重写方法,调用 super() |
组合多个 Customizer 和 SecurityFilterChain |
| 测试支持 | 依赖 SecurityMockMvcConfigurers |
直接注入 SecurityFilterChain 进行断言 |
| Lambda DSL | 部分支持 | 全面支持,配置更流畅 |
二、核心配置迁移:从继承到 Bean 声明
2.1 旧代码(Security 5.x)
java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.and()
.logout()
.logoutSuccessUrl("/login?logout");
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("{noop}admin123").roles("ADMIN")
.and()
.withUser("user").password("{noop}user123").roles("USER");
}
}
2.2 新代码(Security 6.0)
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* 定义安全过滤器链(核心替代方案)
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Security 6.0 推荐:使用 Lambda DSL 进行链式配置
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login", "/error").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").hasAnyRole("ADMIN", "USER")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
)
.sessionManagement(session -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
);
return http.build();
}
/**
* 内存用户存储(仅用于演示/测试)
*/
@Bean
public UserDetailsService userDetailsService() {
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$slYQmyNdGzTn7ZLBXBChFOC9f6kFjAqPhccnP6DxlWXx2lPk1C3G6")
.roles("ADMIN")
.build();
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$slYQmyNdGzTn7ZLBXBChFOC9f6kFjAqPhccnP6DxlWXx2lPk1C3G6")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
}
关键变化:
- 不再继承任何类,纯
@Configuration+@BeanauthorizeRequests()→authorizeHttpRequests()(前者在 6.0 已移除)antMatchers()→requestMatchers()(支持 Servlet 和 Reactive 统一语义)and()不再需要,Lambda DSL 直接链式调用
三、认证管理器迁移:AuthenticationManager 显式 Bean 化
3.1 旧模式:通过父类方法获取
java
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean(); // 容易踩坑,可能返回 null
}
3.2 新模式:显式构建
java
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class AuthenticationConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
// 可以添加多个 Provider(如 LDAP、SAML、OAuth2)
return new ProviderManager(provider);
}
}
3.3 在 SecurityFilterChain 中引用
java
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AuthenticationManager authenticationManager) throws Exception {
http
.authenticationManager(authenticationManager)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new CustomAuthFilter(authenticationManager),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
四、方法级安全升级:@EnableMethodSecurity
4.1 旧注解废弃
Spring Security 6.0 中,@EnableGlobalMethodSecurity 已被废弃,替换为 @EnableMethodSecurity:
| 旧注解 | 新注解 | 说明 |
|---|---|---|
@EnableGlobalMethodSecurity(prePostEnabled = true) |
@EnableMethodSecurity |
默认启用 prePostEnabled |
@EnableGlobalMethodSecurity(securedEnabled = true) |
@EnableMethodSecurity(securedEnabled = true) |
参数名保持一致 |
@EnableGlobalMethodSecurity(jsr250Enabled = true) |
@EnableMethodSecurity(jsr250Enabled = true) |
JSR-250 注解支持 |
4.2 实战配置
java
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity(
prePostEnabled = true, // 启用 @PreAuthorize / @PostAuthorize
securedEnabled = true, // 启用 @Secured
jsr250Enabled = true // 启用 @RolesAllowed
)
public class MethodSecurityConfig {
}
java
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
public Order getOrderByUser(String userId, String orderId) {
// 只有 ADMIN 或订单所有者本人可以查看
return orderRepository.findById(orderId).orElseThrow();
}
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #orderId, 'ORDER', 'DELETE')")
public void deleteOrder(String orderId) {
// 基于自定义 PermissionEvaluator 的细粒度控制
orderRepository.deleteById(orderId);
}
@Secured("ROLE_ADMIN")
public void batchUpdateOrders(List<OrderUpdateRequest> requests) {
// 仅 ADMIN 可执行批量操作
}
}
4.3 自定义 PermissionEvaluator
java
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.io.Serializable;
@Component("permissionEvaluator")
public class DomainPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
if (targetDomainObject == null) return false;
String domainType = targetDomainObject.getClass().getSimpleName();
return checkPermission(authentication, domainType, permission.toString());
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
// 基于 ID 和类型的查询场景(如未加载实体时)
return checkPermission(authentication, targetType, permission.toString());
}
private boolean checkPermission(Authentication auth, String type, String perm) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(type + "_" + perm));
}
}
五、OAuth2 与 OIDC 配置迁移
5.1 旧配置(基于继承)
java
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.oauth2Login()
.loginPage("/login")
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
5.2 新配置(Bean 模式)
java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class OAuth2SecurityConfig {
@Bean
public SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/oauth2/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
)
.successHandler((request, response, authentication) -> {
// 自定义登录成功逻辑
response.sendRedirect("/dashboard");
})
)
.logout(logout -> logout
.logoutSuccessHandler((request, response, authentication) -> {
// OAuth2 登出时同步注销 IdP
String idToken = ((OAuth2User) authentication.getPrincipal())
.getAttribute("id_token");
response.sendRedirect("/logout/idp?token=" + idToken);
})
);
return http.build();
}
@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> customOAuth2UserService() {
return userRequest -> {
// 自定义用户信息加载逻辑
// 例如:将 OAuth2 用户信息同步到本地数据库
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
userRequest.getAdditionalParameters(),
"sub"
);
};
}
}
5.3 资源服务器(JWT)配置
java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // 仅对 /api/** 路径生效
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("https://auth.example.com/.well-known/jwks.json").build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("sub");
return converter;
}
}
六、WebSecurity 与静态资源分离配置
6.1 WebSecurityCustomizer Bean
Security 6.0 中,如果需要配置 WebSecurity(如忽略静态资源路径),不再重写 WebSecurityConfigurerAdapter.configure(WebSecurity),而是声明 WebSecurityCustomizer Bean:
java
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
@Configuration
public class WebSecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(
"/favicon.ico",
"/static/**",
"/webjars/**",
"/swagger-ui/**",
"/v3/api-docs/**"
);
}
}
注意 :
WebSecurityCustomizer与SecurityFilterChain是互补关系。被ignoring()的路径会完全绕过 Spring Security 过滤器链,不会经过任何认证/授权逻辑。对于需要匿名访问但需记录日志的路径,仍应使用permitAll()。
七、测试迁移:SecurityMockMvcConfigurers 到 SecurityMockMvcRequestPostProcessors
7.1 旧测试代码
java
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
@ExtendWith(SpringExtension.class)
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/orders"))
.andExpect(status().isOk());
}
}
7.2 新测试代码(Security 6.0 兼容)
java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class) // 显式导入安全配置
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/orders"))
.andExpect(status().isOk());
}
@Test
void anonymousUserIsRedirectedToLogin() throws Exception {
mockMvc.perform(get("/admin/orders"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("**/login"));
}
@Test
void csrfTokenRequiredForPost() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"item\": \"book\"}"))
.andExpect(status().isForbidden()); // 缺少 CSRF
mockMvc.perform(post("/api/orders")
.with(csrf()) // 显式附加 CSRF token
.contentType(MediaType.APPLICATION_JSON)
.content("{\"item\": \"book\"}"))
.andExpect(status().isOk());
}
@Test
void customUserDetails() throws Exception {
UserDetails customUser = User.builder()
.username("test")
.password("password")
.roles("USER")
.build();
mockMvc.perform(get("/api/orders")
.with(user(customUser))) // 使用自定义 UserDetails
.andExpect(status().isOk());
}
}
八、迁移检查清单与常见陷阱
8.1 迁移检查清单
markdown
- [ ] 移除所有 `extends WebSecurityConfigurerAdapter`
- [ ] 将 `configure(HttpSecurity)` 重写改为 `SecurityFilterChain` Bean
- [ ] 将 `configure(AuthenticationManagerBuilder)` 改为 `AuthenticationManager` Bean
- [ ] 将 `configure(WebSecurity)` 改为 `WebSecurityCustomizer` Bean
- [ ] 将 `authorizeRequests()` 替换为 `authorizeHttpRequests()`
- [ ] 将 `antMatchers()` 替换为 `requestMatchers()`
- [ ] 将 `@EnableGlobalMethodSecurity` 替换为 `@EnableMethodSecurity`
- [ ] 移除所有 `.and()` 调用,使用 Lambda DSL
- [ ] 检查自定义过滤器,确保构造函数接收 `AuthenticationManager`
- [ ] 验证测试类中 `@Import` 了正确的安全配置
8.2 常见陷阱与解决方案
| 陷阱 | 现象 | 解决方案 |
|---|---|---|
多个 SecurityFilterChain 冲突 |
某些 URL 未被预期规则拦截 | 使用 securityMatcher() 限定每个 FilterChain 的作用范围 |
AuthenticationManager 为 null |
自定义过滤器中认证失败 | 显式注入 AuthenticationManager Bean,而非从父类获取 |
@PreAuthorize 不生效 |
方法级安全注解被忽略 | 确认使用 @EnableMethodSecurity 而非已废弃的 @EnableGlobalMethodSecurity |
| 静态资源仍被拦截 | CSS/JS 404 或 403 | 使用 WebSecurityCustomizer 配置 web.ignoring(),而非 permitAll() |
| CORS 配置失效 | 跨域请求被阻止 | 在 SecurityFilterChain 中使用 .cors(withDefaults()) 显式启用 |
总结
Spring Security 6.0 的变革并非简单的 API 重命名,而是从继承驱动 到组合驱动的设计哲学跃迁:
- 核心迁移 :
WebSecurityConfigurerAdapter→SecurityFilterChainBean - 认证迁移 :
super.authenticationManagerBean()→ 显式ProviderManagerBean - 方法安全 :
@EnableGlobalMethodSecurity→@EnableMethodSecurity - 静态资源 :
WebSecurity重写 →WebSecurityCustomizerBean - Lambda DSL :消除
.and(),配置更流畅、类型安全
建议 :在迁移过程中,建议分阶段进行:先完成核心
SecurityFilterChain迁移,再逐步处理 OAuth2、方法级安全和测试代码。Spring Security 6.0 的组合模型虽然初始配置代码量略增,但长期可维护性和可测试性显著提升,是云原生和微服务架构下的更优选择。