本节目标:讲清
@EnableMethodSecurity背后其实是 AOP 织入、@PreAuthorize的 SpEL 求值上下文里有哪些可用变量、方法安全与@Transactional的拦截器谁先谁后,以及SecurityContext为什么在异步与虚拟线程里会丢、如何补回。
适用版本:Spring Boot 4.1.x(Java 21)
8.3 方法安全与上下文传播
前两节讲的是「请求进来时」的安全:过滤器链负责认证与 URL 级授权。但很多时候授权判断需要方法参数或返回值——「只能借自己的书」「只能看自己名下的借阅记录」,URL 级规则表达不了。方法安全(@PreAuthorize 等)解决的就是这类问题。
本节沿用图书借阅系统:LoanService.borrow(isbn, reader) 借书、LoanService.find(id) 查借阅记录。本节要回答三个问题:这些注解是怎么被「织」进方法调用的;表达式里的 authentication、returnObject、#isbn 是从哪来的;当借书动作被丢进 @Async 或虚拟线程后,为什么 SecurityContextHolder.getContext() 变成了匿名。
8.3.1 @EnableMethodSecurity 装配了什么
@EnableMethodSecurity(org.springframework.security.config.annotation.method.configuration,来自 spring-security-config)是一个注解,它的属性(核实自 javap)是:
| 属性 | 默认 | 作用 |
|---|---|---|
prePostEnabled() | true | 是否启用 @PreAuthorize / @PostAuthorize |
securedEnabled() | false | 是否启用 @Secured |
jsr250Enabled() | false | 是否启用 @RolesAllowed(JSR-250) |
proxyTargetClass() | false | 是否强制 CGLIB 代理 |
mode() | PROXY | 代理模式(AdviceMode) |
offset() | 0 | 拦截器顺序偏移 |
它通过 @Import 引入 MethodSecuritySelector,后者再引入 PrePostMethodSecurityConfiguration(两者均核实存在)。PrePostMethodSecurityConfiguration 注册了四个 MethodInterceptor(核实的静态方法名):
preFilterAuthorizationMethodInterceptor
preAuthorizeAuthorizationMethodInterceptor
postAuthorizeAuthorizationMethodInterceptor
postFilterAuthorizationMethodInterceptor
关键认知:@EnableMethodSecurity 本身不写任何认证逻辑,它只负责「注册一组通知(advice)」。真正的拦截发生在 AOP 代理层——这与 8.1 里 HttpSecurity 只负责「装配过滤器」是同一个设计思路:配置类只描述结构,运行逻辑交给被装配出来的组件。
8.3.2 背后还是 AOP:拦截器与顺序
MethodSecuritySelector 同时引入了一个 AutoProxyRegistrar(核实内部类 MethodSecuritySelector$AutoProxyRegistrarSelector),它注册 InfrastructureAdvisorAutoProxyCreator,让带安全注解的 bean 被自动代理。因此方法安全的效果只在 Spring 代理上生效——这正是第 3 章讲过的代理边界:同类内部 this.method() 自调用会绕过代理,注解失效。
四个拦截器分别由两个类实现,按 AuthorizationInterceptorsOrder 枚举排序(org.springframework.security.authorization.method,来自 spring-security-core,核实自 javap -constants):
| 枚举值 | 对应注解 | 拦截器类 |
|---|---|---|
PRE_FILTER | @PreFilter | AuthorizationManagerBeforeMethodInterceptor |
PRE_AUTHORIZE | @PreAuthorize | AuthorizationManagerBeforeMethodInterceptor |
SECURED | @Secured | AuthorizationManagerBeforeMethodInterceptor |
JSR250 | @RolesAllowed | AuthorizationManagerBeforeMethodInterceptor |
POST_AUTHORIZE | @PostAuthorize | AuthorizationManagerAfterMethodInterceptor |
POST_FILTER | @PostFilter | AuthorizationManagerAfterMethodInterceptor |
AuthorizationManagerBeforeMethodInterceptor 提供静态工厂 preAuthorize()、secured()、jsr250();AuthorizationManagerAfterMethodInterceptor 负责方法返回后再判定(核实均有)。注解本身的包路径要记准:@PreAuthorize / @PostAuthorize / @PreFilter / @PostFilter 都在 org.springframework.security.access.prepost(核实;不在 access.annotation,后者只放 @Secured)。
@Service
public class LoanService {
private final BookRepository books;
public LoanService(BookRepository books) {
this.books = books;
}
@Transactional
@PreAuthorize("hasRole('READER')")
public Loan borrow(String isbn, String reader) {
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new IllegalArgumentException("unknown isbn"));
book.setBorrower(reader);
return new Loan(isbn, reader);
}
@PostAuthorize("returnObject.reader == authentication.name")
public Loan find(Long id) {
return books.findLoan(id);
}
}
8.3.3 表达式求值上下文:MethodSecurityExpressionRoot
@PreAuthorize("...") 里的字符串是 SpEL。它的求值上下文由 DefaultMethodSecurityExpressionHandler.createEvaluationContext(Supplier<Authentication>, MethodInvocation) 构建(核实签名),而上下文里那个「根对象」是 MethodSecurityExpressionRoot——一个 package-private 类,签名是:
class MethodSecurityExpressionRoot
extends SecurityExpressionRoot<MethodInvocation>
implements MethodSecurityExpressionOperations {
public void setFilterObject(Object filterObject);
public Object getFilterObject();
public void setReturnObject(Object returnObject);
public Object getReturnObject();
void setThis(Object target);
public Object getThis();
}
它继承的 SecurityExpressionRoot 提供 hasRole、hasAuthority、hasAnyRole、permitAll、denyAll、isAuthenticated、authentication、principal 等基础方法。因此在方法安全的 SpEL 里,你能直接用的变量有:
| 表达式 | 来源 | 含义 |
|---|---|---|
authentication | SecurityExpressionRoot | 当前 Authentication |
principal | SecurityExpressionRoot | Authentication.getPrincipal() |
hasRole('READER') | SecurityExpressionRoot | 角色判定(自动加 ROLE_ 前缀) |
#isbn | SpEL 参数变量 | 方法参数(编译时 -parameters 或调试信息) |
#this | MethodSecurityExpressionRoot.getThis() | 目标对象本身 |
returnObject | setReturnObject(...) | 仅 @PostAuthorize 可用 |
filterObject | setFilterObject(...) | 仅 @PreFilter/@PostFilter 可用 |
@PreAuthorize 与 @PostAuthorize 的差别就在这里:前者在方法执行前求值,returnObject 还不存在;后者在方法执行后求值,returnObject 已被 setReturnObject(...) 塞进上下文。所以「只能看自己的记录」必须用 @PostAuthorize。判定逻辑由 PreAuthorizeAuthorizationManager / PostAuthorizeAuthorizationManager 承担(核实存在),最终仍落到上一节的 AuthorizationManager.authorize(...),返回 AuthorizationResult。
8.3.4 与 @Transactional 的代理顺序问题
borrow 同时标了 @Transactional 与 @PreAuthorize,两个通知会挂到同一个代理上。谁先执行,决定了两种完全不同的语义:
| 顺序 | 语义 | 后果 |
|---|---|---|
| 安全在前、事务在后 | 先判权限,通过才开事务 | 无权限的请求不会开启事务(推荐) |
| 事务在前、安全在后 | 先开事务,再判权限 | 无权限请求也会开/回滚事务,浪费连接 |
Spring Security 的四个拦截器通过 AuthorizationInterceptorsOrder 的 getOrder() 拿到固定序号,而 @EnableMethodSecurity(offset = ...) 提供的 offset() 属性正是用来整体平移这组序号,把它们插到你想要的相对位置。同理,事务通知的顺序由 @EnableTransactionManagement(order = ...) 控制。两者配合,就能保证「安全判定在事务开启之前」。
为什么要有 offset 而不是固定顺序? 因为框架无法预知你还有哪些通知(审计、缓存、重试)。给一个相对偏移量,比写死绝对顺序更能与第三方通知共存。排障时如果发现「无权限的请求也开了一次事务」,先看两个通知的 order 是否被别的配置打乱。
8.3.5 上下文的跨线程传播
回到 8.2 的结论:SecurityContextHolder 默认用 ThreadLocal。这意味着换一个线程,上下文就没了。三种典型场景:
| 场景 | 现象 | 根因 |
|---|---|---|
@Async 方法 | 方法内 getContext().getAuthentication() 是匿名 | 异步线程没继承 ThreadLocal |
| 线程池里跑任务 | 同上,且可能拿到别的请求的上下文 | 线程复用 + 未清理 |
| 消息消费线程 | 消费逻辑里无身份 | 上下文从未在该线程建立 |
注意第二种最危险:如果只是简单地切到 MODE_INHERITABLETHREADLOCAL,线程池里的线程会继承「创建它的那个线程」的上下文,而复用后不会自动更新——可能把一个请求的身份泄漏给另一个请求。InheritableThreadLocal 不适合线程池,只适合「一次性创建子线程」的场景。
正确解法是显式装饰执行器。Spring Security 在 org.springframework.security.concurrent 与 org.springframework.security.task 两个包里提供了一组委托类(构造器均已核实,都接受「被委托的执行器」并可选一个显式 SecurityContext):
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
AsyncTaskExecutor securityAwareExecutor(ThreadPoolTaskExecutor delegate) {
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
}
| 委托类 | 包装什么 | 包 |
|---|---|---|
DelegatingSecurityContextExecutor | Executor | concurrent |
DelegatingSecurityContextExecutorService | ExecutorService | concurrent |
DelegatingSecurityContextScheduledExecutorService | ScheduledExecutorService | concurrent |
DelegatingSecurityContextCallable / DelegatingSecurityContextRunnable | Callable / Runnable | concurrent |
DelegatingSecurityContextAsyncTaskExecutor | AsyncTaskExecutor | task |
DelegatingSecurityContextTaskExecutor | TaskExecutor | task |
它们的做法是「提交时捕获当前上下文,执行时设置、结束后清理」,因此在线程复用下也不会串味。
8.3.6 4.1 的新选项:propagate-context 与 TaskDecorator
Spring Framework 7 与 Spring Boot 4.1 提供了另一条更轻的路径:TaskDecorator 机制。org.springframework.core.task.TaskDecorator 是个函数式接口(来自 spring-core):
public interface TaskDecorator {
Runnable decorate(Runnable runnable);
}
Spring Framework 7.0.9 自带两个实现(均在 org.springframework.core.task.support,核实自本机 spring-core-7.0.9.jar):
| 类 | 作用 |
|---|---|
ContextPropagatingTaskDecorator | 基于 Micrometer Context Propagation 复制上下文(构造器可接收 io.micrometer.context.ContextSnapshotFactory) |
CompositeTaskDecorator | 把多个 TaskDecorator 组合成一个 |
这里必须纠正一个常见误解:ContextPropagatingTaskDecorator 不是 Spring Security 的类,而是 Spring Framework(spring-core)的类。它在执行 Runnable 前后通过 ContextSnapshot 保存/恢复所有注册过的 ThreadLocal——其中就包括 SecurityContextHolderThreadLocalAccessor(核实 spring-security-core 里有这个类),于是 SecurityContext 被自动带进异步线程。
Spring Boot 4.1 把它接到了配置上。本机 spring-boot-autoconfigure-4.1.1.jar 的配置元数据里核实到:
spring.task.execution.propagate-context = false # 默认关闭
spring.task.execution.mode = auto
spring.task.execution.pool.core-size = 8
TaskExecutionProperties 确有 getPropagateContext() / setPropagateContext(boolean)(核实)。把它设为 true,Boot 会自动给默认的 applicationTaskExecutor(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)套上上下文传播,无需自己写委托类。注意它依赖 classpath 上的 Micrometer Context Propagation(io.micrometer:context-propagation,提供 io.micrometer.context.ContextSnapshotFactory,已核实该工件存在)。
8.3.7 消息消费线程里的上下文
同步 HTTP 请求之外,还有一类线程完全没有 SecurityContext 的来源:消息消费线程。Kafka / RabbitMQ 的监听容器用自己的线程池调用 @KafkaListener / @RabbitListener 方法,这些线程既不是请求线程,也没有「父线程的上下文」可继承。
两条可行路线:
- 把身份放进消息头,消费时手动重建上下文。 生产端在消息头写用户标识,消费端读出后构造
UsernamePasswordAuthenticationToken,用SecurityContextHolder.setContext(...)设置,处理完在finally里clearContext()。 - 用
DelegatingSecurityContext*装饰监听容器的执行器。 若消费入口本身已在有上下文的线程上(例如由另一个安全感知的调度器触发),把监听容器的TaskExecutor包一层即可。
手工重建上下文的骨架(SecurityContextHolder.setContext / clearContext、SimpleGrantedAuthority 均已核实):
@Component
public class LoanEventConsumer {
@KafkaListener(topics = "loan-events")
public void onLoanEvent(ConsumerRecord<String, String> record) {
String reader = new String(record.headers()
.lastHeader("X-Reader").value(), StandardCharsets.UTF_8);
try {
var auth = UsernamePasswordAuthenticationToken.authenticated(
reader, null, List.of(new SimpleGrantedAuthority("ROLE_READER")));
SecurityContextHolder.getContext().setAuthentication(auth);
// ... 处理业务
} finally {
SecurityContextHolder.clearContext(); // 线程复用,必须清理
}
}
}
这里 finally 里的 clearContext() 不是可选项。 消费线程会被复用,若不清理,下一条消息(可能属于别的用户)会看到上一条残留的身份——这是消息场景里最容易发生的越权。HTTP 请求之所以不需要你手写清理,是因为 SecurityContextHolderFilter 已经替你做了。
8.3.8 知道之后能做什么
选传播方案。 只在少数几个执行器上需要传播,用 DelegatingSecurityContextAsyncTaskExecutor 显式包装,语义最清晰;全局统一,用 spring.task.execution.propagate-context=true。不要用 MODE_INHERITABLETHREADLOCAL 配线程池。
观察上下文是否真的丢了。 在 @Async 方法首行打 SecurityContextHolder.getContext().getAuthentication().getClass().getSimpleName():拿到 AnonymousAuthenticationToken 说明没传播,拿到 UsernamePasswordAuthenticationToken 说明传播成功。
处理虚拟线程。 虚拟线程是「新线程」,同样不继承 ThreadLocal(也不继承 InheritableThreadLocal)。因此虚拟线程 + 安全上下文的组合,同样要靠 TaskDecorator / DelegatingSecurityContext* 显式传递,而不是指望线程继承。
排障清单。 @PreAuthorize 完全不生效:先确认 bean 被 Spring 代理(不是 new 出来的、不是内部自调用);returnObject 为 null:@PostAuthorize 用在了返回 void 的方法上;表达式里 #param 取不到:编译时没保留参数名(加 -parameters);异步里身份丢失:检查是否只配了 ThreadLocal。
小结
@EnableMethodSecurity通过MethodSecuritySelector+PrePostMethodSecurityConfiguration注册四个MethodInterceptor,背后是 AOP 代理,注解只在代理上生效。@PreAuthorize/@PostAuthorize在org.springframework.security.access.prepost;SpEL 根对象是MethodSecurityExpressionRoot,returnObject只有@PostAuthorize能取到。- 方法安全与
@Transactional的顺序由offset()/order()决定,应保证安全判定先于事务开启。 ThreadLocal上下文不跨线程;用DelegatingSecurityContext*委托类或 4.1 的spring.task.execution.propagate-context=true补回;ContextPropagatingTaskDecorator是 spring-core 的类,不是 Security 的。
下一章进入 AOT 与原生镜像:容器启动时的 AOT 处理会生成哪些产物、反射与资源为什么要显式登记、原生镜像在启动速度与峰值吞吐之间做了哪些权衡。
阅读导航:上一节:8.2 认证与授权流程 · 下一节:9.1 AOT 处理与生成物 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。