公司的安全团队做了一次渗透测试,发现一个严重漏洞。订单详情接口
GET /api/order/detail?orderId=1001没有校验当前用户是否拥有这个订单——把 orderId 改成 1002,就能看到别人的订单。测试人员用脚本遍历了 orderId 从 1 到 50000,抓取了所有订单数据——包括收货地址、手机号、购买记录。
这就是 IDOR(Insecure Direct Object Reference),中文叫"水平越权"。代码里只校验了用户有没有登录,没校验"当前用户是不是这个数据的主人"。同一个权限级别的用户之间,通过篡改 ID 就能看到对方的数据。
今天聊聊怎么用 AOP 在 Controller 层统一做归属校验——不用在每个接口里手写 if (userId != order.getUserId()),一个注解搞定。
问题出在哪
最典型的漏洞代码:
@GetMapping("/api/order/detail")
public Order detail(@RequestParam Long orderId) {
Long userId = getCurrentUserId(); // 获取了当前用户,但没校验它跟 orderId 的关系
return orderService.findById(orderId); // ❌ 直接返回,不检查归属
}
正确的做法应该是:
public Order detail(@RequestParam Long orderId) {
Long userId = getCurrentUserId();
Order order = orderService.findById(orderId);
if (order == null || !order.getUserId().equals(userId)) {
throw new ForbiddenException("无权访问"); // ✅ 归属校验
}
return order;
}
但如果系统有 100 个接口,每个都写一遍这个校验——迟早有一个漏掉。
AOP 统一拦截:注解声明,自动校验
声明一个注解 @DataOwner,标在需要校验归属的参数上:
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataOwner {
/** 数据所属的用户 ID 字段名 */
String field() default "userId";
/** 数据对应的实体类 */
Class<?> entity();
}
AOP 切面自动拦截:
@Aspect
@Component
public class DataOwnerAspect {
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PostMapping)")
public Object checkOwnership(ProceedingJoinPoint pjp) throws Throwable {
Long currentUserId = getCurrentUserId();
// 遍历方法参数,找到标了 @DataOwner 的参数
for (int i = 0; i < pjp.getArgs().length; i++) {
MethodParameter param = getParameter(pjp, i);
DataOwner annotation = param.getParameterAnnotation(DataOwner.class);
if (annotation == null) continue;
// 获取传入的 ID
Long resourceId = (Long) pjp.getArgs()[i];
// 查询数据 → 检查归属
Object entity = findById(annotation.entity(), resourceId);
Long ownerId = getFieldValue(entity, annotation.field());
if (ownerId == null || !ownerId.equals(currentUserId)) {
throw new ForbiddenException("无权访问此资源");
}
}
return pjp.proceed();
}
}
业务代码变成这样:
@GetMapping("/api/order/detail")
public Order detail(@RequestParam @DataOwner(entity = Order.class) Long orderId) {
return orderService.findById(orderId); // AOP 已自动校验归属
}
一个 @DataOwner 注解,AOP 自动做了四件事:拿到当前用户 ID → 查出数据 → 比对归属 → 不匹配直接抛异常。业务代码里一行校验都没写,但安全性跟手写的一样。
三种典型场景
场景一:主键 ID 参数(最常见的 IDOR)
// AOP 自动校验 orderId 对应的 Order.userId 是否等于当前用户
@GetMapping("/order/{orderId}")
public Order detail(@PathVariable @DataOwner(entity = Order.class) Long orderId) {
return orderService.findById(orderId);
}
场景二:请求体中的 ID 字段
@PostMapping("/order/cancel")
public Result cancel(@RequestBody @DataOwner(entity = Order.class) CancelRequest req) {
// AOP 从 req.orderId 找到 Order,校验归属
return orderService.cancel(req);
}
场景三:批量操作
@PostMapping("/order/batch-delete")
public Result batchDelete(@RequestBody @DataOwner(entity = Order.class) BatchDeleteRequest req) {
// AOP 对 req.orderIds 中的每一个 ID 做归属校验
return orderService.batchDelete(req.getOrderIds());
}
性能优化:归属校验不重复查 DB
AOP 每次都要查一次数据库做归属校验,等于每次请求多了一次 SELECT。如果业务代码里本来就要查这个数据,就会重复查询。
优化的方式:AOP 把查到的数据放进请求上下文,业务代码直接从上下文拿,不再查数据库。
// AOP 校验归属后,把 entity 放进 request attribute
request.setAttribute("currentOrder", order);
// 业务代码不用再查
@GetMapping("/order/detail")
public Order detail(@RequestParam @DataOwner(entity = Order.class) Long orderId) {
Order order = (Order) request.getAttribute("currentOrder");
return order; // 直接从上下文取,零额外查询
}
这样归属校验和业务查询合并成了一次 DB 操作。安全性不妥协,性能也没额外代价。
总结
IDOR 是最容易被忽视的安全漏洞之一——它不需要复杂的攻击手段,只需要改一下 URL 里的数字。
AOP 统一拦截三步:声明 @DataOwner 注解 → AOP 切入 Controller 方法 → 自动查出数据、校验归属、不匹配抛异常。安全团队再测你的系统,遍历 orderId 只会看到一样的 403。
