权限校验应统一收口到中间件,而非Controller或验证器

权限校验该放在哪里?Controller 还是中间件?
把权限判断直接写在 Controller 方法里,大概是新手最容易踩的坑。看起来简单直接,但后果往往是重复的 Auth::check() 散落各处、角色名被硬编码、某个接口一不小心漏了校验,上线后分分钟出现权限绕过的安全漏洞。
真正稳健的方案,是统一收口到中间件。ThinkPHP 6+ 提供的「闭包中间件」或「类中间件」简直是为此场景量身定做。自定义一个权限中间件,核心目标就一个:明确分离关注点。请求经过路由后,先过权限这一关,不满足的直接 throw new HttpException(403) 或返回标准JSON错误。这样一来,Controller 就能彻底“躺平”,完全不用操心自己有没有权限执行,只专注于处理业务逻辑。
具体实现时,有几个细节需要把握:
- 在中间件里,通过
Auth::user()获取当前登录用户,然后查询其关联的角色或权限节点。这里有个性能小技巧:建议将用户的权限集合缓存在user_permissions字段或 Redis 中,避免频繁查库。 - 中间件里应极力避免直接进行数据库查询。权限数据最好在用户登录或更新权限时就预加载好。如果采用经典的RBAC模型,推荐通过预加载
roles.permissions关系来一次性获取所有权限,而不是每次校验都去关联查询三张表。 - 还有一个常见的误区:别依赖
session('admin_role')这类数据进行关键权限判断。Session 数据在理论上存在被伪造的风险。权限校验的基石必须是当前用户的真实ID,并以此为依据重新查询数据库或可靠的缓存。
自定义验证器怎么接权限字段?rule 里不能写业务逻辑
ThinkPHP 的 Validate 验证器,本质是负责数据格式与范围校验的,比如字段是否必填、是否是邮箱格式、数字是否在某个区间。它并不是一个权限闸门。所以,千万别试图在 rule 规则里塞入像 Auth::can('delete_user') 这样的业务逻辑判断——验证器执行时通常早于中间件,用户上下文可能还未初始化,Auth 类很可能根本不可用。
那么,如果业务上确实需要结合权限做字段级的控制(例如,普通管理员不允许修改 is_super 这个超级管理员标识字段),该怎么办?思路需要转换一下:
立即学习“PHP免费学习笔记(深入)”;
- 一种方法是在数据进入
Controller的正式处理逻辑前,先用input()助手函数获取原始参数,然后手动过滤掉当前用户无权修改的字段键名。例如使用array_filter($data, function($k) { return in_array($k, $allowedFields); }, ARRAY_FILTER_USE_KEY)。 - 另一种更优雅的方式是利用验证器的「场景」功能。为“编辑”操作定义
scene('edit'),并在调用验证前,根据当前用户的权限动态地设置该场景下的验证规则$validate->scene('edit')->rule([...])。 - 最后切记一点:验证器返回的错误信息
message里,不要暴露任何权限相关的细节。比如,不要直接提示“你不是超级管理员,不能修改此字段”,而应该统一返回“参数非法”或“字段校验失败”这类模糊但安全的提示。
Auth::can() 返回 false 就一定没权限?小心缓存和节点路径写错
Auth::can() 这个方法用起来顺手,但它的正确运行依赖于两个关键前提:权限节点已经正确注册,并且用户的权限缓存是实时的。很多时候,当它返回 false 时,90% 的问题出在配置或数据层面,而不是你的代码逻辑写错了。
下面这几个是典型的“翻车”现场:
- 节点标识符不匹配:代码里写的节点是
'admin/user/delete',但数据库里存储的却是'admin:user:delete',或者少了一个斜杠。ThinkPHP 默认使用/作为路径分隔符,一旦不匹配,自然就查不到权限。 - 用户上下文与缓存混淆:如果在代码中手动调用了
Auth::setUser($user)来切换用户,但忘记清空旧的权限缓存,那么后续的can()检查可能仍然读取的是上一个用户的权限结果。 - 缓存驱动不一致:在单机开发时用
File文件缓存可能没问题,但一旦部署到多台服务器,各机器间的文件缓存无法同步,权限状态就会混乱。生产环境务必切换到Redis或Memcached这类集中式缓存驱动。
遇到权限判断异常时,有个高效的调试方法:临时在代码里加入 dump(Auth::getPermissions());,直接打印出当前用户实际加载的所有权限节点列表。这比盲目猜测要快得多。
RBAC 权限表设计漏了「权限继承」会卡死后期迭代
很多项目在初期设计权限表时,只建立了 role(角色)、permission(权限)、role_permission(角色-权限关联)这三张表,觉得已经够用了。但随着业务发展,当需要引入“区域管理员”、“部门主管”、“审计员”等具有层级关系的新角色时,问题就来了:每个新角色都需要重新绑定几十甚至上百个基础权限,修改一个通用权限点,就得批量更新无数个角色关联。维护成本瞬间爆炸,迭代举步维艰。
一个真正具备可维护性的设计,必须支持角色的继承关系:
- 可以在
role表中增加一个pid(parent_id)字段,指向其父角色的ID。在查询某个角色的权限时,递归地查找并合并所有上级角色的权限(注意要做好防止循环引用的检测)。 - 或者,更清晰地,可以引入一张独立的
role_inherit中间表,显式地声明child_id → parent_id的继承关系。这样查询逻辑更清晰,关系也更直观。 - 最后是一个至关重要的原则:不要让前端直接传递
role_id来进行权限判断。角色只是一个权限的容器和分组概念,真正的校验依据必须是具体的“权限节点字符串”。否则,一旦未来角色策略发生变化,整个权限校验体系都可能陷入混乱。
说到底,权限系统最复杂的部分从来不是编写校验代码本身,而是如何将“谁、在什么范围内、能执行哪些操作”这套复杂的业务规则,用数据库里几张表、若干行记录清晰、灵活地定义出来。前期设计时少考虑一个字段,后期弥补的成本可能就是成倍增加。
