本节目标:把授权从 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 异步与线程池 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。