Spring Method Security 如何实现?RBAC 应如何设计?
简化版
Method Security(方法级安全) 通过 AOP 代理在方法调用前后做权限判断,常用 @PreAuthorize(方法执行前判断,写 SpEL 表达式)保护服务层的业务方法——补足 URL 授权表达不了的细粒度规则。RBAC(基于角色的访问控制) 应把用户、角色、权限三者分开建模:用户拥有角色、角色拥有权限、权限对应资源动作,代码尽量判断「权限点」而非硬编码「角色名」。注意:Method Security 依赖代理,同类内部自调用会绕过拦截;且数据级权限(只能看自己的数据)不能只靠注解,要进业务查询条件。
详细版
Method Security 用法:
@EnableMethodSecurity // 开启方法级安全(Spring Security 6)
public class SecurityConfig { }
@Service
public class OrderService {
@PreAuthorize("hasAuthority('order:refund')") // 方法执行前判断
public void refund(Long orderId) { ... }
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public User getUser(Long userId) { ... } // 管理员 或 查自己
@PreAuthorize("@auth.canReadOrder(#orderId)") // 复杂逻辑封装成 Bean
public Order getOrder(Long orderId) { ... }
}
常用注解:
| 注解 | 时机 | 说明 |
|---|---|---|
@PreAuthorize | 方法执行前 | 最常用,SpEL 表达式 |
@PostAuthorize | 方法返回后 | 可判断返回值,但业务已执行,慎用 |
@PreFilter/@PostFilter | 前/后 | 过滤集合参数/返回值 |
RBAC 模型:
用户(User) ──多对多── 角色(Role) ──多对多── 权限(Permission)
└─ 对应资源动作:order:refund
完整版教学
一、为什么需要方法级安全
URL 授权只能保护「入口路径」(/admin/**),但一个业务方法可能被多个入口调用——多个 Controller、消息消费者、定时任务、其他服务方法都可能调 orderService.refund()。
如果只在 URL 层做授权,就有漏洞风险:某个 Controller 忘了配 URL 规则、或者方法被非 Web 入口(消息、定时任务)调用,权限检查就绕过了。把关键权限放在服务层(方法级),就让业务能力本身具备保护——不管从哪个入口调用,方法执行前都会检查权限。这是纵深防御:URL 层是第一道、方法层是第二道。
| 授权位置 | 保护对象 | 优点 | 典型盲区 |
|---|---|---|---|
| URL 授权 | Web 入口路径 | 粗粒度、统一入口控制 | 非 Web 入口或漏配路径 |
| 方法授权 | 服务层业务能力 | 多入口复用时仍能兜底 | AOP 自调用、数据范围 |
| 数据授权 | 具体记录和查询结果 | 能防跨租户/跨用户 | 必须进入查询条件或业务校验 |
方法级安全的心法是“保护业务能力本身”,而不是只保护某个 URL。
二、Method Security 的实现方式(AOP 代理)
Spring Method Security 基于 AOP 代理:Spring 为标注了安全注解的 Bean 生成代理,代理在方法调用前后拦截——读取注解和 SpEL 表达式,结合当前 Authentication 做权限决策,通过才放行调用。
@PreAuthorize:方法执行前判断。不满足直接抛AccessDeniedException(不执行方法)。最常用、最推荐。@PostAuthorize:方法返回后判断(可以基于返回值判断,如「只能看自己创建的订单」)。但要谨慎——此时业务方法已经执行了(如果方法有副作用,即使最后拒绝,副作用已发生)。适合纯查询、无副作用的场景。
三、SpEL 表达式里能做什么
@PreAuthorize 的表达式是 SpEL,能力很强:
hasRole('ADMIN')/hasAuthority('order:refund'):判断角色/权限。- 访问方法参数:
#orderId、#userId(配合@PreAuthorize引用参数)。 - 访问当前用户:
authentication.principal。 - 组合逻辑:
hasRole('ADMIN') or #userId == authentication.principal.id(管理员或查自己)。 - 调用自定义 Bean:
@auth.canReadOrder(#orderId)。
建议:复杂权限逻辑封装成方法/Bean,如 @PreAuthorize("@authService.canRefund(#orderId)")——不要在注解里塞满业务细节(注解里写一长串 SpEL 难读、难测、难维护),把逻辑放进可测试的 Java 方法里。
Controller A ┐
Controller B ├─> OrderService.refund() -> @PreAuthorize("hasAuthority('order:refund')")
MQ Consumer ┘
四、RBAC 的基本模型
RBAC(Role-Based Access Control) 的核心是用「角色」作为「用户」和「权限」之间的桥梁,三者分开:
用户 ── 拥有 ──> 角色 ── 拥有 ──> 权限(对应资源动作)
- 用户不直接绑定大量权限,而是通过角色获得权限。
- 角色表达岗位/职责(管理员、财务、客服)。
- 权限表达具体动作(
order:read、order:refund)。
好处:调整权限时只改「角色-权限」关系,不用改每个用户、也不用改代码。比如「财务角色也要退款权限」,只需给财务角色加 order:refund 权限,所有财务用户自动获得——不用逐个改用户、不用改代码。
五、角色和权限的命名与代码判断
- 角色(如
ROLE_ADMIN)偏「身份集合」。 - 权限(如
order:read、order:refund)偏「能力点」。
代码层应优先判断「权限点」而非「角色名」:
// ❌ 硬编码角色:新增「财务」角色也要退款时,得改代码
@PreAuthorize("hasRole('ADMIN')")
// ✅ 判断权限点:谁有这个权限谁能退款,代码不用动
@PreAuthorize("hasAuthority('order:refund')")
原因:如果代码到处写 hasRole('ADMIN'),将来「财务角色也应该能退款」时,要改所有相关代码(把 ADMIN 改成 ADMIN or FINANCE)。而写 hasAuthority('order:refund'),只要把权限点分配给财务角色即可,代码零改动。角色管「分给谁」,权限点管「能做什么」——代码依赖权限点更稳定灵活。
六、数据权限不是简单 RBAC(重点)
RBAC 解决「能不能做某类操作」,但解决不了「能对哪些数据做」。举例:
用户 A 和用户 B 都有
order:read权限,但 A 只能看自己部门的订单,B 只能看自己创建的订单。
这种数据级权限(数据范围)——按部门、租户、创建人、区域限制能访问的数据——不能只靠方法注解表达。因为它依赖具体数据的归属,必须进入业务查询条件:
// 数据权限:查询时加上数据范围条件
List<Order> orders = orderMapper.selectByDept(currentUser.getDeptId());
// 或在 @PostAuthorize / 业务里校验:这条订单属不属于当前用户
只靠 @PreAuthorize 很难完整表达数据隔离——RBAC 管「操作权限」,数据权限要靠查询条件 + 业务校验(更复杂的还要引入 ABAC 基于属性的访问控制)。这是越权漏洞的重灾区。
七、代理边界和测试
- 代理边界(易错):Method Security 依赖 AOP 代理,所以同一个类内部的
this.方法()自调用会绕过代理——注解不生效!(和 Spring 事务@Transactional自调用失效同理)。如果一个方法内部调用了本类的另一个@PreAuthorize方法,那个安全检查不会触发。解决:拆到不同 Bean,或通过代理调用。 - 测试要覆盖反向场景:授权成功、权限不足(403)、跨用户访问、以及绕过 Web 入口直接调服务方法——确保方法级安全真的拦住了越权,而不只是测「有权限的能调」。
八、常见误区与追问
- 误区:URL 授权已经配置了,方法级安全就没必要。 关键服务方法可能被多个入口调用,方法级安全能在业务能力层形成第二道防线。
- 误区:
@PostAuthorize和@PreAuthorize安全效果一样。@PostAuthorize在方法执行后判断,有副作用的方法已经执行,通常只适合查询类场景。 - 误区:RBAC 可以完整解决数据权限。 RBAC 解决能不能做某类操作,不能自动解决能操作哪些租户、部门或用户的数据。
- 追问:为什么同类内部自调用会让注解失效? Method Security 依赖 AOP 代理,
this.xxx()不经过代理,自然不会触发安全拦截。 - 追问:为什么代码更推荐判断权限点? 权限点表达稳定业务能力,新增角色时只改配置关系,不改业务代码。
- 追问:复杂 SpEL 应该怎么写更可维护? 把复杂判断封装到 Bean 方法里,例如
@auth.canReadOrder(#orderId),便于测试和复用。
九、加强记忆
Method Security 用 AOP 代理在方法前后做权限判断,@PreAuthorize(执行前,SpEL 表达式)最常用(@PostAuthorize 方法已执行慎用),保护服务层业务方法——补足 URL 授权(URL 只管入口,方法被多入口调用时靠方法级兜底,纵深防御)。复杂逻辑封装成 Bean(@auth.canX()),别在注解塞业务细节。RBAC:用户→角色→权限三层,用户通过角色获得权限,调权限只改「角色-权限」关系不改代码;代码判断「权限点(order:refund)」而非「角色名(ADMIN)」(新角色要该权限时代码零改动)。数据权限(只能看自己/本部门数据)不是简单 RBAC,要进业务查询条件 + 校验(越权重灾区)。坑:同类内部自调用绕过代理注解失效(同 @Transactional);测试覆盖权限不足/跨用户/绕过入口等反向场景。