《Spring Boot 高级》8.3 方法安全与上下文传播

@EnableMethodSecurity 如何借 AOP 织入方法安全、@PreAuthorize 的表达式求值上下文、与 @Transactional 同时存在时的拦截器顺序,以及 SecurityContext 在 @Async 与虚拟线程里为何丢失,并给出两种补回上下文的方案。

本节目标:讲清 @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@PreFilterAuthorizationManagerBeforeMethodInterceptor
PRE_AUTHORIZE@PreAuthorizeAuthorizationManagerBeforeMethodInterceptor
SECURED@SecuredAuthorizationManagerBeforeMethodInterceptor
JSR250@RolesAllowedAuthorizationManagerBeforeMethodInterceptor
POST_AUTHORIZE@PostAuthorizeAuthorizationManagerAfterMethodInterceptor
POST_FILTER@PostFilterAuthorizationManagerAfterMethodInterceptor

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 里,你能直接用的变量有:

表达式来源含义
authenticationSecurityExpressionRoot当前 Authentication
principalSecurityExpressionRootAuthentication.getPrincipal()
hasRole('READER')SecurityExpressionRoot角色判定(自动加 ROLE_ 前缀)
#isbnSpEL 参数变量方法参数(编译时 -parameters 或调试信息)
#thisMethodSecurityExpressionRoot.getThis()目标对象本身
returnObjectsetReturnObject(...)仅 @PostAuthorize 可用
filterObjectsetFilterObject(...)仅 @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);
    }
}
委托类包装什么包
DelegatingSecurityContextExecutorExecutorconcurrent
DelegatingSecurityContextExecutorServiceExecutorServiceconcurrent
DelegatingSecurityContextScheduledExecutorServiceScheduledExecutorServiceconcurrent
DelegatingSecurityContextCallable / DelegatingSecurityContextRunnableCallable / Runnableconcurrent
DelegatingSecurityContextAsyncTaskExecutorAsyncTaskExecutortask
DelegatingSecurityContextTaskExecutorTaskExecutortask

它们的做法是「提交时捕获当前上下文,执行时设置、结束后清理」,因此在线程复用下也不会串味。

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 方法,这些线程既不是请求线程,也没有「父线程的上下文」可继承。

两条可行路线:

  1. 把身份放进消息头,消费时手动重建上下文。 生产端在消息头写用户标识,消费端读出后构造 UsernamePasswordAuthenticationToken,用 SecurityContextHolder.setContext(...) 设置,处理完在 finally 里 clearContext()。
  2. 用 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 处理与生成物 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计