权限控制要解决的核心问题是:谁(认证后的用户)能对什么资源(URL、方法、数据)执行什么操作。Spring Security 提供了从粗粒度到细粒度的三层权限模型,能够覆盖绝大部分企业应用的授权需求:
- URL 级权限:控制用户能否访问某个接口路径,粒度到 HTTP 方法 + URL 模式。
- 方法级权限:控制用户能否调用某个 Service 方法,粒度到业务操作。
- 数据权限:控制用户能看到或操作哪些数据行,粒度到具体的记录。
这三层权限并非互斥,而是自上而下逐级收窄。实际项目中通常会组合使用,用 URL 权限做第一道防线,方法权限保证业务安全,数据权限实现“同角色不同数据范围”的隔离。
15.4.1 URL 级权限:基于请求路径的访问控制
URL 级权限是最直观、配置最简单的授权方式。通过在 SecurityFilterChain 中定义匹配规则,可以精确控制哪些角色或权限能够访问哪些接口。
1. 基本配置方式
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
// 静态资源、登录页等公开访问
.requestMatchers("/css/**", "/js/**", "/login", "/public/**").permitAll()
// 特定接口仅管理员可访问
.requestMatchers("/admin/**").hasRole("ADMIN")
// 订单相关接口需要"订单管理"权限
.requestMatchers("/orders/**").hasAuthority("ORDER_MANAGE")
// 用户自身信息接口,需要登录即可
.requestMatchers("/user/profile").authenticated()
// 其他所有请求都需认证
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
Spring Security 的匹配规则遵循 “最先匹配优先” 原则,必须将更具体的路径放在前面,通配规则放在后面。
2. 常用匹配器与权限表达式
permitAll()— 任何人(含匿名用户)可访问。denyAll()— 禁止所有人访问。authenticated()— 通过认证即可,不检查角色。hasRole("ADMIN")— 要求用户拥有ROLE_ADMIN。hasAuthority("ORDER_READ")— 要求用户拥有ORDER_READ权限。hasAnyRole("ADMIN", "OPERATOR")/hasAnyAuthority(...)— 满足任一即可。
可以使用 requestMatchers 支持 Ant 风格的通配符(? 匹配单字符, 匹配一层路径,* 匹配多层路径),也可以配合 HttpMethod 进行更精细的约束:
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAuthority("USER_READ")
.requestMatchers(HttpMethod.POST, "/api/users/**").hasAuthority("USER_CREATE")
.requestMatchers(HttpMethod.PUT, "/api/users/**").hasAuthority("USER_UPDATE")
.requestMatchers(HttpMethod.DELETE, "/api/users/**").hasAuthority("USER_DELETE")
这样就做到了同一个 /api/users 路径下,不同 HTTP 方法对应不同权限的 RESTful 风格控制。
3. 动态 URL 权限
对于规则繁多或需要运行时配置的系统(例如后台管理系统的菜单权限),可以在数据库中存储 URL 与权限的映射关系,然后自定义一个 Filter 或通过实现 AuthorizationManager 来动态判断。Spring Security 5.7+ 推荐使用 AuthorizationManager<RequestAuthorizationContext> 接口,配合自定义的权限加载器实现动态规则。
15.4.2 方法级权限:细粒度的业务操作控制
当权限控制的粒度需要比“能否访问某个接口”更精细时,比如“普通用户只能查看自己的订单,管理员可以查看所有订单”,URL 权限就不够用了。这时需要深入到 Service 层,对方法调用进行拦截——这就是方法级权限。
1. 开启方法安全
首先在任意配置类上标注 @EnableMethodSecurity(Spring Security 6 推荐写法,取代旧版的 @EnableGlobalMethodSecurity):
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
开启后,可以使用 @PreAuthorize、@PostAuthorize、@Secured、@RolesAllowed 等注解。
2. 常用注解与 SpEL 表达式
@PreAuthorize:在方法执行前验证权限,支持 Spring 表达式(SpEL),可以使用方法参数。
@Service
public class OrderService {
// 仅允许管理员或订单所属用户查看
@PreAuthorize("hasRole('ADMIN') or #orderId == authentication.principal.userId")
public Order getOrderDetail(Long orderId) {
// ...
}
// 参数为对象时,访问其属性
@PreAuthorize("hasAuthority('ORDER_UPDATE') and #order.userId == authentication.principal.userId")
public void updateOrder(Order order) {
// ...
}
}
@PostAuthorize:在方法执行后验证,可以用在需要对返回结果做权限判断的场景,例如:
@PostAuthorize("returnObject.userId == authentication.principal.userId")
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
如果返回对象的 userId 与当前登录用户不符,Spring Security 会拦截返回并抛出 AccessDeniedException。
@Secured和@RolesAllowed:不支持 SpEL,只能做简单的角色判断,例如@Secured("ROLE_ADMIN")。因功能较弱,实际项目中@PreAuthorize使用更广泛。
3. 自定义权限校验逻辑
当权限逻辑复杂时,可以将判断提取到独立的组件中,利用 SpEL 的 @beanName.method() 语法引用 Spring Bean:
@Component("authz")
public class AuthorizationLogic {
public boolean isOrderOwner(Long orderId) {
// 从 SecurityContext 获取当前用户,查询数据库判断
// ...
}
}
// 在方法上使用
@PreAuthorize("@authz.isOrderOwner(#orderId)")
public Order cancelOrder(Long orderId) {
// ...
}
这种方式可以让权限逻辑集中管理,更易于测试和维护。
15.4.3 数据权限:按规则过滤用户可见的数据行
数据权限是授权体系的最后一公里,也是最贴近业务的部分。它要解决的问题是:同一个查询接口,不同用户看到的数据集不同。比如“部门经理只能看到本部门下的订单”,“华北区销售只能看华北区的客户”。URL 权限和方法权限只决定了“能不能调这个方法”,数据权限则决定了“调用后能返回哪些数据”。
常见的实现方案有三种:
方案一:业务代码中手动拼接查询条件
这是最直接、最灵活的方式。在 Service 或 DAO 层,根据当前用户的角色或属性,手动追加过滤条件。
@Service
public class CustomerService {
@Autowired
private CustomerMapper customerMapper;
public List<Customer> listCustomers(String keyword) {
Integer currentUserId = SecurityUtils.getCurrentUserId();
// 获取当前用户可查看的部门ID集合
List<Integer> deptIds = permissionService.getUserDataScopeDeptIds(currentUserId);
// 构造查询条件
return customerMapper.selectByKeywordAndDeptIds(keyword, deptIds);
}
}
MyBatis 中动态拼接条件:
<select id="selectByKeywordAndDeptIds" resultType="Customer">
SELECT * FROM customer
WHERE 1 = 1
<if test="keyword != null and keyword != ''">
AND name LIKE CONCAT('%', #{keyword}, '%')
</if>
<if test="deptIds != null and deptIds.size() > 0">
AND dept_id IN
<foreach collection="deptIds" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</if>
</select>
这种方式清晰可控,没有黑魔法,推荐在团队中采用,尤其是在业务规则复杂的场景。
方案二:基于 AOP + 自定义注解的数据过滤
当项目中很多查询都需要做数据权限拦截时,手动拼接会让代码显得臃肿。可以通过 AOP 抽取通用的过滤逻辑。步骤如下:
- 定义数据权限注解
@DataScope,包含需要的参数:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
String deptIdColumn() default "dept_id"; // 表中部门列名
String userColumn() default ""; // 表中用户列名(如 creator_id)
}
- 在切面中解析注解,获取当前用户的数据权限范围,并动态修改查询参数。例如对于 MyBatis Plus,可以在参数实体中加入自定义的 SQL 条件,或者使用拦截器重写 SQL(风险较高,不推荐轻易尝试)。更稳妥的方式是在 Mapper 方法接口层面提供支持,比如在传入参数中强制传入一个
dataScope参数,由切面统一填充。
- Service 方法只需标注注解:
@DataScope(deptIdColumn = "dept_id")
public List<Order> queryOrders(OrderQueryParam param) {
return orderMapper.selectByQuery(param);
}
这种方法适合所有查询类方法的数据权限逻辑一致、且系统规模较大的场景。但需要注意,切面实现不要太“智能”,否则会引入隐蔽的副作用,增加排查难度。
方案三:使用数据库行级安全或框架级拦截(较少采用)
有些团队尝试通过 MyBatis 拦截器或 JPA 的 Filter 功能,在底层自动改写 SQL,增加 WHERE 条件。例如 Hibernate 的 @Filter 注解:
@Entity
@FilterDef(name = "deptFilter", parameters = @ParamDef(name = "deptIds", type = Integer.class))
@Filter(name = "deptFilter", condition = "dept_id IN (:deptIds)")
public class Customer {
// ...
}
使用时在 Session 上启用过滤器:
session.enableFilter("deptFilter").setParameterList("deptIds", currentUserDeptIds);
这种方式的优点是业务代码完全无感,缺点是框架耦合度较高,且需要特别注意查询性能(自动拼接可能无法利用索引)。更严重的是,一旦忘记启用过滤器,就会造成严重的越权漏洞。因此在实际项目中使用相对谨慎。
推荐的做法是: 普通项目首选 方案一,简单直接,易于调试。当查询接口数量暴增、重复代码过多时,逐步抽象为 方案二。方案三通常只在特定持久层深度集成的情况下采用。
15.4.4 三层权限的协同应用
在实际系统中,三层权限常常叠加使用,形成纵深防御。以一个典型的订单管理系统为例:
- URL 权限:确保只有具有“订单模块”权限的角色(如销售、销售主管、管理员)才能访问
/orders/**。 - 方法权限:保护核心业务方法,例如
deleteOrder(Long id)方法加上@PreAuthorize("hasRole('ADMIN') or @authz.isOrderOwner(#id)"),防止普通销售人员误删或恶意删除他人订单。 - 数据权限:在列表查询时,根据用户所属部门、职级等,过滤销售只能看到自己创建的订单,销售主管看到本部门的全部订单,管理员看到全公司订单。这通过查询时动态拼接
WHERE条件或 AOP 来统一实现。
最终,三层权限各司其职:URL 权限过滤掉未授权的外部访问,方法权限防止已认证用户进行未授权的操作,数据权限确保返回的数据集符合用户的可视范围。三者结合,才能构筑一套严密的授权体系。
在实际开发中,权限设计必须与业务方深入对齐,明确各种角色对资源的操作矩阵,同时在代码审查和测试环节中将权限作为重要验收点。不遗留“看似无效但实际能越权”的接口,是开发者对系统安全最基本也最重要的责任。