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 新版配置对照表:
antMatchers→requestMatchers、and()的消失、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 + Session | Session + 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 SecurityFilterChain | 5.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) | @EnableMethodSecurity | prePost 默认开启 |
@SendToUser 相关 websocket 安全配置 | authorizeHttpRequests + SecurityContext | 细节变化,查官方迁移指南 |
acs.expressionHandler(...) | @Bean AuthorizationManager / MethodSecurityExpressionHandler | SpEL 判定器升级为 AuthorizationManager |
http.addFilterBefore(...) 位置常量 | SecurityContextPersistenceFilter → SecurityContextHolderFilter | 6.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) | @EnableMethodSecurity | 6.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: antMatchers | 6.x 移除 | 全部换 requestMatchers |
WebSecurityConfigurerAdapter 红线 | 6.x 移除 | 重构为 SecurityFilterChain @Bean |
and() 找不到 | Lambda DSL 无 and | 拆成并列的 Lambda 块 |
@EnableGlobalMethodSecurity 红线 | 6.x 移除 | 换 @EnableMethodSecurity |
requiresChannel().antMatchers 编译错 | 同 antMatchers | requestMatchers + 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 的官方立场:优先 permitAll。ignoring() 会跳过整条安全链(含安全响应头、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 了吗?
antMatchers改requestMatchers时踩过 PathPatternParser 的坑吗?复杂权限表达式你们是抽 Bean 还是写在注解里?评论区聊聊。
参考资料
- Spring Security 6.x 官方文档:Lambda DSL
- 官方迁移指南(5.x → 6.x)
- Spring Security 7 迁移指南
- OAuth2 Resource Server JWT 文档
- 方法级安全 @EnableMethodSecurity
- PathPatternParser 与 AntPathMatcher 差异
- CSRF CookieCsrfTokenRepository
标题:Spring Security 不用再写配置类了:新版的 Lambda DSL 一行搞定 90% 场景
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/04/1788098979418.html
公众号:服务端技术精选
- 引言
- 一、Lambda DSL 的设计逻辑:为什么"没有配置"才是配置
- 1.1 旧写法的三个痛点
- 1.2 Lambda DSL:配置即代码,层级即作用域
- 1.3 FilterChain:理解新架构的第一概念
- 二、六大场景速查:可直接抄的代码
- 2.1 场景一:表单登录(传统页面应用)
- 2.2 场景二:JWT 无状态(前后端分离最常用)
- 2.3 场景三:方法级权限(7.x 推荐的主力写法)
- 2.4 场景四:OAuth2 登录(接入第三方登录)
- 2.5 场景五:CSRF 配置(什么时候关、怎么开)
- 2.6 场景六:跨域(CORS)——别再让 CORS 和 Security 打架
- 三、旧版 vs 新版配置对照表:升级 6.x/7.x 必看
- 3.1 API 层面的核心变更
- 3.2 新旧完整对照示例
- 3.3 方法级安全对照
- 四、升级踩坑清单
- 4.1 编译不过类
- 4.2 行为变化类(编译能过但行为变了)
- 4.3 测试写法变化
- 五、常见问题
- 5.1 Lambda DSL 还能用 and() 吗?
- 5.2 Customizer.withDefaults() 是什么?
- 5.3 多条 FilterChain 的顺序怎么保证?
- 5.4 Spring Security 7 主要变了什么?
- 5.5 静态资源该 ignoring 还是 permitAll?
- 5.6 自定义 JWT 过滤器还需要吗?
- 六、总结
- 场景速查卡
- 升级三步法
- 一句话
- 给团队的建议
- 参考资料
评论