Security资源授权:方法、对象与租户边界
一次请求通过验签,只能说明凭证满足认证策略。它不证明调用者可以修改订单,不证明订单属于调用者,更不证明同名用户在另一个租户拥有这条记录。资源授权需要把“谁、对哪个资源、执行什么动作、何时仍有权”连成一个不变量。
本文是 Servlet 资源授权的唯一主正文,承担请求、方法、对象、租户,以及 ACL、授权事件、Run-As、后台执行时授权、Spring Data 查询与 WebSocket 消息授权。认证、session 与浏览器防护先读 U29认证、U29浏览器防护;access token 如何获得和验证、撤销及密钥轮换读 U30 OAuth2与OIDC。这里使用这些机制产生的可信身份,不重复定义它们。
版本与证据:固定 Spring Security 7.0.8,对应 commit 0102bb2e879e00632f30864785f0f46127877a4d;真实工程为 Boot 4.0.9 / Framework 7.0.10 / JDK 21.0.11+11-LTS,macOS 27.0.1 arm64。2026-10-26 的完整证据已独立核对:七组场景、154 次 HTTP、runner exitCode=0、三个 JVM 全部回收。本文只将其中请求/方法/对象/可信租户场景标为实跑;后文 ACL JDBC、events 观测接线、Run-As、异步撤销、Spring Data 和 WebSocket 是固定源码/具体配置推导,未借用这154次请求宣称运行通过。
一、把请求、方法和数据边界分开
以 alice 读取 t1 租户订单为例,请求层检查令牌是否可用于 orders-api、是否有读 scope;方法层检查调用是否允许读这个订单;数据层以可信 tenant、owner、id 三个条件选择记录。表单里的 tenant 只是选择意图,不能创建租户成员资格。前端隐藏按钮只改善交互,任何人仍能直接构造 HTTP 请求。
| 层次 | 输入与职责 | 不能替代的边界 |
|---|---|---|
| 认证 | 可信 issuer 的签名、audience、时间、tenant claim | token 有效不等于有资源权限 |
| 请求 | matcher、HTTP动作、authority;其他路径 denyAll | 同一方法可能由定时任务或其他入口调用 |
| 方法 | Spring代理、动作权限、参数/返回对象规则 | 注解不会自动改写所有SQL,也不会拦截所有自调用 |
| 对象/租户 | 服务端记录、可信主体、行级查询/条件更新 | 同租户不等于同owner,owner同名也不等于同租户 |
hasAuthority("SCOPE_orders.read") 检查精确字符串;默认 JWT scope 转换会加 SCOPE_ 前缀。hasRole("ADMIN") 通常按默认角色前缀寻找 ROLE_ADMIN,不等于 SCOPE_admin。角色层级需要显式配置、并在相应表达式/manager中使用,不能根据“管理员”这个名字自动得到所有业务scope。权限来自可信认证器,不能把 X-Role 或用户提交的 JSON authorities直接转成GrantedAuthority。
请求匹配还要区分两个选择:securityMatcher 决定哪一条 SecurityFilterChain 接手,requestMatchers 决定该链内部的授权规则。多条链按Order先匹配先使用;链内也按声明顺序选择匹配项,并不是把所有规则累加。先写过宽 permitAll 会让后面的精确规则不可达。只覆盖 /jwt/** 而没有兜底链,还会留下其他入口。认证错误应先看凭证链,已认证但无权应看授权规则,避免拿“所有请求都403”冒充安全通过。
固定源码:RequestMatcherDelegatingAuthorizationManager.java,Security 7.0.8,连续66–90行,SHA-256为整文件摘要。
@Override
public @Nullable AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication,
HttpServletRequest request) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorizing %s", requestLine(request)));
}
for (RequestMatcherEntry<AuthorizationManager<? super RequestAuthorizationContext>> mapping : this.mappings) {
RequestMatcher matcher = mapping.getRequestMatcher();
MatchResult matchResult = matcher.matcher(request);
if (matchResult.isMatch()) {
AuthorizationManager<? super RequestAuthorizationContext> manager = mapping.getEntry();
if (this.logger.isTraceEnabled()) {
this.logger.trace(
LogMessage.format("Checking authorization on %s using %s", requestLine(request), manager));
}
return manager.authorize(authentication,
new RequestAuthorizationContext(request, matchResult.getVariables()));
}
}
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.of(() -> "Denying request since did not find matching RequestMatcher"));
}
return DENY;
}循环按mappings顺序找第一个match,命中后立即return对应manager的结果;没有匹配则明确DENY。这个兜底与“命中的自定义manager返回null”不是同一分支。AuthorizationResult是允许/拒绝结果接口,null表示未作决定;HTTP AuthorizationFilter和本文方法before拦截器都仅在非null且拒绝时阻止调用,所以自定义规则应把未识别输入转成显式拒绝。WebSocket的channel拦截器对null又是默认拒绝,不可把某层的null语义外推全部入口。
二、最小完整工程与生效条件
可复制运行的工程入口是 lab README,依赖见 pom.xml,完整配置是 ResourceServer.java。它与真实 AS、Client 在三个独立 JVM 中运行,使用合成用户与内存记录,不需要外部身份账号。下面完整内嵌当前资源服务器,保留其本地实验secret和GET演示接口的边界:/jwt/method 只返回合成结果,并未通过GET写数据库;生产写操作应使用POST/PATCH等动作并落实自身CSRF/幂等策略。
package study.oauthlab;
import java.time.Duration;
import java.util.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.oauth2.core.*;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.introspection.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
@Configuration @Profile("rs") @EnableMethodSecurity
class ResourceServer {
@Bean JwtDecoder decoder(@Value("${lab.issuer}") String issuer) {
var decoder=NimbusJwtDecoder.withJwkSetUri(issuer+"/oauth2/jwks").jwsAlgorithm(SignatureAlgorithm.RS256).build();
var clock=new JwtTimestampValidator(Duration.ZERO);
clock.setAllowEmptyExpiryClaim(false);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer), clock,
new JwtAudienceValidator("orders-api"),
jwt -> jwt.getClaimAsString("tenant") != null && Set.of("t1","t2").contains(jwt.getClaimAsString("tenant"))
? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token","trusted tenant required",null))));
return decoder;
}
@Bean OpaqueTokenIntrospector introspector(@Value("${lab.issuer}") String issuer) {
var remote=SpringOpaqueTokenIntrospector.withIntrospectionUri(issuer+"/oauth2/introspect")
.clientId("lab-client").clientSecret("lab-secret").build();
return token -> {
var principal=remote.introspect(token);
Object aud=principal.getAttribute("aud");
if(!(aud instanceof Collection<?> values) || !values.contains("orders-api"))
throw new BadOpaqueTokenException("wrong audience");
return principal;
};
}
@Bean @Order(1) SecurityFilterChain jwt(HttpSecurity http) throws Exception {
http.securityMatcher("/jwt/**").sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// This API accepts only explicit Authorization Bearer headers; no browser automatic credentials.
.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.requestMatchers("/jwt/admin").hasAuthority("SCOPE_admin")
.requestMatchers("/jwt/read").hasAuthority("SCOPE_orders.read").anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> {}).withObjectPostProcessor(failureResponses()));
return http.build();
}
@Bean @Order(2) SecurityFilterChain opaque(HttpSecurity http) throws Exception {
http.securityMatcher("/opaque/**").sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.anyRequest().hasAuthority("SCOPE_orders.read"))
.oauth2ResourceServer(o -> o.opaqueToken(t -> {}).withObjectPostProcessor(failureResponses()));
return http.build();
}
static ObjectPostProcessor<BearerTokenAuthenticationFilter> failureResponses() {
return new ObjectPostProcessor<BearerTokenAuthenticationFilter>() {
public <O extends BearerTokenAuthenticationFilter> O postProcess(O filter) {
filter.setAuthenticationFailureHandler((request,response,error) -> {
if (error instanceof AuthenticationServiceException) {
response.setStatus(503);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"identity_provider_unavailable\"}");
} else new BearerTokenAuthenticationEntryPoint().commence(request,response,error);
});
return filter;
}
};
}
@Bean @Order(3) SecurityFilterChain deny(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().denyAll()); return http.build();
}
@Bean Ownership ownership() { return new Ownership(); }
public static class Ownership {
// Synthetic server-side records; tenant/user come from validated authentication, never caller authority.
final Map<String,List<String>> records=Map.of("own",List.of("t1","alice"),"other",List.of("t1","bob"),"foreign",List.of("t2","alice"));
public boolean allowed(Authentication auth,String id,String tenant) {
if(!(auth instanceof org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken jwt)) return false;
String trustedTenant=jwt.getToken().getClaimAsString("tenant");
return Objects.equals(tenant,trustedTenant) && Objects.equals(records.get(id),List.of(trustedTenant,auth.getName()));
}
}
@Service @Profile("rs") public static class Orders {
@PreAuthorize("hasAuthority('SCOPE_orders.write')")
public Map<String,String> write() { return Map.of("result","authorized-write"); }
@PreAuthorize("hasAuthority('SCOPE_orders.read') and @ownership.allowed(authentication,#id,#tenant)")
public Map<String,String> read(String id,String tenant) { return Map.of("id",id,"tenant",tenant,"result","owned"); }
}
@RestController @Profile("rs") static class Endpoints {
final Orders orders;
Endpoints(Orders orders) {this.orders=orders;}
@GetMapping({"/jwt/read","/opaque/read","/jwt/admin"}) Map<String,Object> read(Authentication a) {
return Map.of("name",a.getName(),"authorities",a.getAuthorities().stream().map(Object::toString).toList());
}
@GetMapping("/jwt/method") Map<String,String> method() {return orders.write();}
@GetMapping("/jwt/orders/{id}") Map<String,String> order(@PathVariable String id,@RequestParam String tenant) { return orders.read(id,tenant); }
}
}读配置应沿输入到拒绝的顺序。decoder只信固定issuer、RS256、orders-api audience,显式要求exp、时钟偏差0秒,并要求tenant为t1/t2;nbf存在时校验,不意味着必须存在。把tenant放进合法签名只是前提,发行方还必须在签发时核实用户的租户资格;若发行方任由用户填写tenant,资源端的签名验证不能补救这个信任错误。
JWT链的 /jwt/read 要求读权限,/jwt/admin 要求管理权限;/jwt/method 和订单入口在请求层只要求authenticated,因此方法负例确实需要跨过请求层再由代理拒绝。opaque链只做读能力演示,Ownership 明确只接受JwtAuthenticationToken,不能把reference机器客户端当成alice。两个Bearer链是stateless、仅接受显式Authorization header、不使用自动携带的session/cookie/Basic凭据,所以局部关闭CSRF;条件改变时回到U29重新设计。
方法参数名表达式 #id/#tenant 依赖参数名可发现性;本工程compiler参数由POM固定。外部工程应启用 -parameters,或明确使用受支持的参数名注解/#p0等位置参数,并检查实际代理方法。@EnableMethodSecurity 开启默认pre/post支持;只有把业务类作为Spring bean、从代理外调用,注解才有机会执行。控制器注入Orders的代理,不能手工new Orders()绕过容器。
三、方法代理与前置后置授权时序
@PreAuthorize 在目标方法前评价表达式。@PostAuthorize 在目标已经返回后再评价 returnObject。前者适合写入前检查动作与归属,后者适合针对返回对象再确认读权限。二者都要求实际调用通过对应代理;this.write()直接进入同对象方法时绕过代理通知,不能因方法上出现注解就宣布生效。跨bean调用是容易复核的修正;也可调整代理/织入方式,但须重新检查实际调用路径。CGLIB不能通知final方法;也要核查目标类是否可被代理。
固定源码:AuthorizationManagerBeforeMethodInterceptor.java,Security 7.0.8,连续原码,SHA-256 为整文件摘要。
private @Nullable Object attemptAuthorization(MethodInvocation mi) throws Throwable {
this.logger.debug(LogMessage.of(() -> "Authorizing method invocation " + mi));
AuthorizationResult result;
try {
result = this.authorizationManager.authorize(this::getAuthentication, mi);
}
catch (AuthorizationDeniedException denied) {
return handle(mi, denied);
}
if (result != null) {
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, mi, result);
}
if (result != null && !result.isGranted()) {
this.logger.debug(LogMessage.of(() -> "Failed to authorize " + mi + " with authorization manager "
+ this.authorizationManager + " and result " + result));
return handle(mi, result);
}
this.logger.debug(LogMessage.of(() -> "Authorized method invocation " + mi));
return proceed(mi);
}
private @Nullable Object proceed(MethodInvocation mi) throws Throwable {
try {
return mi.proceed();
}
catch (AuthorizationDeniedException ex) {
if (this.authorizationManager instanceof MethodAuthorizationDeniedHandler handler) {
return handler.handleDeniedInvocation(mi, ex);
}
return this.defaultHandler.handleDeniedInvocation(mi, ex);关键输入是MethodInvocation和延迟取得的Authentication。manager返回非null结果时发布事件;拒绝交给denied handler,只有允许或null才进入proceed。默认拒绝处理抛授权异常;若团队自行实现manager却对未识别资源返回null,这一拦截器不会把null自动变成拒绝。自定义manager应对未知情况显式拒绝,测试应覆盖这一分支。this::getAuthentication在这里的调用时点是代理执行处,而不是凭空建立“任务最终提交时重新认证”的承诺。
固定源码:AuthorizationManagerAfterMethodInterceptor.java,Security 7.0.8,连续原码,SHA-256 为整文件摘要。
@Override
public @Nullable Object invoke(MethodInvocation mi) throws Throwable {
Object result;
try {
result = mi.proceed();
}
catch (AuthorizationDeniedException ex) {
if (this.authorizationManager instanceof MethodAuthorizationDeniedHandler handler) {
return handler.handleDeniedInvocation(mi, ex);
}
return this.defaultHandler.handleDeniedInvocation(mi, ex);
}
return attemptAuthorization(mi, result);
}这里的先后关系是mi.proceed()先、attemptAuthorization后。假设方法先向支付系统发HTTP再返回order,PostAuthorize拒绝只能阻止返回结果,不能撤销支付。给方法再加Transactional也不能覆盖外部HTTP、消息和任意内存变化;即使仅数据库,事务与授权advisor顺序也决定拒绝发生时事务是否仍可回滚。需要在副作用前检查权限,再以条件写和幂等保证提交;后置授权是补充,不是补偿机制。
@PreFilter/@PostFilter 可对受支持的集合元素应用表达式,但不是数据库分页授权。先取20条后过滤成3条,会留下总数泄露、页面空洞和大量无权数据进入进程的问题;流还可能有延迟消费和资源关闭约束。@Secured 与JSR-250注解需在EnableMethodSecurity分别开启对应支持,不能只因为加入依赖就假定已生效。新配置使用AuthorizationManager;旧AccessDecisionManager/voter属于另一套调用架构,不能把新旧类名拼成一条当前链。
四、对象归属与可信租户
实验记录固定为 own=(t1,alice)、other=(t1,bob)、foreign=(t2,alice)。Ownership.allowed 首先从已经校验的JWT取tenant和principal name,再比较请求tenant与可信tenant,最后比较服务端记录的tenant和owner。缺记录返回false,缺claim在认证阶段拒绝;没有拿请求tenant去覆盖身份。令牌t1/alice访问own/t1可成功,访问other/t1、foreign/t1、foreign/t2均失败;最后一种即使owner同名也跨越了tenant边界。
对于多issuer平台,单独sub=alice不构成全局身份,应以 (trustedIssuer, subject) 作为主体标识,再从可信成员关系取得tenant。解析未验证JWT的iss可用于选择候选认证器,但只能在预先信任的issuer注册表内选择;不允许拿任意URL发现密钥,否则既扩大身份信任又引入出站请求攻击面。JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...) 是显式信任入口,不会替业务校验tenant成员关系。多个issuer的同名tenant也必须做命名空间映射。此多issuer配置没有在本lab运行。
数据库方案应在读取、计数与更新都带边界。例如下列是教学SQL,非本lab的内存Map实跑:
SELECT id, status FROM orders
WHERE tenant_id = :trustedTenant AND owner_id = :trustedOwner AND id = :id;
SELECT COUNT(*) FROM orders
WHERE tenant_id = :trustedTenant AND owner_id = :trustedOwner;
UPDATE orders SET status = :newStatus, version = version + 1
WHERE tenant_id = :trustedTenant AND owner_id = :trustedOwner
AND id = :id AND version = :expectedVersion;trusted变量由认证与服务端成员表计算,参数绑定只解决值注入,不负责判断变量可信。更新影响0行可表示无权、记录不存在或版本冲突,API应按信息暴露策略选择统一404/403或可区分的409,再用内部审计定位。教材lab固定返回403,不虚称已经验收数据库404策略。若先查归属再无条件UPDATE ... WHERE id=?,中间归属变化会形成TOCTOU;把归属条件带入写语句,必要时使用行锁/版本和事务,才有可证明的提交边界。
五、ACL把对象权限落到持久状态
当文档可逐对象共享给用户或组,单一owner列不够时,ACL适合表达“哪个SID对哪个ObjectIdentity有什么权限”。固定7.0.8模块为spring-security-acl;AclPermissionEvaluator把hasPermission表达式连接到AclService,再按SID与权限掩码判断。常见JDBC模型含acl_class、acl_sid、acl_object_identity、acl_entry,分别标类型、主体、对象与ACE;ACE含顺序、granting、mask和审计标记,父ACL/继承会改变搜索范围。
以下给出独立扩展配置,未运行,不并入本lab。依赖为 org.springframework.security:spring-security-acl:7.0.8、spring-security-config:7.0.8、Framework 7.0.10的 spring-jdbc、spring-context-support 与相应PostgreSQL JDBC驱动;需已有指向专用实验库的DataSource。启动前由数据库迁移应用固定PostgreSQL schema:四表及主外键、对象类型/id唯一键、ACE顺序唯一键均保留。该schema用bigserial;默认JdbcMutableAclService却按HSQL的call identity()取ID,所以本配置显式覆盖两条currval查询。切换数据库时不能原样复制这些SQL。
import javax.sql.DataSource;
import org.springframework.cache.support.NoOpCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.security.acls.AclPermissionEvaluator;
import org.springframework.security.acls.domain.*;
import org.springframework.security.acls.jdbc.*;
import org.springframework.security.acls.model.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.access.expression.method.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableMethodSecurity
@EnableTransactionManagement
class AclSecurityConfiguration {
@Bean AclAuthorizationStrategy aclAuthorizationStrategy() {
return new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ROLE_ACL_ADMIN"));
}
@Bean PermissionGrantingStrategy permissionGrantingStrategy() {
return new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger());
}
@Bean AclCache aclCache(PermissionGrantingStrategy granting, AclAuthorizationStrategy strategy) {
return new SpringCacheBasedAclCache(new NoOpCache("acl"), granting, strategy);
}
@Bean LookupStrategy lookupStrategy(DataSource ds, AclCache cache,
AclAuthorizationStrategy strategy, PermissionGrantingStrategy granting) {
return new BasicLookupStrategy(ds, cache, strategy, granting);
}
@Bean JdbcMutableAclService aclService(DataSource ds, LookupStrategy lookup, AclCache cache) {
var service = new JdbcMutableAclService(ds, lookup, cache);
service.setClassIdentityQuery("select currval(pg_get_serial_sequence('acl_class','id'))");
service.setSidIdentityQuery("select currval(pg_get_serial_sequence('acl_sid','id'))");
return service;
}
@Bean static MethodSecurityExpressionHandler methodSecurityExpressionHandler(AclService service) {
var handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(new AclPermissionEvaluator(service));
return handler;
}
@Bean PlatformTransactionManager transactionManager(DataSource ds) {
return new DataSourceTransactionManager(ds);
}
}这份完整Bean接线假定该扩展应用仅一个DataSource/事务管理器;若已有事务管理器应复用并明确限定名,不能再制造两个互不协调的事务。ACL创建/授予/撤销的服务方法必须从外部代理进入@Transactional,因为新SID/类型的自增ID读取要求事务同步;ROLE_ACL_ADMIN来自受控认证映射,用户不能自行提交。
本例使用NoOpCache,避免可变ACL和事务内读取结果进入共享缓存;@Transactional负责数据库变更的提交与回滚。直接用共享ConcurrentMapCache时,updateAcl()会在事务提交前重新填充缓存:授予READ后即使数据库回滚,缓存仍可能保留授权并放行。生产启用ACL缓存时,还须处理可变对象隔离、提交前可见性、回滚及并发回填,再按第十节预算设计跨节点失效与TTL;仅延迟cache.put到提交后仍不足以隔离共享MutableAcl。ConsoleAuditLogger也只是示例接线,正式审计应脱敏并持久交付。此处是固定源码推演,未执行ACL数据库/回滚实验。
业务方法可以写 @PreAuthorize("hasPermission(#id, 'study.Order', 'read')")。在已认证且有ACL管理权的事务里,为 new ObjectIdentityImpl("study.Order", 42L) 创建MutableAcl,执行 insertAce(0, BasePermission.READ, new PrincipalSid("alice"), true) 后 aclService.updateAcl(acl);alice读取应允许,bob应拒绝。要撤销时读出MutableAcl,按该ACE的实际位置deleteAce再updateAcl,不以“第0条总属于alice”为假设。ObjectIdentity的类型与id由服务端固定;不能信客户端传来一个“已获授权”的DTO。
固定源码:AclPermissionEvaluator.java,Security 7.0.8,连续91–109行,SHA-256为整文件摘要。
private boolean checkPermission(Authentication authentication, ObjectIdentity oid, Object permission) {
// Obtain the SIDs applicable to the principal
List<Sid> sids = this.sidRetrievalStrategy.getSids(authentication);
List<Permission> requiredPermission = resolvePermission(permission);
this.logger.debug(LogMessage.of(() -> "Checking permission '" + permission + "' for object '" + oid + "'"));
try {
// Lookup only ACLs for SIDs we're interested in
Acl acl = this.aclService.readAclById(oid, sids);
if (acl.isGranted(requiredPermission, sids, false)) {
this.logger.debug("Access is granted");
return true;
}
this.logger.debug("Returning false - ACLs returned, but insufficient permissions for this principal");
}
catch (NotFoundException nfe) {
this.logger.debug("Returning false - no ACLs apply for this principal");
}
return false;
}输入对象身份先转换成适用于当前Authentication的SID集合和所需权限,再readAclById,并调用isGranted。没有ACL的NotFoundException转false;数据库不可用的异常不在这个catch范围,不能解释成“默认允许”或正常不存在。ACL策略的ACE顺序与继承有实际含义,deny/grant不是把所有mask简单OR;应在共享/撤销用例中验证本对象与父ACL的组合。
失败过程应具体到状态:给alice某对象READ,读成功;撤销对应ACE但保留其他读规则,清除相关对象及继承依赖缓存,再读应拒绝;授权库不可用时不得把查不到权限当允许。只更新SQL却保留进程缓存,可能继续依据旧ACE放行;跨节点失效需要版本/消息/有界TTL和重试。ACL对象owner是管理权限的重要主体,不自动等同于业务orders.owner_id;tenant更不是默认ACL字段。可以把tenant编码进ObjectIdentity的全局ID或采用受控隔离存储,并在进入ACL前验证tenant,避免不同租户的Order:42碰撞。对象删除和分享撤销由业务事务协调,缓存失效失败由授权服务负责重试与告警。
六、授权事件与拒绝诊断
认证成功事件说明凭据被接受,授权事件说明某个受保护调用的判定,两者不能互相充当证据。前置拦截器源码在执行前发布结果,事件不是业务提交成功通知。默认SpringAuthorizationEventPublisher只发布拒绝事件,允许事件不应假定全部自动采集;如需允许事件,应显式自定义publisher并控制采样/敏感信息。
下面是独立的publisher与listener接线,需将这两个@Configuration/@Component交给同一应用扫描,未在本lab增加事件计数断言。AuthorizeHttpRequestsConfigurer优先取应用的publisher,找不到则自己创建Spring publisher;pre/post方法配置会注入应用提供的publisher。直接new拦截器则仍须显式setAuthorizationEventPublisher,不能假定容器替任意手工对象注入。
@Configuration
class AuthorizationAuditConfiguration {
@Bean
AuthorizationEventPublisher authorizationEventPublisher(ApplicationEventPublisher events) {
return new SpringAuthorizationEventPublisher(events);
}
}
@Component
class AuthorizationAuditListener {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(AuthorizationAuditListener.class);
@org.springframework.context.event.EventListener
public void denied(AuthorizationDeniedEvent<?> event) {
// 示例仅记录结果;不要隐式序列化event中的对象、参数或Authentication。
log.warn("resource_authorization granted={}", event.getAuthorizationResult().isGranted());
}
}上面除@Configuration、@Bean、@Component外的导入为 org.springframework.context.ApplicationEventPublisher、org.springframework.security.authorization.AuthorizationEventPublisher、org.springframework.security.authorization.SpringAuthorizationEventPublisher 和 org.springframework.security.authorization.event.AuthorizationDeniedEvent。实际审计应另以服务端生成的traceId、规则版本、资源类型和脱敏主体/tenant关联,不记录原始MethodInvocation、参数、HTTP headers或完整Authentication。
固定源码:SpringAuthorizationEventPublisher.java,Security 7.0.8,连续60–71行,SHA-256为整文件摘要。
@Override
public <T> void publishAuthorizationEvent(Supplier<Authentication> authentication, T object,
@Nullable AuthorizationResult result) {
if (result == null) {
return;
}
if (!this.shouldPublishResult.test(result)) {
return;
}
AuthorizationDeniedEvent<T> failure = new AuthorizationDeniedEvent<>(authentication, object, result);
this.eventPublisher.publishEvent(failure);
}固定源码先忽略null,再应用shouldPublishResult,随后无条件构造AuthorizationDeniedEvent。默认predicate为!result.isGranted()。7.0.8文档成功事件示例出现无参构造与setShouldPublishEvent,与此版本实际构造器和setShouldPublishResult不一致;而把实际predicate设为true仍不会让该类构造AuthorizationGrantedEvent。若必须记录允许事件,应另实现AuthorizationEventPublisher,按result.isGranted分支显式构造对应事件并验证监听类型;不要把事件类名当唯一判定,也不要照抄浮动文档示例。
排障顺序是先分认证与授权,再分请求与方法,最后分对象查询。缺Bearer/失效Bearer在本lab为401;合法Bearer缺scope为403;依赖introspection/JWKS不可用通过本工程显式failure handler给503,且不创建身份。不能把503改成permitAll,也不能把所有403当成会话超时。HTTP异常翻译只在过滤链可捕获且响应未提交时工作;后台线程应记录任务失败、对消息执行受控拒绝/死信,而不是期待其变成原请求的403。高吞吐时同步监听器会增加判定路径延迟,异步审计队列需有界并定义丢弃/持久化策略;审计丢失不能靠伪造允许日志补齐。
七、Run-As与后台执行的授权时点
Run-As是受控调用期间临时切换Authentication,不是重新登录,也不是把用户永久升级为管理员。独立的spring-security-access:7.0.8模块保留旧授权类;固定源码的AbstractSecurityInterceptor在授权通过后调用RunAsManager,得到临时Authentication时建立新SecurityContext并替换,结束时finally恢复;RunAsManagerImpl从受信配置属性中的RUN_AS_项构造RunAsUserToken,消费方需匹配RunAsImplAuthenticationProvider的key。这个key是内部token校验约定,不是网络认证凭据。新@PreAuthorize的AuthorizationManagerBeforeMethodInterceptor没有自动调用旧RunAsManager的步骤。
固定源码:AbstractSecurityInterceptor.java,Security 7.0.8,连续233–249行,SHA-256为整文件摘要。
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
if (runAs != null) {
SecurityContext origCtx = this.securityContextHolderStrategy.getContext();
SecurityContext newCtx = this.securityContextHolderStrategy.createEmptyContext();
newCtx.setAuthentication(runAs);
this.securityContextHolderStrategy.setContext(newCtx);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Switched to RunAs authentication %s", runAs));
}
// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
this.logger.trace("Did not switch RunAs authentication since RunAsManager returned null");
// no further work post-invocation
return new InterceptorStatusToken(this.securityContextHolderStrategy.getContext(), false, attributes, object);固定源码:AbstractSecurityInterceptor.java,Security 7.0.8,连续278–286行,SHA-256为整文件摘要。
protected void finallyInvocation(InterceptorStatusToken token) {
if (token != null && token.isContextHolderRefreshRequired()) {
this.securityContextHolderStrategy.setContext(token.getSecurityContext());
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage
.of(() -> "Reverted to original authentication " + token.getSecurityContext().getAuthentication()));
}
}
}授权已经通过后才执行buildRunAs;仅返回非null时替换context,保存原context的token标记需要恢复。finallyInvocation负责恢复,但具体拦截器调用者必须在finally中调用它,不能只阅读这个方法就证明所有业务代码异常时都恢复成功。
若维护历史系统,只有实际使用AbstractSecurityInterceptor、配置其RunAsManager,并配置认可token的provider,才可讨论这一旧机制;仅声明一个RunAsManager bean不会让现代method DSL自动提权。新系统可用显式受控的服务身份执行固定任务:动作、目标tenant、对象范围、有效期和发起者都写入服务端委托记录,worker只持执行所需权限。不要把提交人的全部Authentication永久绑定共享executor。此节Run-As是固定源码时序,未运行提权实验。
固定源码:DelegatingSecurityContextRunnable.java,Security 7.0.8,连续原码,SHA-256 为整文件摘要。
public DelegatingSecurityContextRunnable(Runnable delegate, SecurityContext securityContext) {
this(delegate, securityContext, true);
}
/**
* Creates a new {@link DelegatingSecurityContextRunnable} with the
* {@link SecurityContext} from the {@link SecurityContextHolder}.
* @param delegate the delegate {@link Runnable} to run under the current
* {@link SecurityContext}. Cannot be null.
*/
public DelegatingSecurityContextRunnable(Runnable delegate) {
this(delegate, SecurityContextHolder.getContext(), false);
}
private DelegatingSecurityContextRunnable(Runnable delegate, SecurityContext securityContext,
boolean explicitSecurityContextProvided) {
Assert.notNull(delegate, "delegate cannot be null");
Assert.notNull(securityContext, "securityContext cannot be null");
this.delegate = delegate;
this.delegateSecurityContext = securityContext;
this.explicitSecurityContextProvided = explicitSecurityContextProvided;
}
@Override
public void run() {
this.originalSecurityContext = this.securityContextHolderStrategy.getContext();
try {
this.securityContextHolderStrategy.setContext(this.delegateSecurityContext);
this.delegate.run();
}
finally {
SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext();
if (emptyContext.equals(this.originalSecurityContext)) {
this.securityContextHolderStrategy.clearContext();
}
else {
this.securityContextHolderStrategy.setContext(this.originalSecurityContext);
}
this.originalSecurityContext = null;
}包装器保存worker原context、装入委托context、执行delegate,在finally恢复旧context或清空。这证明线程复用清理,不证明重新登录或撤销复核。无显式context的构造器在包装时取SecurityContextHolder,保存的是context引用,也不是深拷贝不可变身份。DelegatingSecurityContextExecutor在提交时包装任务;显式固定context的executor可能让所有任务以同一身份运行。ThreadLocal本身不会自动跨普通线程池传播;直接改为InheritableThreadLocal对已存在的池线程也不能建立正确的逐任务身份。
具体失败时序:10:00:00提交导出任务,10:00:02管理员撤销alice权限,10:00:30 worker从队列执行。只传播提交时Authentication,方法检查的authorities仍可能是旧快照。@Async拦截器与安全advisor顺序会影响检查在线程池提交前还是worker内发生,不能凭注解排列猜测。可靠方案是在worker入口另一个受代理的服务方法执行授权,按任务中不可变的issuer/sub/tenant重新加载当前成员资格或校验受限委托,然后在写入/输出前重新检查关键条件。JWT即使再验签也不自动查询撤销表。
恢复责任要区分:线程包装器负责finally恢复;任务平台负责过期任务不执行、幂等重试与失败可见;授权服务负责撤销状态可达与版本传播;业务服务负责检查到提交的竞争。权限服务故障时任务可保持待重试状态并限制重试窗口,不得使用旧管理员身份无限续跑。设计验证需卡住队列、撤销、释放worker,再检查是否拒绝以及下一个无身份任务是否串号;本lab尚未执行这一扩展。
八、SpringData把边界放进查询
spring-security-data提供SecurityEvaluationContextExtension,让支持相应SpEL查询的Spring Data模块读取安全根对象。具体配置是声明 @Bean SecurityEvaluationContextExtension securityExtension() { return new SecurityEvaluationContextExtension(); },在JPA Repository的@Query中使用安全表达式。例如默认JWT principal为Jwt时:
@Query("select o from OrderEntity o where o.tenantId = ?#{principal.claims['tenant']} "
+ "and o.ownerId = ?#{authentication.name}")
Page<OrderEntity> findVisible(Pageable pageable);本例依赖spring-security-data与Spring Data JPA、已验证且含tenant的JWT,以及ownerId采用当前Authentication.name语义。对opaque、匿名或自定义principal不能假定存在claims;应在服务层拒绝或建立经过审查的统一主体类型。SecurityEvaluationContextExtension并不自动给所有Repository方法追加tenant条件,也不使findAll()、native SQL、批量更新或实体关联自动安全。异步调用查询时同样需要有效context与执行时授权。
固定源码:SecurityEvaluationContextExtension.java,Security 7.0.8,连续130–142行,SHA-256为整文件摘要。
@Override
public SecurityExpressionRoot<Object> getRootObject() {
Authentication authentication = getAuthentication();
SecurityExpressionRoot<Object> root = new SecurityExpressionRoot<>(() -> authentication, new Object()) {
};
root.setAuthorizationManagerFactory(this.authorizationManagerFactory);
root.setPermissionEvaluator(this.permissionEvaluator);
if (!DEFAULT_ROLE_PREFIX.equals(this.defaultRolePrefix)) {
// Ensure SecurityExpressionRoot can strip the custom role prefix
root.setDefaultRolePrefix(this.defaultRolePrefix);
}
return root;
}getRootObject创建SecurityExpressionRoot,并设置授权工厂与PermissionEvaluator;无显式authentication时从当前holder读取。该扩展的默认PermissionEvaluator是DenyAllPermissionEvaluator,所以查询里再写hasPermission并不会自动复用第五节ACL;需要显式设置相同evaluator。扩展只暴露表达式上下文,不安装SQL行级安全,也不重新验证JWT。
可维护的另一方案是服务层生成只读TrustedActor,再向Repository显式传trustedTenant与trustedOwner,SQL如上一节。两种方案都必须使Page的countQuery与数据查询同边界,并为排序/关联连接检查泄露;若自定义countQuery漏tenant,总数仍可能暴露。负例设计:t1/t2各建两条、同一owner,t1请求分页size=1,预期只见t1且totalElements=2;再直接调用无边界findAll应由架构约束阻止;批量更新只允许t1自身记录,检查影响行数。恢复时修正数据/count/update所有路径,再复验t2自己的正常访问,而不是只把查询全部改成空结果。该配置与测试是未运行推导,Data/JPA持久化机制另归其主教材。
九、WebSocket按消息授权
WebSocket握手后的连接会复用HTTP认证主体,握手成功不能授权所有SEND/SUBSCRIBE。对基于session的浏览器STOMP连接,固定7.0.8的@EnableWebSocketSecurity配置应与消息broker配置一起提供AuthorizationManager<Message<?>>;默认入站CONNECT的CSRF检查仍有意义,因为浏览器WebSocket不提供与普通同源读取完全相同的保证。允许Origin与CONNECT CSRF分别负责来源和请求证明,不能互换。
下面是面向已完成HTTP登录的session/STOMP应用的具体规则片段,需配合spring-websocket、STOMP端点、broker与HTTP登录配置;不是本Bearer lab的已运行配置:
@Configuration
@EnableWebSocketSecurity
class MessageSecurity {
@Bean
AuthorizationManager<Message<?>> messageAuthorizationManager(
MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages.nullDestMatcher().authenticated()
.simpMessageDestMatchers("/app/orders/**").hasAuthority("SCOPE_orders.write")
.simpSubscribeDestMatchers("/user/queue/orders").hasAuthority("SCOPE_orders.read")
.simpTypeMatchers(SimpMessageType.MESSAGE, SimpMessageType.SUBSCRIBE).denyAll()
.anyMessage().denyAll();
return messages.build();
}
}固定源码:WebSocketMessageBrokerSecurityConfiguration.java,Security 7.0.8,连续98–113行,SHA-256为整文件摘要。
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
ChannelInterceptor csrfChannelInterceptor = getBeanOrNull(CSRF_CHANNEL_INTERCEPTOR_BEAN_NAME,
ChannelInterceptor.class);
if (csrfChannelInterceptor != null) {
this.csrfChannelInterceptor = csrfChannelInterceptor;
}
AuthorizationManager<Message<?>> manager = this.authorizationManager;
manager = this.postProcessor.postProcess(manager);
AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(manager);
interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(this.context));
interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
this.securityContextChannelInterceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
registration.interceptors(this.securityContextChannelInterceptor, this.csrfChannelInterceptor, interceptor);
}这里明确是configureClientInboundChannel,依次注册context、CSRF、authorization拦截器。这个顺序使身份和CONNECT证明先于消息权限判定。配置不能证明outbound已逐接收者过滤。规则片段所需导入为 org.springframework.messaging.Message、org.springframework.messaging.simp.SimpMessageType、org.springframework.security.authorization.AuthorizationManager、org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager 与 org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity,以及@Configuration/@Bean。
规则顺序明确禁止客户端直接SEND到broker目标或订阅任意topic,避免伪造“系统广播”。nullDestMatcher().authenticated()使CONNECT等无destination消息先满足身份条件;内建CSRF拦截器仍单独验证CONNECT,客户端需按实际配置把CSRF token放入STOMP CONNECT native header。若采用header token认证,需显式在授权前建立消息身份,不能仅把字符串放进CONNECT就假定框架会运行HTTP Bearer过滤器。
此规则仅给动作能力,/app/orders/{id}的handler仍必须检查可信tenant和对象归属;客户端payload中的tenant不能替换simpUser。Spring Security主要保护clientInboundChannel,不会为每一条outbound消息自动执行等价的租户授权。共享topic会把一人的数据推给其他合法订阅者,发送端必须路由到服务端计算的用户/租户目标,按实际语义核查每个接收者。长连接中的身份也可能是握手快照;敏感SEND执行时复核撤销,必要时主动断开连接。
未运行的验证矩阵应包括:无/错CONNECT CSRF被拒绝;合法登录+CSRF连接成功;读者SEND拒绝/写者SEND允许;任意topic订阅拒绝;跨租户payload被handler拒绝;合法t2用户仍能收到自己的消息;撤销后旧连接不再执行敏感操作。不能用握手HTTP101替代这些消息断言。
十、撤销窗口与容量选择
以下数字是教学模型,不是本lab压测。假设峰值2000次请求/秒,每次一次权限判定,远程判定平均20毫秒,授权调用总在途量按Little定律约为 2000次/秒 × 0.020秒 = 40次。若为每个业务请求增加一次判定,授权服务需承载2000次/秒,不能只按资源服务器CPU扩容。网络抖动到200毫秒时,在途升到400次;连接池、超时与有界队列必须一起预算。
方案A是每次关键操作到权威授权库读当前权限,并在同一业务事务中用tenant/owner/version条件写。代价是网络/数据库负载与依赖可用性,收益是可对提交边界定义清晰不变量。对转账、分享撤销、跨租户导出等高风险操作优先;授权库故障时拒绝或把可重试任务留在队列,由授权服务值班者恢复服务,业务worker按幂等键重试,不能放行。
方案B是本地权限缓存或短期委托,假设10000个活跃主体、每条2KiB,则每节点payload约 10000 × 2KiB ≈ 19.5MiB,尚不含key、索引和对象开销。若每个活跃主体每30秒最多一次TTL回源,在均匀满活跃模型中约333次/秒;实际受请求分布、同时过期、节点数和cache miss影响,不保证达到该值。若授权方发布撤销消息目标传播2秒、TTL为30秒,则2秒只是健康路径目标;消息丢失时在“TTL硬失效、禁止stale续期、权威状态已更新、时钟/请求耗时另有界”的条件下,旧允许决定还可能存活接近30秒。若选择故障时无限stale,撤销上界就消失。
后台导出还要计排队:等待60秒、执行120秒的任务,在开工时检查一次不能保证最后输出仍有权。可以选择开工和输出前都复核的“用户当前权限”模型,或签发目标集合固定、有效期60秒的受限委托并确保超期不输出。后者使某些已接受任务在用户普通权限变化后仍被允许,必须作为产品契约明确授权,不能偷偷称为实时撤销。超过安全预算时由任务平台取消/重新授权,生成的临时文件由导出服务回收。
演进触发要用单位:当业务p95新增授权耗时超过30毫秒、判定错误率超过0.1%持续5分钟,先查看依赖与缓存命中分层证据,再决定扩容或精确缓存低风险读;当权限陈旧允许超过约定2秒健康目标,应检查消息延迟与失效重试,高风险写退回权威检查。这些阈值是示例SLO,必须以真实请求、缓存失效、并发撤销/写入和故障恢复实验重新标定。
十一、真实证据与扩展复核入口
复现实跑:准备JDK21/Maven3.9/Python3.9+,按lab README启动;只复现本文核心可从仓库根执行 JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/oauth2-authorization/run.py --case authorization --label authorization-local。macOS之外改为显式JDK21路径。脚本生成独有cache产物、随机loopback端口并只清理自己创建的进程;单组不覆盖完整验收JSON。不要手工复用示例token或修改共享证据。
| 真实输入 | 最终观察 | 判定边界 |
|---|---|---|
| 签名合法但缺读scope | /jwt/read 403 | 认证成功不替代请求授权 |
| 读者请求admin | 403 | scope精确区分 |
| authenticated入口调用write,无write/有write | 403 / 200 | 经Spring代理的方法规则 |
| t1/alice读own,参数t1 | 200 | 正常对象/租户归属 |
| t1/alice读other或foreign | 403 | 同租户他人、跨租户对象均拒绝 |
| t1/alice把tenant参数伪造成t2 | 403 | 不能由请求参数建立租户身份 |
| 可信t2/alice访问自己的foreign/t2 | 200 | 负例不是因为系统一概禁止t2 |
| 缺tenant的签名token | 401 | 本工程认证策略显式拒绝 |
| 下一请求没有Bearer | 401 | 不继承前一请求身份 |
完整证据还包含OAuth生命周期与故障恢复,但ACL/后台/Data/WebSocket尚未运行,必须按各节给出的前提、负例、恢复正常例另建实验。Mock JWT或手工SecurityContext可验证表达式布线,不能证明真实验签、issuer信任或协议撤销。排错日志只保留脱敏场景、HTTP状态和规则结果;不得打印完整token、Cookie、私钥或参数中的敏感对象。
闭卷验收
本页 15 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。