Spring Security 6.0重构实战:从WebSecurityConfigurerAdapter迁移

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 配置模式 :直接声明 SecurityFilterChainWebSecurityCustomizerAuthenticationManager 等 Bean。

1.2 新旧架构对比

维度 旧模式(Security 5.x) 新模式(Security 6.0)
配置方式 继承 WebSecurityConfigurerAdapter 声明 @Bean 方法返回 SecurityFilterChain
配置类数量 通常一个(上帝类) 多个职责分离的 @Configuration
扩展性 重写方法,调用 super() 组合多个 CustomizerSecurityFilterChain
测试支持 依赖 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);
    }
}

关键变化

  1. 不再继承任何类,纯 @Configuration + @Bean
  2. authorizeRequests()authorizeHttpRequests()(前者在 6.0 已移除)
  3. antMatchers()requestMatchers()(支持 Servlet 和 Reactive 统一语义)
  4. 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/**"
            );
    }
}

注意WebSecurityCustomizerSecurityFilterChain 是互补关系。被 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 重命名,而是从继承驱动组合驱动的设计哲学跃迁:

  • 核心迁移WebSecurityConfigurerAdapterSecurityFilterChain Bean
  • 认证迁移super.authenticationManagerBean() → 显式 ProviderManager Bean
  • 方法安全@EnableGlobalMethodSecurity@EnableMethodSecurity
  • 静态资源WebSecurity 重写 → WebSecurityCustomizer Bean
  • Lambda DSL :消除 .and(),配置更流畅、类型安全

建议 :在迁移过程中,建议分阶段进行:先完成核心 SecurityFilterChain 迁移,再逐步处理 OAuth2、方法级安全和测试代码。Spring Security 6.0 的组合模型虽然初始配置代码量略增,但长期可维护性和可测试性显著提升,是云原生和微服务架构下的更优选择。