Spring Security 不用再写配置类了:新版的 Lambda DSL 一行搞定 90% 场景

引言

上个月带新同事接手一个 Spring Boot 3.3 的项目,他打开 SecurityConfig 看了半天问我:"这个项目是不是没有配 Security?"我说配了,就在你眼前——十行 Lambda,六个场景全覆盖:放行 Swagger、JWT 无状态、接口按角色鉴权、关 CSRF、开跨域、异常处理。他更困惑了:"我学的是 extends WebSecurityConfigurerAdapter + @EnableWebSecurity,这个写法怎么一行像配置的都没有?"

这个困惑非常普遍。Spring Security 从 5.2 开始引入 Lambda DSL,5.7 把 WebSecurityConfigurerAdapter 标记废弃,6.x 彻底移除——网上大量教程还在教 5.x 之前的写法,新项目的官方写法早已换了一代。更让人头疼的是 7.x 又有新变化:方法级安全成为推荐、authorizeRequests 彻底移除、MockMvc 测试的写法也变了。

这篇文章把 6.x/7.x 的 Lambda DSL 一次讲清:

  • Lambda DSL 的设计逻辑:为什么"不再写配置类"反而是更清晰的设计
  • 六大常见场景的完整写法:表单登录 / JWT 无状态 / 方法级权限 / OAuth2 登录 / CSRF / 跨域,每个都是可直接抄的代码
  • 旧版 vs 新版配置对照表:antMatchersrequestMatchersand() 的消失、MvcMatcher 的合并,升级 6.x/7.x 时对着表改就行
  • 升级踩坑清单:改完编译不过、行为变化的坑一次列全

一、Lambda DSL 的设计逻辑:为什么"没有配置"才是配置

1.1 旧写法的三个痛点

先看 Spring Security 5.x 之前的典型配置:

// ❌ 5.x 之前的经典写法(已彻底移除)
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()                      // 返回 HttpSecurity 自身,链式调用
                .antMatchers("/public/**").permitAll()
                .antMatchers("/api/**").authenticated()
                .anyRequest().denyAll()
                .and()                                // ← and() 跳回上一层,手动维护层级
            .formLogin()
                .loginPage("/login")
                .and()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

三个痛点:

痛点表现根源
and() 满天飞写漏一个 and() 层级就错,IDE 提示混乱链式调用靠返回值切换层级,DSL 状态藏在返回类型里
Configurer 藏在深坑出问题要钻进 HttpSecurity 内部的十几个 Configurer 类配置状态分散在可变对象里,看代码看不出"当前配了什么"
异常处理反直觉两个 configure() 重载(HttpSecurity / WebSecurity)容易配错继承式 API 的先天缺陷

1.2 Lambda DSL:配置即代码,层级即作用域

新写法的核心变化:每个配置项变成一个 Lambda 方法,参数的作用域天然就是层级

// ✅ 6.x/7.x 官方写法:同样的配置,无 and()、无继承、无 throws
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth          // 作用域:授权规则
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().denyAll()
            )
            .formLogin(form -> form                      // 作用域:表单登录
                .loginPage("/login")
            )
            .csrf(AbstractHttpConfigurer::disable)       // 方法引用:关 CSRF
            .sessionManagement(session -> session        // 作用域:会话管理
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );
        return http.build();
    }
}

对比一下设计上的变化:

维度旧版(链式 + and())新版(Lambda DSL)
层级表达and() 手动跳转Lambda 参数作用域天然隔离
可读性读整条链才知道配了什么每个 Lambda 块自成一个语义单元
出错方式漏 and() 编译能过但层级错作用域错了直接编译错
继承必须 extends WebSecurityConfigurerAdapter普通 @Bean,纯组合
IDE 支持深层链式提示不全Lambda 参数类型即全部可选项

一句话理解:旧版是把配置"叠"在一条链上,新版是把配置"装"进一个个 Lambda 盒子里。盒子之间的边界就是代码块的花括号——层级不再靠 and() 维护,靠作用域天然保证。

1.3 FilterChain:理解新架构的第一概念

6.x 之后,SecurityFilterChain 是核心交付物——一个 @Bean 就是一条安全过滤链

HTTP 请求
  │
  ▼
FilterChainProxy(Spring Security 总入口)
  │  匹配 SecurityMatcher(可选)
  ▼
SecurityFilterChain(一个 @Bean = 一条链)
  │  按顺序执行 N 个 Filter:
  │  CsrfFilter → ... → UsernamePasswordAuthenticationFilter → ...
  ▼
authorizeHttpRequests 判定 → 到达业务 Controller

这个模型带来一个 6.x 前做不到的能力:多链共存。不同的 URL 前缀可以走完全不同的安全策略:

/**
 * 多条 FilterChain 共存:按 SecurityMatcher 分流
 * API 链走 JWT 无状态,页面链走 Session + 表单登录
 */
@Configuration
@EnableWebSecurity
public class MultiChainConfig {

    // 链1:/api/** 走 JWT 无状态(高优先级,order 靠前)
    @Bean
    @Order(1)
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")                      // 这条链只管 /api/**
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated())
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    // 链2:其余请求走 Session + 表单登录
    @Bean
    @Order(2)
    SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/error").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}

旧版要靠继承 + 两个 Configurer 硬凑的东西,现在是两个独立的 @Bean


二、六大场景速查:可直接抄的代码

2.1 场景一:表单登录(传统页面应用)

@Bean
SecurityFilterChain formLoginChain(HttpSecurity http,
                                   UserDetailsService uds) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/css/**", "/js/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            .loginPage("/login")                      // 自定义登录页
            .loginProcessingUrl("/doLogin")           // 登录提交地址
            .defaultSuccessUrl("/index")              // 登录成功跳转
            .failureHandler((req, resp, e) ->         // 失败处理(可记录日志/限流)
                resp.sendRedirect("/login?error"))
            .permitAll()                              // 登录相关页面全放行
        )
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessHandler((req, resp, auth) ->
                resp.setStatus(200))                  // 前后端不分离可换 redirect
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        )
        .userDetailsService(uds);
    return http.build();
}

/**
 * 内存用户(演示用):生产环境对接 DB/LDAP
 * 7.x 提示:InMemoryUserDetailsManager 仍是官方支持的调试手段
 */
@Bean
UserDetailsService users() {
    UserDetails user = User.builder()
            .username("zhangsan")
            .password("{bcrypt}$2a$10$...")           // 必须带加密前缀
            .roles("USER")
            .build();
    UserDetails admin = User.builder()
            .username("admin")
            .password("{bcrypt}$2a$10$...")
            .roles("ADMIN")
            .build();
    return new InMemoryUserDetailsManager(user, admin);
}

2.2 场景二:JWT 无状态(前后端分离最常用)

/**
 * JWT 无状态:两步——关 Session + 挂 JWT 校验
 * 6.x 后官方推荐用 oauth2ResourceServer 的 jwt 模式校验,
 * 不再需要手写 OncePerRequestFilter 解析 token
 */
@Bean
SecurityFilterChain jwtChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/auth/**").permitAll()   // 登录/注册放行
            .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
            .anyRequest().authenticated()
        )
        .csrf(AbstractHttpConfigurer::disable)             // 无状态无 cookie,CSRF 无意义
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        // JWT 校验:两种方式按需二选一
        .oauth2ResourceServer(oauth2 -> oauth2
            // 方式A:远程 JWKS(token 由独立认证服务签发)
            .jwt(jwt -> jwt
                .jwkSetUri("https://auth.example.com/oauth2/jwks")
            )
            // 方式B:本地对称密钥验签(单体应用)
            // .decoder(jwtDecoder())
        );
    return http.build();
}

/** 本地验签方式B:HMAC 密钥从配置中心/Vault 读取,绝不硬编码 */
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {
    SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    return NimbusJwtDecoder.withSecretKey(key).build();
}

签发侧(登录接口手动发 token):

@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;

    @PostMapping("/api/auth/login")
    public Map<String, String> login(@RequestBody LoginRequest body) {
        // 1. 认证(失败抛 BadCredentialsException → 全局异常处理返回 401)
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        body.getUsername(), body.getPassword()));

        // 2. 签发 JWT
        String token = Jwts.builder()
                .subject(auth.getName())
                .claim("roles", auth.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .issuedAt(new Date())
                .expiration(Date.from(Instant.now().plusSeconds(1800)))
                .signWith(signingKey)
                .compact();

        return Map.of("token", token);
    }
}

2.3 场景三:方法级权限(7.x 推荐的主力写法)

6.x 的授权分两层:FilterChain 层管"URL 能不能进",方法层管"方法能不能调"。7.x 之后方法级安全被推到更重要的位置(Controller 内部路由、RPC 内部调用没有 URL 概念,URL 级管不住):

/**
 * 开启方法级安全:一个注解搞定
 * prePostEnabled 默认已开启,6.x 后可省略参数
 */
@Configuration
@EnableMethodSecurity        // 替代旧版 @EnableGlobalMethodSecurity
public class MethodSecurityConfig {
    // 6.0 后默认开启 prePostEnabled=true,不用再写 (prePostEnabled = true)
}

// 使用:注解直接标在方法/类上
@Service
public class OrderService {

    /** SpEL 表达式:按权限/角色/参数任意组合 */
    @PreAuthorize("hasAuthority('SCOPE_order:read')")
    public OrderVO getOrder(String orderId) { ... }

    /** 校验"当前用户是不是订单主人":引用方法参数 */
    @PreAuthorize("#orderId == authentication.name + '_OWNED' or hasRole('ADMIN')")
    public OrderVO getMyOrder(String orderId) { ... }

    /** 返回值校验:@PostAuthorize 过滤返回数据 */
    @PostAuthorize("returnObject.userId == authentication.name")
    public OrderVO getOrderDetail(String orderId) { ... }

    /** 集合过滤:只返回当前用户自己的订单(@filterTarget 指定入参集合) */
    @PreFilter("filterObject.userId == authentication.name")
    public List<OrderVO> batchProcess(List<OrderVO> orders) { ... }
}

6.x 还引入了元注解能力——把常用表达式封装成自定义注解:

/**
 * 元注解:定义一次,到处使用,避免 SpEL 字符串散落各处
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface AdminOnly {
}
// 使用时就是普通注解,可读性大幅提升
@Service
public class AdminService {
    @AdminOnly
    public void deleteUser(String userId) { ... }
}

2.4 场景四:OAuth2 登录(接入第三方登录)

/**
 * OAuth2 登录(授权码模式):接入微信/GitHub 等
 * 配置分两部分:FilterChain + application.yml 的客户端注册信息
 */
@Bean
SecurityFilterChain oauth2Chain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated())
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")                          // 自定义登录页(含第三方按钮)
            .successHandler((req, resp, auth) -> {
                // 登录成功:签发自己的 JWT 给前端(前后端分离场景)
                String jwt = issueJwt(auth);
                resp.sendRedirect("/index?token=" + jwt);
            })
            .userInfoEndpoint(userInfo -> userInfo
                // 自定义用户信息映射:把第三方用户转成本系统用户
                .userService(customOAuth2UserService()))
        );
    return http.build();
}
# application.yml:客户端注册(对应我们 OAuth2 文章里的授权码模式)
spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user
          wechat:
            client-id: ${WX_APP_ID}
            client-secret: ${WX_SECRET}
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          wechat:
            authorization-uri: https://open.weixin.qq.com/connect/qrconnect
            token-uri: https://api.weixin.qq.com/sns/oauth2/access_token
            user-info-uri: https://api.weixin.qq.com/sns/userinfo

2.5 场景五:CSRF 配置(什么时候关、怎么开)

CSRF 防护的前提是 Cookie Session——无状态的 JWT(Header 传 token)没有 CSRF 风险,关掉是正确操作而不是偷懒

@Bean
SecurityFilterChain csrfChain(HttpSecurity http) throws Exception {
    http
        // 情况A:纯 API 无状态 → 直接关
        .csrf(AbstractHttpConfigurer::disable)
        ...
}

但只要还在用 Cookie Session,就必须开着。常见误区是"开着 CSRF 前端 AJAX 全 403"——其实是没把 token 传给前端:

// ✅ 保留 CSRF 的正确姿势:token 写入 Cookie,前端 JS 读取后放请求头
@Bean
SecurityFilterChain csrfKeepChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf
            // CookieCsrfTokenRepository:token 存 cookie,前端可读
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            // SPA 单页应用推荐:配合前端拦截器每次请求带 X-XSRF-TOKEN
            .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
        );
    return http.build();
}

// 前端伪代码(SPA 通用):
// const token = readCookie('XSRF-TOKEN');
// fetch('/api/xxx', { headers: { 'X-XSRF-TOKEN': token } });

决策规则

前端形态认证方式CSRF 开关
前后端分离JWT(Header 携带)关(无 Cookie 自动携带场景)
传统页面 / 服务端渲染Session + Cookie必须开 + 前端带 token
SPA + SessionSession + Cookie开 + CookieCsrfTokenRepository

2.6 场景六:跨域(CORS)——别再让 CORS 和 Security 打架

一个高频踩坑:Controller 层配了 @CrossOrigin,但带凭证的预检请求(OPTIONS)被 Security 拦截返回 401。CORS 配置必须进 FilterChain,让 Spring Security 的 CorsFilter 在认证之前处理预检

@Bean
SecurityFilterChain corsChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors
            .configurationSource(corsConfigurationSource()))
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    // 生产环境明确列出来源,禁止 *(尤其是带凭证的请求,* 直接无效)
    config.setAllowedOrigins(List.of("https://app.example.com", "http://localhost:5173"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
    config.setAllowCredentials(true);          // 允许携带凭证(cookie/token)
    config.setMaxAge(3600L);                   // 预检结果缓存 1 小时,减少 OPTIONS

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

原理一句话:Spring Security 的 CorsFilter 排在认证 Filter 之前,预检 OPTIONS 请求不带 token 也能被正确放行——这就是"CORS 要配在 Security 里"的原因。


三、旧版 vs 新版配置对照表:升级 6.x/7.x 必看

3.1 API 层面的核心变更

旧版写法(5.x 及之前)新版写法(6.x/7.x)说明
extends WebSecurityConfigurerAdapter删除继承@Bean SecurityFilterChain5.7 废弃,6.x 移除
@Override configure(HttpSecurity http)@Bean 方法里直接配不再是"重写",是"组装"
@Override configure(WebSecurity web)@Bean WebSecurityCustomizer静态资源放行的新写法
authorizeRequests()authorizeHttpRequests()6.0 改名,规则匹配模型升级
antMatchers("/api/**")requestMatchers("/api/**")5.8 弃用,6.x 移除
mvcMatchers("/api/**")requestMatchers("/api/**")与 antMatchers 合并成 requestMatchers
regexMatchers()requestMatchers(PathPatternRequestMatcher)同上合并
.and()删除,用 Lambda 作用域替代整个 6.x 无 and()
@EnableGlobalMethodSecurity(prePostEnabled=true)@EnableMethodSecurityprePost 默认开启
@SendToUser 相关 websocket 安全配置authorizeHttpRequests + SecurityContext细节变化,查官方迁移指南
acs.expressionHandler(...)@Bean AuthorizationManager / MethodSecurityExpressionHandlerSpEL 判定器升级为 AuthorizationManager
http.addFilterBefore(...) 位置常量SecurityContextPersistenceFilterSecurityContextHolderFilter6.0 过滤器链内部重构

3.2 新旧完整对照示例

// ─────────── 旧版(5.x 之前,已不可用) ───────────
@Configuration
@EnableWebSecurity
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/static/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/api/auth/**").permitAll()
                .antMatchers("/api/**").hasRole("USER")
                .anyRequest().authenticated()
                .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

// ─────────── 新版(6.x / 7.x) ───────────
@Configuration
@EnableWebSecurity
public class NewSecurityConfig {

    // configure(WebSecurity) → WebSecurityCustomizer
    // ⚠️ 7.x 提示:静态资源更推荐在 authorizeHttpRequests 里 permitAll
    @Bean
    WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers("/static/**");
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/**").hasRole("USER")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 自定义 JWT 过滤器:显式指定插入位置
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

3.3 方法级安全对照

旧版新版变化
@EnableGlobalMethodSecurity(prePostEnabled = true)@EnableMethodSecurity6.0 改名 + 默认开启
@PreAuthorize("hasRole('X')")同名不变,但支持元注解封装自定义组合注解
securedEnabled = true + @Secured仍支持,不推荐统一走 @PreAuthorize
jsr250Enabled = true + @RolesAllowed仍支持(JSR-250 兼容)跨框架代码可选
SpEL 直接写在注解里推荐抽成 Bean 引用:@PreAuthorize("@authz.canRead(#id)")表达式可测试、可复用

表达式抽 Bean 是团队协作的大杀器——把权限逻辑收敛到一个类里,可单测:

/**
 * 权限判定逻辑集中管理:SpEL 里用 @authz.xxx 引用
 * 注解里不再写复杂表达式,逻辑可单测、可复用
 */
@Component("authz")
public class AuthorizationLogic {

    public boolean canReadOrder(Authentication auth, String orderId) {
        return auth.getAuthorities().contains(new SimpleGrantedAuthority("SCOPE_order:read"))
                || orderOwnershipService.isOwner(auth.getName(), orderId);
    }

    public boolean isAdministrator(Authentication auth) {
        return auth.getAuthorities().stream()
                .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    }
}

// 使用
@PreAuthorize("@authz.canReadOrder(authentication, #orderId)")
public OrderVO getOrder(String orderId) { ... }

四、升级踩坑清单

4.1 编译不过类

现象原因解法
cannot find symbol: antMatchers6.x 移除全部换 requestMatchers
WebSecurityConfigurerAdapter 红线6.x 移除重构为 SecurityFilterChain @Bean
and() 找不到Lambda DSL 无 and拆成并列的 Lambda 块
@EnableGlobalMethodSecurity 红线6.x 移除@EnableMethodSecurity
requiresChannel().antMatchers 编译错同 antMatchersrequestMatchers + HTTPS 配置改写

4.2 行为变化类(编译能过但行为变了)

坑一:requestMatchers 的匹配语义变了

5.x 的 antMatchers 走 AntPathMatcher,6.x 默认走 PathPatternParser(更快,但语法有差异):

// ⚠️ PathPatternParser 不支持的写法(5.x 能跑,6.x 报错或行为不同):
// ① 路径中间的 ** 不允许(AntPathMatcher 支持 /a/**/b)
//    PathPatternParser 只允许 ** 在末尾
.requestMatchers("/api/*/detail").authenticated()   // ✅ 单层 *
.requestMatchers("/api/**/detail").authenticated()  // ❌ 6.x 启动报错

// ② 尾部斜杠匹配变化:6.x 中 /api/user 不再匹配 /api/user/
//    5.x 默认会忽略尾部斜杠,6.x 严格匹配
//    官方建议:规范化前端请求路径,或显式配两个 matcher

坑二:@EnableMethodSecurity 默认值变化导致权限"消失"

@EnableGlobalMethodSecurity 迁移时,如果原来没开 prePostEnabled,改新注解后 @PreAuthorize 突然生效——原来裸奔的接口现在 403 了。这是安全收紧,不是 bug,但升级前要全局搜索 @PreAuthorize 的存量使用。

坑三:JWT 验签的 decoder 默认时间戳校验收紧

6.x 的 NimbusJwtDecoder 默认校验 nbf/exp,本地时钟漂移大时会出现"偶发 401"。配合时钟同步服务,或构造 decoder 时显式设置时钟偏移容忍。

坑四:Session 固定保护与多链冲突

多 FilterChain 场景下,两条链如果都配 sessionManagement,注意 SessionFixation 策略只在生效的那条链上起作用——排查"登录后 session 没换"的问题时先确认命中的是哪条链。

4.3 测试写法变化

// 6.x 推荐的 MockMvc 安全测试
@SpringBootTest
@AutoConfigureMockMvc
class OrderApiTest {

    @Autowired MockMvc mockMvc;

    @Test
    void anonymousCannotRead() throws Exception {
        mockMvc.perform(get("/api/orders/1"))
               .andExpect(status().isUnauthorized());   // 无 token → 401
    }

    @Test
    @WithMockUser(roles = "USER")                        // 模拟登录用户
    void userCanRead() throws Exception {
        mockMvc.perform(get("/api/orders/1"))
               .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "GUEST")
    void guestCannotAccessAdmin() throws Exception {
        mockMvc.perform(get("/api/admin/stats"))
               .andExpect(status().isForbidden());       // 角色不够 → 403
    }
}

五、常见问题

5.1 Lambda DSL 还能用 and() 吗?

不能。6.x 的所有配置器都返回 HttpSecurity 自身以支持链式调用入口,但每个 Lambda 块内部是独立作用域,没有"跳回上一层"的语义。官方迁移指南明确:把 and() 链拆成并列的 Lambda 块即可,语义完全等价。

5.2 Customizer.withDefaults() 是什么?

"启用默认配置"的快捷方式。http.formLogin(Customizer.withDefaults()) 等价于 http.formLogin(form -> {})——启用该组件但不用自定义参数。适合:默认行为够用的场景(如 oauth2ResourceServer 的 jwt 校验)。可见性更好:读代码一眼看出"这个组件开了但没定制"。

5.3 多条 FilterChain 的顺序怎么保证?

@Order 注解(或 SecurityFilterChain bean 名排序)。匹配是先到先得:请求按 order 依次尝试各链的 securityMatcher,命中即走这条链,不再往下匹配。所以窄匹配的链(/api/)必须放在宽匹配(/)之前,否则永远轮不到窄链。

5.4 Spring Security 7 主要变了什么?

7.x(随 Spring Boot 4)的核心方向:① authorizeHttpRequests 规则 DSL 进一步简化anyRequest().denyAll() 成为推荐默认;② 方法级安全全面推荐——URL 级授权对"非入口调用"无能为力,官方引导双层并用;③ 部分过时 API(WebSecurityCustomizer.ignoring() 的滥用)被进一步收口,静态资源建议直接 permitAll 进授权链(需要 CSRF/安全头的资源不能 ignoring);④ Matcher 底层统一到 PathPatternRequestMatcher代码迁移成本不高,心智模型(FilterChain + Lambda DSL)不变

5.5 静态资源该 ignoring 还是 permitAll?

7.x 的官方立场:优先 permitAllignoring() 会跳过整条安全链(含安全响应头、CSRF),历史上多起安全问题源于此;permitAll() 仍走链,安全头照常输出。只有纯粹的无状态静态文件(如 /favicon.ico)且确认不需要安全头时才用 ignoring。5.x 时代 configure(WebSecurity) 的习惯要改过来。

5.6 自定义 JWT 过滤器还需要吗?

大多数场景不需要了。6.x 的 oauth2ResourceServer().jwt() 已覆盖:验签、过期校验、scope/role 映射、异常处理。手写 OncePerRequestFilter 解析 token 的教程大量存在于旧文,它们的适用前提(6.x 之前没有内建 JWT 支持)已消失。仍需自定义过滤器的场景:token 不符合 JWT 规范(自定义加密体)、需要从非标准位置取 token、双 token 无感刷新的自定义逻辑——这时用 addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) 显式插链。


六、总结

场景速查卡

┌──────────────────┬──────────────────────────────────────────────────┐
│ 场景              │ 核心写法                                          │
├──────────────────┼──────────────────────────────────────────────────┤
│ URL 授权          │ authorizeHttpRequests(auth -> auth.requestMatchers  │
│                  │   (...).permitAll()/authenticated()/hasRole(...))  │
│ 表单登录          │ formLogin(form -> form.loginPage(...))             │
│ JWT 无状态        │ csrf(disable) + sessionManagement(STATELESS)       │
│                  │ + oauth2ResourceServer(jwt)                        │
│ 方法级权限        │ @EnableMethodSecurity + @PreAuthorize              │
│                  │ (复杂表达式抽 @authz Bean / 元注解)               │
│ OAuth2 登录       │ oauth2Login(oauth2 -> ...) + yml 客户端注册        │
│ CSRF             │ 有 Session 必开(CookieCsrfTokenRepository)        │
│                  │ 纯 JWT Header 可关                                 │
│ 跨域             │ http.cors(...) + CorsConfigurationSource @Bean     │
│ 多链共存          │ securityMatcher + @Order,多个 SecurityFilterChain │
└──────────────────┴──────────────────────────────────────────────────┘

升级三步法

① 编译层:WebSecurityConfigurerAdapter→SecurityFilterChain Bean、
   antMatchers→requestMatchers、@EnableGlobalMethodSecurity→@EnableMethodSecurity
② 行为层:PathPatternParser 语法差异、CSRF 默认值、@PreAuthorize 存量生效检查
③ 收敛层:复杂 SpEL 抽 @authz Bean、静态资源 ignoring→permitAll、
   多链用 securityMatcher 显式划分

一句话

Lambda DSL 的价值不是"少打几个字",而是把安全配置从"继承+链式跳转"的隐式模型,变成"组合+作用域"的显式模型——每个 Lambda 块是一个语义单元,层级由花括号保证,多链由 Bean 组合表达。读懂这个设计,6.x/7.x 的所有 API 变化都不再是死记硬背,而是同一思想的自然延伸。

给团队的建议

阶段建议
新项目直接 6.x/7.x 官方写法,禁止抄 5.x 老教程
存量升级按第四部分三步法走,重点回归测试 PathPattern 差异
团队规范复杂权限表达式一律抽 @authz Bean,注解里不写长 SpEL
JWT 项目优先 oauth2ResourceServer,手写过滤器仅限特殊场景
安全基线静态资源 permitAll 替代 ignoring;多链 securityMatcher 显式化

互动话题:你们的 Security 配置升级到 6.x/7.x 了吗?antMatchersrequestMatchers 时踩过 PathPatternParser 的坑吗?复杂权限表达式你们是抽 Bean 还是写在注解里?评论区聊聊。


参考资料


标题:Spring Security 不用再写配置类了:新版的 Lambda DSL 一行搞定 90% 场景
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/04/1788098979418.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消