本文目录导读:

- 方案一:AOP(面向切面编程)或中间件拦截(最推荐)
- 方案二:数据层强制注入(防漏网之鱼)
- 方案三:Token/签名校验(适用于无状态API)
- 方案四:统一基础控制器(BaseController)模式
- 最容易忽视的3个漏洞点
- 生产环境最佳实践
针对PHP项目的水平越权(Horizontal Privilege Escalation)问题,核心控制策略是在所有涉及资源访问的业务逻辑层进行归属权校验,确保当前用户只能操作属于自己的数据。
以下是几种标准的代码拦截与控制实践方案,按推荐程度排序:
AOP(面向切面编程)或中间件拦截(最推荐)
这是最优雅、入侵性最低的方法,通过框架的中间件或自定义注解,在控制器执行前自动完成权限校验。
实现原理:
定义规则:当前用户ID 必须等于 资源所属用户ID。
示例代码(ThinkPHP/Laravel 风格):
<?php
// 1. 定义一个资源所有者检查中间件或函数
function checkOwnership($resourceType, $resourceId) {
$currentUserId = auth()->id(); // 当前登录用户ID
$ownerId = getResourceOwnerId($resourceType, $resourceId); // 查询该资源属于谁
if ($currentUserId !== $ownerId) {
// 拦截:抛出异常或返回403
throw new \App\Exceptions\UnauthorizedException('您无权访问该资源');
}
}
// 2. 在控制器中调用
public function editOrder($orderId) {
// 执行检查(这一步可放在BaseController的__construct或构造方法中自动执行)
checkOwnership('order', $orderId);
// ... 后续业务逻辑
}
框架集成(以 Laravel 为例):
- 创建 Policy (策略类):
php artisan make:policy OrderPolicy - 在
OrderPolicy中定义view、update等方法:public function view(User $user, Order $order) { return $user->id === $order->user_id; } - 在控制器中使用
$this->authorize('view', $order)即可自动拦截。
数据层强制注入(防漏网之鱼)
为了防止开发人员忘记调用校验函数,建议在SQL查询时直接强制绑定当前用户ID。
核心思想:
所有查询都包含 WHERE user_id = 当前用户ID,即使被绕过校验,也拿不到别人的数据。
<?php
// Repository或Model层
public function getMyOrder($orderId) {
$currentUserId = session('user_id');
return DB::table('orders')
->where('id', $orderId)
->where('user_id', $currentUserId) // 强制条件
->first();
}
// 控制器调用
$order = $this->orderRepository->getMyOrder($inputId);
if (!$order) {
// 要么不存在,要么不属于你,统一报404或403
abort(403);
}
Token/签名校验(适用于无状态API)
对于前后端分离或API接口,仅靠ID不够安全,需要结合数据签名。
原理:
客户端请求资源时,携带一个由用户ID + 资源ID + 密钥生成的签名,服务端验证签名,防止篡改。
<?php
// 服务端生成签名(仅示例,实际使用HMAC-SHA256)
function generateSignature($userId, $resourceId, $secretKey) {
return hash_hmac('sha256', $userId . '|' . $resourceId, $secretKey);
}
// 客户端请求时携带 signature
// POST /api/order/update?orderId=123&signature=xxxx
// 服务端验证
$sign = $request->input('signature');
$expectedSign = generateSignature(auth()->id(), $orderId, config('app.secret'));
if (!hash_equals($expectedSign, $sign)) {
abort(403, '签名验证失败,可能的水平越权攻击');
}
注意: 这种方式虽能防止用户修改orderId值为其他人的ID,但前端源码中会暴露签名生成逻辑,密钥不应暴露,实际项目中更建议使用JWT或OAuth中携带用户身份,结合方案一使用。
统一基础控制器(BaseController)模式
如果团队不使用中间件,最原始的防止遗漏的办法是:所有需要权限校验的方法,都调用父类的一个方法。
<?php
class BaseController {
protected function verifyAccess($model, $userIdField = 'user_id') {
$currentUserId = session('user_id');
$ownerId = $model->$userIdField;
if ((int)$currentUserId !== (int)$ownerId) {
$this->responseError('无权限', 403);
}
}
}
class OrderController extends BaseController {
public function detail($id) {
$order = OrderModel::find($id);
$this->verifyAccess($order); // 一行代码完成校验
// ... 业务逻辑
}
}
最容易忽视的3个漏洞点
-
ID类型/大小写不一致
$userId是int(1),但 SQL 中user_id是string("1"),用 比较通过,严格 可能失败,反之亦然。- 解决: 统一做
(int)强制转换比较。
-
批量操作/列表接口
- 虽然详情页校验了,但
/api/orders?page=2返回了所有用户的订单。 - 解决: 列表查询时,必须过滤
->where('user_id', $currentUserId)。
- 虽然详情页校验了,但
-
关联查询/软删除
- 通过查询别人的订单ID,然后利用关联的
Logs表查到了不属于自己的日志。 - 解决: 关联关系查询也要带
->where('user_id', $currentUserId)。
- 通过查询别人的订单ID,然后利用关联的
生产环境最佳实践
- 基础层: 在数据库查询层(Repository/Model)强制注入
user_id(方案二)。 - 拦截层: 使用框架中间件或Policy(方案一),拦截80%的越权行为。
- 审计层: 日志记录所有ID参数与
user_id的对应关系,出现异常告警。 - 兜底: 统一异常处理,返回
403或404(避免泄露“存在但无权”的信息,用404更安全)。
一句话口诀:
增删改查都要验,不查ID查自己;资源归属不对等,统一返回403。