ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RBAC权限系统实战:从权限字符串到权限校验

RBAC权限系统实战:从权限字符串到权限校验 我最早接触RBAC权限系统的时候一直想不通一个问题权限到底在代码里长什么样后来被前辈点了一句才豁然开朗——本质上角色和权限在代码里的体现就是一个字符串。再进一步说权限校验的很多场景就是判断当前用户的会话session里有没有user:add这样的字符串。这个认知一旦建立整个权限系统就从高大上的架构设计变成了可以上手写代码的具体功能。本文想把这套东西从底层逻辑到落地实现完整梳理一遍为什么权限可以退化成字符串、RBAC里的角色到底解决了什么问题、数据库表怎么设计、登录时怎么把权限装进session、请求进来后怎么拦截校验以及我在实际项目中踩过的坑。适合刚接触权限系统的后端开发、自己搭小系统的全栈工程师以及想搞懂权限控制原理的前端同学。1. 先搞懂RBAC的底层思维权限往小了说就是一个字符串1.1 user:add这一串字符到底在表达什么很多新手第一次看到user:add这种写法会觉得它像某种神秘编码。其实拆开看非常直白冒号前面是资源冒号后面是操作。user代表用户这个资源模块add代表新增这个动作合起来就是允许新增用户。换成order:export就是允许导出订单article:delete就是允许删除文章。在代码层面权限判断最后基本都会落到集合操作上。把当前用户拥有的所有权限字符串放进一个数组或者Set然后每次做敏感操作时判断一下这个权限字符串在不在我的集合里。在就是有权限不在就是没权限。就这么简单。提示这里有一个很多初学者容易绕进去的误区——以为权限系统需要很复杂的加密、签名、算法。实际上权限系统真正的复杂度不在字符串本身而在这个字符串是怎么跑到session里去的谁有资格往session里放这个字符串放到session之后怎么防止被篡改。字符串只是载体围绕它建立的管理流程才是核心。我见过有些团队喜欢用数字编号当权限标识比如权限ID为12表示新增用户。这样确实省存储但debug的时候非常痛苦线上日志里报权限不足缺少12你还得去查权限表看12到底是啥。用字符串的话日志直接打印缺少user:add任何人都能一眼看懂。这也是为什么主流框架和系统的权限标识普遍使用资源:操作这种字符串格式。1.2 为什么要在用户和权限之间多塞一个角色进去如果不加角色直接在用户表里挂权限会怎样设想一个只有三个用户的系统A需要user:add、user:edit、user:deleteB需要user:add、user:viewC需要user:view。维护关系倒还凑合。但真实系统的用户量是几千几万的权限点也可能有几百个如果直接维护用户和权限的对应关系哪天新增了一个order:export权限需要批量给80个运营人员开通你得一个一个挂挂到怀疑人生。角色就是用来解决这个批量管理问题的中间层。你可以创建一个运营专员角色给它挂上所有运营需要的权限字符串然后把80个用户都关联到这个角色上。以后权限有变动只需要改角色和权限的关联所有关联这个角色的人自动生效。这个思路可以类比门禁系统权限字符串就是钥匙能开的门的编号角色就是门禁卡模板用户就是实体门禁卡。你做一张运营部通用门禁卡模板设定它能开哪几扇门再批量把卡发下去远比一张一张单独设置来得高效。RBAC的核心模型就是三层用户User关联角色Role角色关联权限Permission。严谨一点的系统还会支持角色继承比如超级管理员继承普通管理员的所有权限再额外加几个权限点。但无论怎么扩展底层的数据结构不会变最终每个用户手里攥着一把权限字符串集合请求进来时用这个集合做判断。2. 从字符串到功能闭环RBAC系统的完整构成与数据建模2.1 五张核心表把关系拆得明明白白RBAC的标准落库方案一般是五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。用户和权限不直接关联而是通过角色间接关联这样设计的好处就是前面提到的批量管理和解耦。用户表存账号、密码、昵称这些基础信息角色表存角色名称、角色标识、描述权限表存权限字符串、权限名称、所属模块。用户角色关联表和角色权限关联表都是典型的多对多关联表各存两个外键。实际建表SQL可以长这样CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 用户名, password varchar(255) NOT NULL COMMENT 密码hash, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE role ( id int(11) NOT NULL AUTO_INCREMENT, role_name varchar(64) NOT NULL COMMENT 角色名称, role_code varchar(64) NOT NULL COMMENT 角色唯一标识, PRIMARY KEY (id), UNIQUE KEY uk_role_code (role_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE permission ( id int(11) NOT NULL AUTO_INCREMENT, perm_code varchar(128) NOT NULL COMMENT 权限字符串如user:add, perm_name varchar(128) NOT NULL COMMENT 权限描述, module varchar(64) DEFAULT NULL COMMENT 所属模块, PRIMARY KEY (id), UNIQUE KEY uk_perm_code (perm_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user_role ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, role_id int(11) NOT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_role_id (role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE role_permission ( id int(11) NOT NULL AUTO_INCREMENT, role_id int(11) NOT NULL, permission_id int(11) NOT NULL, PRIMARY KEY (id), KEY idx_role_id (role_id), KEY idx_permission_id (permission_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有一个细节值得留意关联表里的user_id和role_id、role_id和permission_id都分别建了索引。因为权限系统最频繁的查询就是查某个用户的所有角色查某个角色的所有权限没有索引的话数据量一大关联查询会明显变慢。2.2 权限点怎么命名才不容易乱权限字符串的命名规范直接决定后续维护的体验。很多系统跑到后期变得混乱不堪就是前期权限命名太随意有的人写user_add有的人写UserAdd还有的人写添加用户全乱了。我比较推荐统一使用资源:操作的格式冒号作为分隔符。资源名用英文单词小写多个单词用下划线连接比如sys_user。操作统一用一组固定的动词add、edit、delete、view、export、import、audit、publish。这样组合出来的权限字符串就是user:add、order:export、article:publish语义清晰可读性强。在设计阶段最好先做一张权限点清单表把系统里所有需要控制的权限点先梳理出来。下面是我习惯用的模板所属模块操作权限字符串说明用户管理新增用户user:add后台添加新用户用户管理编辑用户user:edit修改用户信息用户管理删除用户user:delete删除用户订单模块导出订单order:export导出订单Excel订单模块查看订单order:view查看订单详情内容管理发布文章article:publish发布/上架文章这里要特别注意权限字符串的全局唯一性。一旦权限字符串在权限表里出现重复或者出现两个含义模糊、容易混淆的权限点后面写代码判断时就会非常头疼。比如user:view到底是看用户列表还是看用户详情必须在设计清单阶段就界定清楚。2.3 开发前先做权限点清单比写代码更重要我在一个创业项目里见过最痛苦的场景产品经理说这里加个按钮只有管理员能看到前端同学就在代码里写死if(admin)后端同学又在自己接口里写死if(userId1)。等系统功能多了权限控制散落在各个地方改一个需求要翻遍全项目。这就是典型的没有先做权限点清单的后果。权限点清单的意义是在开发之前先把所有需要做权限控制的地方统一盘点明确每个页面的按钮、每个接口对应哪个权限字符串。这个过程的最佳实践是产品、前端、后端坐在一起对着页面原型逐页过凡是涉及谁能看到谁能操作的地方都记下来统一归类到某个资源模块下定义一个权限字符串。等清单确定之后开发阶段就是纯粹的体力活后端把接口和权限字符串对应起来前端把按钮和权限字符串对应起来。权限判断逻辑全部走统一入口不在业务代码里散写。这套流程跑顺之后新增功能时加权限点的成本会非常低改权限需求时也只需要动角色权限关联表不用担心漏改哪里。3. 设计与实现从数据库表到Session里的字符串3.1 登录成功后权限怎么装进Session在传统服务端渲染或PHP Native环境下权限的载体往往是session。登录流程一般是这样用户提交账号密码服务端验证通过后查这个用户的角色再查这些角色对应的权限字符串把权限字符串合并成一个数组存进session。用PHP写核心逻辑大概是这样session_start(); $username $_POST[username]; $password $_POST[password]; // 1. 验证账号密码这里省略查库细节 $user findUserByUsername($username); if (!$user || !password_verify($password, $user[password])) { exit(用户名或密码错误); } // 2. 禁止被禁用用户登录 if ($user[status] ! 1) { exit(账号已被禁用); } // 3. 查询用户拥有的所有权限字符串 // SQL大致是 // SELECT DISTINCT p.perm_code // FROM user_role ur // JOIN role_permission rp ON ur.role_id rp.role_id // JOIN permission p ON rp.permission_id p.id // WHERE ur.user_id ? $permissionList getPermissionCodesByUserId($user[id]); // 4. 把用户信息和权限集合写入session $_SESSION[user] [ id $user[id], username $user[username], ]; $_SESSION[permissions] $permissionList;这一步做完session里就存了一个权限字符串数组。后面的权限判断本质上就是一句话in_array(user:add, $_SESSION[permissions])。有一个容易被忽略的点session里存的数据不要贪多。有些同学喜欢把用户能访问的菜单、按钮、页面地址一堆东西全塞进session结果session文件越来越大并发一高就容易卡。实际上session里只需要存用户基本信息和权限字符串数组菜单和按钮的展示控制完全可以基于权限数组实时计算不需要单独存储。3.2 中间件拦截每个请求进来先过安检权限存储只是第一步真正的拦截发生在请求处理阶段。理想的架构是有一个统一的权限校验入口每个需要权限控制的请求进来先经过这个入口做安检通过之后才进入业务逻辑。如果使用PHP传统模式可以在每个页面的入口文件统一做校验如果使用框架Laravel、ThinkPHP等一般是写在中间件里。核心逻辑不外乎一个函数function hasPermission($permCode) { if (!isset($_SESSION[permissions])) { return false; } // 绕过判断超级管理员直接放行 if (in_array(*, $_SESSION[permissions])) { return true; } return in_array($permCode, $_SESSION[permissions]); }每个接口对应什么权限建议单独维护一份映射关系而不是在业务代码里到处写hasPermission。比如路由层声明// 新增用户接口需要user:add权限 $router-post(/user/add, function() { checkPermission(user:add); // 业务逻辑... });前端通常会在用户登录后根据权限数组决定显示哪些菜单和按钮。但请注意前端控制只是用户体验层面的优化真正的安全防线必须放在后端。前端隐藏了新增按钮用户依然可以手动构造请求去调新增接口如果后端不校验就等于没设防。我见过太多前端隐藏了就算安全了的翻车案例这一条务必牢记。3.3 按钮级和菜单级控制怎么落地权限控制按粒度大致分两层菜单级和按钮级。菜单级的思路是服务端返回菜单数据时根据当前用户的权限数组做过滤。比如系统菜单配置里用户管理菜单对应user:view权限订单管理对应order:view用户没有这些权限菜单就不展示。说一个实用的小技巧如果前端是Vue或React这类SPA应用服务端登录接口返回的用户信息里带上permissions数组前端路由的meta里声明每个页面需要的权限字符串然后在路由守卫里做判断。没有权限的页面直接跳过配合403无权限页面体验比较完整。按钮级控制也是类似思路。前端拿到permissions数组后用自定义指令或者v-if做判断。例如在Vue里可以封装一个v-permission指令用法类似button v-permissionuser:add新增用户/button判断逻辑很简单如果当前用户的permissions数组里没有user:add就把这个按钮移除或者禁用。不过还是那句话这层控制主要是给人看的真正要紧的是后端接口必须同步校验。4. 实操过程搭一个最小可运行的RBAC权限演示4.1 演示目标与环境说明理论知识讲再多不落地跑一遍总觉得隔了一层。这里我准备了一个最简可运行的方案用PHP MySQL 原生session不依赖任何框架方便你看到RBAC最核心的运行链路。演示目标定得清晰一点管理员账号登录后能看到新增用户按钮并且能调用新增用户接口成功。普通成员账号登录后看不到新增用户按钮直接访问新增用户接口时返回无权限。这套逻辑跑通了RBAC的骨架也就立住了。你完全可以用自己熟悉的语言照着重写核心思路完全一致。4.2 核心代码实现从登录到权限校验首先准备测试数据。我建了一个管理员角色role_codeadmin和一个普通成员角色role_codemember管理员角色关联了user:add和user:view普通成员角色只关联user:view。登录接口和权限判断的核心代码前面已经给出这里补一个完整的权限校验入口文件模拟中间件逻辑。假设我们有一个入口文件auth_middleware.php?php session_start(); function requirePermission($permCode) { $permissions $_SESSION[permissions] ?? []; // 超级管理员标识 if (in_array(*, $permissions)) { return; } if (!in_array($permCode, $permissions)) { http_response_code(403); exit(json_encode([code 403, msg 无权限访问])); } }新增用户接口?php require auth_middleware.php; // 接口级别权限校验 requirePermission(user:add); // 走到这里说明权限校验通过继续执行业务逻辑 $username $_POST[username] ?? ; $password $_POST[password] ?? ; // 验证参数、写入数据库... echo json_encode([code 0, msg 新增成功]);用户列表页面?php require auth_middleware.php; requirePermission(user:view); $permissions $_SESSION[permissions] ?? []; // 根据权限决定是否显示新增按钮 $canAdd in_array(user:add, $permissions) || in_array(*, $permissions); ? !DOCTYPE html html head title用户管理/title /head body h1用户列表/h1 ?php if ($canAdd): ? button新增用户/button ?php endif; ? !-- 用户列表表格 -- /body /html这里的核心就是页面按钮根据权限数组决定显隐接口根据权限数组决定是否放行。前后端使用的是同一个$_SESSION[permissions]数据源逻辑保持一致。4.3 实测验证与效果说明用管理员账号登录后session里会存类似这样的权限数组[user:view, user:add]访问用户列表页面因为存在user:view页面正常显示因为存在user:add新增用户按钮也显示出来。点击新增按钮提交请求接口层requirePermission(user:add)校验通过业务正常执行。用普通成员账号登录后session里的权限数组长这样[user:view]页面能访问按钮不显示。如果这时候用开发者工具手动构造一个POST请求直接提交到新增用户接口服务端在业务代码执行前就会被requirePermission拦截返回403。这就是前端隐藏只是体验后端校验才是安全的直观体现。这一步实测通过一个最小但五脏俱全的RBAC权限系统就跑通了。5. 常见问题与排查技巧实录5.1 权限改了用户怎么还拿着老权限这是权限系统最经典的问题之一。管理员给某个角色新增了权限用户重新登录后权限生效了但正在登录中的用户session里还是旧的权限数组必须退出重新登录或者等待session过期才能拿到新权限。处理这个问题的思路有三种最简单的是要求用户退出重新登录但体验一般进阶方案是权限版本号登录时把角色权限的版本号也存进session每次请求时对比最新版本号不一致就重新拉取权限覆盖旧session还有一种是完全不依赖session里的权限做判断每个请求都去查库拿最新权限权限永远实时但牺牲了性能。结合实际项目经验我比较推荐权限版本号方案Redis里存一个全局版本号角色权限有变动就自增每次请求对比一下这种方式兼顾实时性和性能。如果系统规模不大、用户量小也可以直接每次请求实时查库拿权限代码最简单不会出现权限不一致的问题。5.2 Session过期时间怎么设置才合理Session过期时间的设置是个取舍问题。设得太短用户操作一会儿就被迫重新登录体验差设得太长用户走了之后session还长期有效安全风险高。大多数系统的做法是登录态保持24小时到7天不等同时设置一个较短的空闲超时时间比如用户30分钟没有任何操作就自动失效。具体到PHP涉及session.gc_maxlifetime和cookie过期时间的配置不同框架有不同的配置方式。一个偏安全向的习惯是权限校验必须跟着session走session失效权限判断就自然拒绝。另外涉及资金、删除、导出这类敏感操作哪怕session没过期也建议做二次身份校验比如重新输入密码或者短信验证码。这是很多权限系统的常见补充手段。5.3 Session固定、越权等安全问题怎么防在权限系统的安全性上有几个问题必须重视。第一个是session fixation攻击者事先把自己的session id塞给受害者受害者登录后session id不变攻击者就能冒用身份。防御手段很成熟登录成功后立即session_regenerate_id()让session id重新生成问题就解了。第二个是越权问题也就是IDOR。系统只校验是否登录和是否拥有某个权限字符串还远远不够比如用户A只能查看自己的订单但用户A把请求里的订单ID改成用户B的订单ID如果服务端没有校验数据归属就能看到别人的订单。这类问题需要做数据级权限控制判断当前用户是否有权限访问这个具体的数据对象这属于RBAC之上的扩展话题但实际项目中非常常见。关于session和浏览器查看登录状态有一个概念要澄清session数据是存在服务端的浏览器里只有一条session_id的cookie。很多朋友在浏览器开发者工具里找登录的session数据找半天找不到是因为方向就错了。浏览器能看到的只是Cookie里的session_id服务端才是session内容的保管者。5.4 权限字符串命名不规范会踩哪些坑我接手过一个遗留系统权限字符串有叫用户添加的有叫userAdd的有叫user_add的还有一个模块里User:Edit和user:edit同时存在。后果就是权限判断经常对不上用户在页面上看着有权限接口却一直返回无权限或者用户明明没权限因为字符串匹配错了反而放行了。规范一旦确定就要强制全项目遵守。我整理了一份常见的规范化正反例错误示例正确示例说明UserAdduser:add统一小写冒号分隔添加用户user:add不用中文统一字符串user_adduser:add资源与操作之间用冒号user:Adduser:add操作统一小写USer:ADDuser:add大小写敏感全小写最省心权限字符串建议永远使用小写、半角英文字符和冒号。线上出现过因为从文档复制权限字符串时带上了全角冒号结果权限死活匹配不上的案例排查起来让人抓狂。所以权限表面上是个字符串实际牵涉到规范、流程、沟通一环都别松懈。5.5 数据量大了关联查询变慢怎么办五张表虽然清晰但如果用户量上百万、角色权限关系上千万条每次登录都要拉一大串关联查询不说数据库扛不扛得住响应速度也会成为问题。常规优化手段是分层缓存。登录后把用户ID映射的权限字符串集合直接缓存到Rediskey类似user:permissions:{userId}value就是权限字符串数组。首次查询走数据库后续请求直接读Redis权限判断从查库JOIN变成读一次内存数组性能提升非常明显。权限变更时只需要删除或者更新对应用户的缓存key。一次批量给某个角色加权限可以通过后台任务把该角色关联的所有用户的缓存key全部失效掉或者用前面提到的权限版本号机制统一处理。核心原则是权限数据允许短暂的不一致但要尽快收敛不能让用户几个小时都拿不到新权限。5.6 多个角色叠加权限怎么算真实系统里一个用户往往有多个角色。比如张三既是运营专员又是区域经理两个角色的权限集合并集。代码层面的处理很简单查角色权限时把两个角色的权限字符串数组用array_merge合并再array_unique去重得到的结果集就是最终权限。这里有一个决策点如果两个角色权限冲突怎么办比如运营专员有order:export区域经理没有order:export那以哪个为准行业里最常见的约定是并集优先权限只增不减——即只要任意一个角色拥有这个权限用户就有这个权限。这种设计更符合实际管理诉求也避免了越管越少的困惑。极端情况下超级管理员账号会特殊处理要么在权限表里定义一个通配权限判断时如果权限数组里有就直接放行所有操作要么在代码里单独判断用户ID。我倾向于用*通配符方案在权限判断逻辑里加一行判断即可不用把某个用户ID写死在代码里。5.7 最后分享一点个人体会我在实际项目里做权限系统踩过最多的坑不是技术实现而是前期梳理不清楚、后期维护不统一。现在每接手一个涉及权限的新项目我第一件事永远是拉着产品把所有操作路径捋一遍把权限点清单一版一版对清楚然后才允许进入开发。权限点清单确认了后续的代码实现基本就是流水线工作。如果你正在开发自己的系统我的建议很直接哪怕只有一个管理员也把用户-角色-权限的模型搭起来别嫌五张表麻烦别图省事让用户直接挂权限。一个小系统要成长为大系统权限模型的演进成本是最高的之一早期把骨架立稳后期能省下大量重构的力气。权限系统这件事本质上考的是对业务边界的划分能力把谁能在什么条件下做什么事情理清楚了代码层面反而不复杂。
返回列表