《Spring Boot 实战》9.3 方法级授权与多租户

用 @EnableMethodSecurity 与 @PreAuthorize/@PostAuthorize 做方法级授权,设计 RBAC 与资源级权限,并在分馆租户模型下比较租户字段过滤、schema 隔离与库隔离的取舍,解决租户上下文在异步线程中的传播与越权测试。

本节目标:把授权从 URL 层下沉到方法层,用 @PreAuthorize/@PostAuthorize 表达资源级权限,并为「图书借阅管理服务」设计多分馆租户的数据隔离与上下文传播,最后给出越权访问的测试写法。
适用版本:Spring Boot 4.1.x(Java 21)

9.3 方法级授权与多租户

前两节解决的是「你是谁」(认证)和「凭证怎么带」(JWT)。本节解决「你能做什么」——而且这个「什么」不只是「能不能访问 /api/books」,还包括「能不能修改这一本书」「能不能看到本分馆的借阅记录」。

authorizeHttpRequests 只能按 URL 和角色做粗粒度控制,一旦权限规则依赖请求参数或返回值,它就无能为力。方法级安全补上这一层。同时,「图书借阅管理服务」是分馆制:北区分馆的馆员不应看到南区分馆的数据,这就是多租户隔离问题。

9.3.1 启用方法级安全

在任意 @Configuration 类上加 @EnableMethodSecurity:

@Configuration
@EnableMethodSecurity(
    prePostEnabled = true,   // @PreAuthorize / @PostAuthorize / @PreFilter / @PostFilter,默认开
    securedEnabled = false,  // @Secured,默认关,新代码不建议用
    jsr250Enabled = false)   // @RolesAllowed 等 JSR-250 注解,默认关
public class MethodSecurityConfig {
}

几个必须知道的事实:

  • 旧的 @EnableGlobalMethodSecurity 已被 @EnableMethodSecurity 取代并移除,网上大量旧文还在用前者。
  • prePostEnabled 默认为 true,所以最简写法是光加 @EnableMethodSecurity。这里显式写出来是为了让团队评审时一眼看到取舍。
  • 方法级安全靠 AOP 代理生效。同类内部调用(this.method())不会经过代理,注解失效——这是「明明加了 @PreAuthorize 却没拦住」的头号原因。

9.3.2 @PreAuthorize 与 @PostAuthorize

@PreAuthorize 在方法执行前判断,能拿到方法参数;@PostAuthorize 在方法执行后判断,能拿到返回值。图书借阅场景下两者都要用。

@Service
public class LoanService {

    // 只有馆员或管理员能审批借阅
    @PreAuthorize("hasAnyRole('LIBRARIAN', 'ADMIN')")
    public void approve(Long loanId) {
        // ...
    }

    // 读者只能查自己的借阅记录:参数里的 memberId 必须等于当前主体
    @PreAuthorize("#memberId == authentication.principal.memberId")
    public LoanView findByMember(Long memberId, Long loanId) {
        // ...
    }

    // 馆员可查任意记录,但返回值必须属于本租户,防止越租户读取
    @PostAuthorize("hasRole('ADMIN') or "
        + "returnObject.tenantId() == authentication.principal.tenantId")
    public LoanView findById(Long loanId) {
        // ...
    }

    // 复杂规则抽到策略 bean,避免 SpEL 里塞业务逻辑
    @PreAuthorize("@loanPolicy.canBorrow(authentication, #bookId)")
    public Loan borrow(Long bookId) {
        // ...
    }
}

常用的 SpEL 表达式:

表达式含义
hasRole('LIBRARIAN')拥有 ROLE_LIBRARIAN(自动补前缀)
hasAnyRole('LIBRARIAN','ADMIN')任一角色满足
hasAuthority('loan:approve')精确匹配权限字符串,不补前缀
isAuthenticated()已认证即可
#memberId == authentication.principal.memberId参数与主体比对
@beanName.method(...)调用容器里的 bean
returnObject.xxx仅 @PostAuthorize 可用

authentication.principal 的具体类型取决于你的 JwtAuthenticationConverter。本节把 JWT 转成一个自定义主体,这样 SpEL 里能直接写 memberId 和 tenantId:

public record LoanUserPrincipal(Long memberId, String tenantId, List<String> roles) {
}

对应的转换器把 roles claim 变成 ROLE_ 前缀的权限,并让 getPrincipal() 返回上面的 record:

Converter<Jwt, AbstractAuthenticationToken> tenantAwareConverter() {
    return jwt -> {
        List<String> roles = jwt.getClaimAsStringList("roles");
        var authorities = roles.stream()
            .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
            .toList();
        var principal = new LoanUserPrincipal(
            Long.valueOf(jwt.getSubject()),
            jwt.getClaimAsString("tenant"),
            roles);
        return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject()) {
            @Override
            public Object getPrincipal() {
                return principal;
            }
        };
    };
}

9.3.3 RBAC 与资源级权限

「馆员 / 读者 / 管理员」是典型的 RBAC(基于角色的访问控制):权限挂在角色上,用户挂角色。它简单、易审计,但只能回答「这类人能做什么」,回答不了「这个人能不能动这条数据」。

模型判断依据能表达典型场景
RBAC主体角色「馆员能审批借阅」功能入口开关
资源级(ACL/归属)角色 + 资源归属/状态「读者只能看自己的借阅」数据行级访问
租户级角色 + 租户归属「馆员只管理本分馆」SaaS 多租户

生产系统三者叠加:先用租户隔离把数据范围收敛到本分馆,再用 RBAC 决定功能入口,最后用资源级规则决定具体某行数据能不能碰。三层的顺序不能反——先做功能级 RBAC 再做行级过滤,往往会导致「有权限但查不到数据」的困惑,正确的顺序是先收敛数据范围。

一个易被忽略的点:资源级判断尽量放在查询条件里而不是返回后判断。@PostAuthorize 发现越权时数据已经查出来了,对敏感数据而言「读到了再拒绝」本身就是泄露。能用 WHERE member_id = :current 过滤的,就别用 @PostAuthorize 兜。

9.3.4 多租户的数据隔离

「图书借阅管理服务」的租户就是分馆。三种隔离方案:

方案隔离强度迁移成本连接池适用
共享库 + 租户字段(discriminator)弱,靠代码与框架保证低,一次迁移单池多小租户、成本敏感
每租户 schema中,数据库层隔离中,迁移要对 N 个 schema 执行单池(切换 schema)中等租户数、有合规要求
每租户独立库强,物理隔离高,运维与迁移复杂多池少量大租户、强合规

绝大多数 SaaS 从「共享库 + 租户字段」起步,只有在单个大客户提出合规要求时才升级到独立库。不要一上来就做独立库:连接池数量、迁移工具、备份策略都会成倍复杂化。

共享库方案里,让框架自动给每条查询加上租户条件,比手工在每个 repository 方法里写 WHERE tenant_id = ? 可靠得多。Hibernate 6+ 提供了 @TenantId:

@Entity
public class Loan {

    @Id
    @GeneratedValue
    private Long id;

    @TenantId                      // 所有查询自动追加 tenant_id 条件
    private String tenantId;

    private Long bookId;
    private Long memberId;

    // getter / setter 省略
}

配套需要一个 CurrentTenantIdentifierResolver,把当前请求的租户告诉 Hibernate:

@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver<String> {

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.get();
        return tenant != null ? tenant : "DEFAULT";   // 无租户上下文时兜底,禁止返回 null
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

必须兜底一个非 null 的租户。如果 resolveCurrentTenantIdentifier() 返回 null,Hibernate 会跳过租户过滤,查询范围瞬间变成「所有租户」——这是多租户系统里最危险的一类静默漏洞。

@TenantId 只覆盖 Hibernate 发出的查询。如果你还用了原生 SQL、JdbcTemplate、或缓存 key,必须各自保证带上租户维度,否则缓存会出现跨租户串数据(缓存章节讲过的 key 设计原则在这里同样成立:租户 id 是缓存 key 的一部分)。

9.3.5 租户上下文的传播

租户上下文用 ThreadLocal 承载,随请求线程建立、随请求结束清理:

public final class TenantContext {

    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    private TenantContext() {
    }

    public static void set(String tenantId) {
        CURRENT.set(tenantId);
    }

    public static String get() {
        return CURRENT.get();
    }

    public static void clear() {
        CURRENT.remove();          // 必须 remove,避免线程复用导致串租户
    }
}

在过滤器里从 JWT 写入,并保证 finally 清理:

@Component
public class TenantContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof JwtAuthenticationToken jwtAuth) {
            TenantContext.set(jwtAuth.getToken().getClaimAsString("tenant"));
        }
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

问题出在异步:线程池里的线程是复用的,ThreadLocal 不会自动跟着任务走。如果 @Async 方法或 CompletableFuture 里读 TenantContext.get(),拿到的是上一次任务残留的租户,也就是串租户。解决方式是给线程池装一个 TaskDecorator,在任务执行前后搬运上下文:

public class TenantAwareTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        String tenant = TenantContext.get();
        SecurityContext security = SecurityContextHolder.getContext();
        return () -> {
            try {
                TenantContext.set(tenant);
                SecurityContextHolder.setContext(security);
                task.run();
            } finally {
                TenantContext.clear();
                SecurityContextHolder.clearContext();
            }
        };
    }
}

接到线程池上:

@Bean
ThreadPoolTaskExecutor loanExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("loan-async-");
    executor.setTaskDecorator(new TenantAwareTaskDecorator());
    executor.initialize();
    return executor;
}

4.0 起,容器里若有多个 TaskDecorator bean,自动配置会用 CompositeTaskDecorator 把它们串起来,所以你可以把「租户搬运」和「日志 MDC 搬运」拆成两个独立的 decorator。4.1 又为 @Async 增加了上下文传播能力(面向可观测性,把 trace 上下文带到异步线程),但它不负责租户这种业务上下文——业务上下文仍要靠 TaskDecorator 或显式传参。CompletableFuture.supplyAsync 如果用的是自建线程池而非上面的 loanExecutor,同样要装 decorator,否则就是又一个串租户入口。

9.3.6 越权访问的测试

越权是最容易写对代码却测不出来的缺陷。测试要点是覆盖「越权应当被拒绝」的负向用例,而不只是「有权限能通过」的正向用例。

用 @WithMockUser 做角色级测试(记得引入 spring-boot-starter-security-test,4.x 下它不再随主 starter 附带):

@SpringBootTest
@AutoConfigureMockMvc      // 4.x 起 @SpringBootTest 不再自带 MockMvc,必须显式加
class LoanAuthorizationTest {

    @Autowired
    MockMvc mvc;

    @Test
    @WithMockUser(roles = "READER")
    void readerCannotApproveLoan() throws Exception {
        mvc.perform(post("/api/loans/42/approve"))
           .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "LIBRARIAN")
    void librarianCanApproveLoan() throws Exception {
        mvc.perform(post("/api/loans/42/approve"))
           .andExpect(status().isOk());
    }
}

但 @WithMockUser 造出来的主体是 User,没有 memberId/tenantId,所以 9.3.2 里那些依赖自定义主体的规则测不到。这时要用 @WithSecurityContext 自定义一个注解,由工厂构造带 LoanUserPrincipal 的 JwtAuthenticationToken 塞进 SecurityContext:

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithLoanUserSecurityContextFactory.class)
public @interface WithLoanUser {
    long memberId() default 1L;
    String tenantId() default "branch-north";
    String[] roles() default {"READER"};
}

这样就能断言「北区分馆的读者读不到南区分馆的借阅记录」。这类跨租户用例是必测项——多租户系统出事故,十有八九是某个查询路径漏了租户条件。

最后提醒一个测试盲区:@SpringBootTest 起的完整上下文会装配真实的 JwtDecoder,但如果测试里用 @WithMockUser 绕过了它,算法校验、exp、aud 这些逻辑其实没被测到。认证逻辑应另有一组针对 JwtDecoder 的单元测试,用真签发的 token 覆盖「过期」「签名错误」「aud 不符」等分支。

小结

方法级授权把权限判断下沉到方法,用 @PreAuthorize 看参数、@PostAuthorize 看返回值,复杂规则抽到策略 bean。多租户优先「共享库 + 租户字段」,用 Hibernate @TenantId 自动过滤,并保证租户解析器永不返回 null。租户上下文基于 ThreadLocal,跨异步边界必须靠 TaskDecorator 显式搬运。测试的重点是负向用例:越权请求必须被拒,跨租户读取必须被拦。

阅读导航:上一节:9.2 JWT 无状态认证 · 下一节:10.1 异步与线程池 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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