人人都会AI编程

15.4 权限控制:URL 级权限、方法级权限、数据权限

更新时间:2026-07-10

权限控制要解决的核心问题是:谁(认证后的用户)能对什么资源(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 抽取通用的过滤逻辑。步骤如下:

  1. 定义数据权限注解 @DataScope,包含需要的参数:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    String deptIdColumn() default "dept_id";   // 表中部门列名
    String userColumn() default "";            // 表中用户列名(如 creator_id)
}
  1. 在切面中解析注解,获取当前用户的数据权限范围,并动态修改查询参数。例如对于 MyBatis Plus,可以在参数实体中加入自定义的 SQL 条件,或者使用拦截器重写 SQL(风险较高,不推荐轻易尝试)。更稳妥的方式是在 Mapper 方法接口层面提供支持,比如在传入参数中强制传入一个 dataScope 参数,由切面统一填充。
  1. 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 三层权限的协同应用

在实际系统中,三层权限常常叠加使用,形成纵深防御。以一个典型的订单管理系统为例:

  1. URL 权限:确保只有具有“订单模块”权限的角色(如销售、销售主管、管理员)才能访问 /orders/**
  2. 方法权限:保护核心业务方法,例如 deleteOrder(Long id) 方法加上 @PreAuthorize("hasRole('ADMIN') or @authz.isOrderOwner(#id)"),防止普通销售人员误删或恶意删除他人订单。
  3. 数据权限:在列表查询时,根据用户所属部门、职级等,过滤销售只能看到自己创建的订单,销售主管看到本部门的全部订单,管理员看到全公司订单。这通过查询时动态拼接 WHERE 条件或 AOP 来统一实现。

最终,三层权限各司其职:URL 权限过滤掉未授权的外部访问,方法权限防止已认证用户进行未授权的操作,数据权限确保返回的数据集符合用户的可视范围。三者结合,才能构筑一套严密的授权体系。

在实际开发中,权限设计必须与业务方深入对齐,明确各种角色对资源的操作矩阵,同时在代码审查和测试环节中将权限作为重要验收点。不遗留“看似无效但实际能越权”的接口,是开发者对系统安全最基本也最重要的责任。