最近我在整理后台权限系统。这个问题看起来像是“不同角色显示不同菜单”,但真正做起来至少有两件事:
- 页面和按钮要不要给用户看。
- 用户直接调用接口时,服务端到底放不放行。
只做第一件事不算权限系统。因为浏览器里的 JavaScript、路由地址和按钮样式都可以被用户改掉。真正的权限边界必须放在服务端。
这篇文章先按当前项目的代码,把权限链路从登录一直拆到接口校验;然后再介绍几种常见的后台权限方案。我的目标不是把概念讲得很复杂,而是让刚接触后台权限的人能看懂:每一层是干什么的,为什么要这样做,什么时候应该换方案。
先说结论
当前项目实际采用的是一套“以角色为主、菜单和按钮为权限项、服务端按接口再次校验”的混合 RBAC 方案。
它的主要特点是:
- 用户通过
role_id关联角色。 - 角色通过
role_permissions关联菜单权限和按钮权限。 - 系统还保留了
access_user_items,可以给单个用户额外绑定权限。 - 前端根据权限过滤侧栏、拦截路由、隐藏按钮。
- 服务端每次管理接口请求都会重新查数据库,按接口路径和请求方法判断权限。
- 超级管理员由数据库角色字段
is_super决定,可以跳过普通权限判断。 - 登录会生成 JWT,同时生成后台会话记录;修改密码、角色、账号状态或强制下线后,旧会话会失效。
所以,这不是“前端拿到一个角色名,然后在页面里判断”的简单做法。前端负责体验,服务端负责安全。
一、我先把权限拆成五个问题
刚开始看权限代码时,我不建议直接搜索 permission 这个单词。更容易的方法是按下面五个问题去找:
1. 用户是谁
当前项目登录接口是:
POST /api/admin/user/login
服务端在 service/routers/user.js 中校验用户名和密码。密码使用 bcrypt 比对,登录成功后返回 JWT,并把用户 ID、用户名、auth_version、会话 ID sid 等信息写进 token。
前端在 admin/src/views/Login/Index.vue 中拿到 token 后,做了两件事:
userStore.setToken(data.token, data.session_timeout_hours);
userStore.setUserInfo(data);
token 最终保存在名为 ADMIN_TK 的 Cookie 中,用户基本资料保存在 USER_INFO 这个 localStorage 项里。之后请求统一在 admin/src/utils/request.js 中添加:
Authorization: Bearer <token>
这里有一个容易误解的地方:localStorage 里的用户资料只是给前端显示和初始化权限用的,不能当作可信的身份来源。服务端仍然会根据 token 里的用户 ID 回数据库查当前账号和角色。
2. 用户能不能进入某个页面
前端入口在 admin/src/permission.js。
每次路由跳转时,路由守卫会先检查 Cookie 里有没有 token。没有 token 就跳到登录页;有 token 就调用 Pinia 里的 permissionStore 加载权限。
权限加载的逻辑在 admin/src/store/permission.js:
- 有
role_id时,调用getRoleRouteItemsDetail获取角色权限。 - 没有
role_id时,调用getUserRouteItems获取用户权限。 type === "menu"的权限被整理成菜单路径。type === "button"的权限被整理成按钮 key。
路由配置本身仍然在 admin/src/router/routers.js 中写死。数据库里的菜单权限只负责“过滤已有路由”,不会自动生成一个新的 Vue 页面。
这一点很重要。后台新增一个数据库菜单,如果前端没有对应的路由和组件,它不会凭空变成一个可访问页面。
3. 用户能不能看到某个按钮
按钮权限使用指令:
<el-button v-auth="'users.session.invalidate'">
强制下线
</el-button>
指令实现位于 admin/src/directives/auth.js。权限不满足时,默认会把元素设置为 display: none;也支持 remove 和 invisible 两种效果。
当前项目中比较典型的按钮 key 有:
users.session.invalidate
system.database-backup.create
system.database-backup.download
system.database-backup.restore
system.tasks.run
system.alerts.update
cross-border.shops.write
cross-border.shops.import
cross-border.shops.export
cross-border.shops.credentials.read
这类权限比“能不能进入用户页面”更细。比如一个人可以查看用户列表,但不一定可以强制其他用户下线。
项目中还有一个 v-has 指令,位置是 admin/src/directives/has.js。它检查的是菜单权限,主要用于控制菜单级内容。实际写按钮操作时,应该优先使用 v-auth,不要把菜单权限 key 当成按钮权限 key。
4. 用户直接调用接口时能不能成功
这一层在服务端,核心文件是:
service/middleware/auth.js
service/middleware/admin-permissions.js
RouterContainer 会给每个路由注册两个前缀:
/api/xxx
/api/admin/xxx
前端主要调用带 /api/admin 的地址。请求进入 Koa 后,鉴权中间件会:
- 读取
Authorization里的 Bearer token。 - 使用环境变量
JWT_SECRET和 HS256 验证 JWT。 - 根据 token 中的用户 ID 查询数据库中的用户和角色。
- 检查用户状态、角色状态和
auth_version。 - 用 token 中的
sid查询admin_sessions,确认会话存在、没有被撤销、没有过期。 - 确认账号拥有启用中的管理员角色。
- 调用
hasAdminPermission做具体接口权限判断。
具体接口权限不是从前端传来的,而是服务端根据请求路径和方法计算出来的。例如:
POST /api/admin/user/8/invalidate-session
↓
users.session.invalidate
然后服务端从 access_route_items、role_permissions 和 access_user_items 查出当前用户拥有的权限 key、path 和 name,只要与要求的 key 匹配才放行。
5. 权限变化后,旧登录是否还有效
当前项目已经考虑了这个问题。
后台会话保存在 admin_sessions,登录历史保存在 admin_login_events。修改用户密码、角色或账号状态时,服务端会增加用户的 auth_version,并撤销该用户的会话。
保存角色权限时,setRoleRouteItems 会调用 invalidateRoleSessions,让这个角色下的用户重新登录。管理员在用户页面执行“强制下线”时,也会让目标账号的所有会话失效。
这样做的效果是:权限不是等 JWT 自然过期后才更新,而是可以主动让旧登录失效。
二、当前项目的权限数据是怎么连起来的
从前后端代码可以确认,当前权限关系大致如下:
users
└─ role_id → roles
└─ role_permissions → access_route_items
users
└─ access_user_items ───────────────→ access_route_items
access_route_items 同时存菜单和按钮,通过 type 区分:
type = menu 菜单、路由
type = button 按钮、操作
菜单或按钮还会通过 parent_id 组织成树,所以角色编辑界面可以直接使用 Element Plus 的树形复选框。
角色编辑组件是:
admin/src/views/Users/components/AddEditRole.vue
保存角色时,前端会把选中的权限项 ID 发送给:
POST /api/admin/access/setRoleRouteItems
服务端目前采用“先删除这个角色旧权限,再逐条插入新权限”的覆盖式保存方式。
三、这套实现的优点
我认为当前方案有几个很实用的优点。
1. 前后端都有权限判断
侧栏和按钮隐藏能让界面更干净,服务端再次校验能防止用户绕过页面直接调用接口。两者缺一不可。
2. 权限项可以由后台维护
菜单、按钮和角色权限没有全部写死在前端。管理员可以在访问管理页面中创建、修改、停用和删除权限项。
3. 已经开始区分危险操作
跨境店铺的编辑、导入、导出、查看凭据,数据库备份的创建、下载、恢复,系统任务执行等,都使用了独立按钮 key。
相比“只要能进系统设置页面就能做所有事情”,这种拆分更接近最小权限原则。
4. 会话可以被主动撤销
角色权限改变后让旧会话失效,能减少“刚刚收回权限,但对方还拿着旧 token 继续操作”的时间窗口。
5. 超级管理员判断在服务端
前端虽然也会读取 is_super 来展示界面,但服务端的 isSuperAdmin 使用的是数据库查询出来的角色字段。用户修改浏览器里的 localStorage,不能直接把自己变成服务端超级管理员。
四、当前方案还需要注意什么
下面这些不一定马上要重写,但在继续扩展系统前最好知道。
1. 菜单权限和接口权限的映射仍然比较粗
service/middleware/admin-permissions.js 里有一张 ADMIN_PERMISSION_RULES 表,把接口前缀映射到菜单 key。例如很多文章接口只要拥有博客菜单权限就能访问。
这种方式能快速覆盖整个模块,但它无法天然区分:
文章查看
文章新增
文章编辑
文章删除
文章发布
如果以后要做真正的只读角色,建议把接口权限直接命名成 article.read、article.create、article.update、article.delete、article.publish,不要长期依赖菜单名称来充当接口权限。
2. access_user_items 会让权限关系变复杂
服务端查询权限时,会把角色权限和用户直接权限做并集。也就是说,用户即使换了角色,只要 access_user_items 里还留着某一项,仍然可能继续拥有这项权限。
当前保存角色权限时,代码还会把角色权限同步复制到这个角色下的用户的 access_user_items。这会让“角色继承”和“用户直接授权”混在一起。尤其是用户换角色时,应该重点检查旧的用户直接权限有没有清理。
如果项目没有明确的“给单个用户加例外权限”需求,我更建议只保留角色授权,暂时不要使用用户直接授权。模型越简单,排查权限问题越容易。
3. 覆盖式保存最好使用事务
现在设置角色权限的过程大致是:
删除旧关系
逐条插入新关系
同步用户关系
撤销旧会话
如果中途数据库断开,可能出现旧权限已经删掉、新权限只保存了一部分的情况。后续可以把删除、插入、同步和版本更新放进一个数据库事务里,并且校验 role_id 和每一个权限项 ID 是否真实存在。
4. 新增菜单不等于新增前端页面
当前前端路由组件来自 src/router/routers.js,数据库权限项主要用来过滤路由。新增一个数据库菜单后,如果没有在前端补对应的路由组件,用户最多只能看到一条没有实际页面的权限数据。
如果未来希望完全动态菜单,就需要额外设计组件白名单、路由加载策略和动态路由刷新,不能只把数据库里的 component 字符串直接交给浏览器执行。
5. 隐藏路由不是安全措施
hidden: true 只是“不在侧栏显示”。账户中心、访问管理、文章详情等页面仍然可能被直接输入地址访问。真正的安全控制仍然要看服务端接口是否返回 401 或 403。
五、几种常见的后台权限实现方案
下面几种方案不是互相完全排斥的。实际项目经常是先用 RBAC,后面再叠加数据范围或策略判断。
方案一:纯 RBAC + 权限编码
这是我最推荐当前项目逐步演进的方向。
RBAC 的意思是 Role-Based Access Control,也就是“基于角色的访问控制”。用户不直接拥有一堆页面,而是先属于角色,角色再拥有权限。
权限不再主要使用菜单名称,而是使用稳定的业务编码:
article.read
article.create
article.update
article.delete
article.publish
user.read
user.update
user.session.invalidate
数据库关系可以简化成:
users → user_roles → roles → role_permissions → permissions
服务端路由里明确写要求的权限:
router.post('/articles', requirePermission('article.create'), createArticle);
router.put('/articles/:id', requirePermission('article.update'), updateArticle);
router.post('/articles/:id/publish', requirePermission('article.publish'), publishArticle);
前端只使用同一个权限编码控制显示:
<el-button v-auth="'article.publish'">发布</el-button>
优点:
- 权限语义清楚,菜单改名不会影响接口授权。
- 很容易做只读、编辑、审核等角色。
- 服务端路由旁边就能看出权限要求。
- 更适合自动化测试。
缺点:
- 需要为接口整理权限清单。
- 权限编码设计不好时,也会变成一堆难以维护的字符串。
适合:大多数中小型管理后台,也是我认为 admin 下一步最值得做的改造。
方案二:RBAC + 数据范围
普通 RBAC 只能回答“能不能访问这个接口”,但不能回答“能看到哪些数据”。
例如同一个订单列表接口:
- 总管理员可以看全部订单。
- 区域管理员只能看华南区订单。
- 店铺管理员只能看自己负责的店铺。
- 普通员工只能看自己创建的数据。
这时可以在角色上增加数据范围:
scope = all 全部数据
scope = region 指定区域
scope = shop 指定店铺
scope = self 仅本人
服务端查询时把范围条件加进 SQL:
const scope = await getDataScope(ctx.state.user, 'orders.read');
const where = buildOrderScope(scope, ctx.state.user);
const rows = await db(`SELECT * FROM orders WHERE ${where.sql}`, where.params);
这里不能只在前端过滤列表。前端过滤只是少显示几行,接口仍然可能把全部数据返回给用户,数据已经泄露了。
优点:能解决真实业务中的“同一个功能,不同人看到不同数据”。
缺点:查询逻辑会变复杂,必须防止漏加范围条件。
适合:跨境店铺、订单、客户、财务、组织架构等存在数据归属的系统。当前项目如果继续扩展跨境电商模块,迟早会遇到这个问题。
方案三:ABAC / 策略权限
ABAC 是 Attribute-Based Access Control,即“基于属性的访问控制”。它不只看角色,还会看用户、资源、动作和环境属性。
例如:
用户:角色=运营,部门=华南
资源:店铺=深圳店,状态=草稿
动作:编辑
环境:当前时间=工作日白天
策略可以写成:
运营人员可以编辑自己部门的草稿店铺
财务人员可以查看费用,但不能修改店铺凭据
只有安全管理员可以恢复数据库备份
代码形式可以简单写成:
function canEditShop(user, shop) {
return user.department_id === shop.department_id
&& shop.status === 'draft'
&& user.permissions.includes('shop.update');
}
优点:表达能力强,适合复杂的资源级规则。
缺点:规则容易散落在代码里;如果没有统一的策略管理方式,最后会变成“到处写 if”。
适合:审批流、组织隔离、资源归属、时间限制、风险等级等条件很多的系统。小项目一开始不建议直接上完整策略引擎,先把规则集中到几个可测试的函数里更稳妥。
方案四:ACL / 用户直接授权
ACL 是 Access Control List,可以理解为“直接给某个用户一张权限清单”。
比如:
用户 Levi:article.read、article.update
用户 Alice:article.read、article.publish
它实现简单,临时给某个人开一个权限也很方便。当前项目里的 access_user_items 就具有这种能力。
但 ACL 的问题也很明显:
- 用户多了以后,权限关系数量快速增加。
- 很难回答“为什么这个人有权限”。
- 换岗位时需要逐个清理旧权限。
- 权限容易形成历史遗留。
适合:用户数量很少、权限例外很多、或者需要临时授权的场景。更常见的做法是“RBAC 为主,ACL 只作为少量例外”,并且必须记录授权人、原因和过期时间。
方案五:接入统一身份和权限平台
当公司已经有统一登录、LDAP、OAuth2、OIDC、Keycloak 或其他 IAM 平台时,后台可以不自己维护账号登录,而是:
- 跳转统一登录平台。
- 回调拿到身份信息。
- 根据部门、组或角色映射成本系统权限。
- 本地只保存必要的用户和权限缓存。
优点:统一登录、统一禁用账号、减少重复造登录系统。
缺点:部署和排查依赖外部系统;小项目接入成本可能大于收益。
适合:多系统、多人协作、企业内部平台。单个个人博客后台没有必要为了“看起来专业”强行接入。
六、我会怎么给当前项目选方案
如果这是我继续维护的项目,我不会马上推翻现有实现,而是分三步走。
第一步:保留当前菜单树,但补齐稳定的权限编码
菜单继续负责导航,按钮继续负责界面操作;接口权限改成明确的业务编码。
例如:
菜单:/blog/article-list
查看:article.read
新增:article.create
编辑:article.update
删除:article.delete
发布:article.publish
菜单名称、路由路径可以调整,但 article.publish 尽量保持稳定。
第二步:服务端从“接口前缀映射”改成“路由声明权限”
现在的 ADMIN_PERMISSION_RULES 适合快速兜底,但长期维护时,最好让每个敏感接口声明自己的权限。
例如可以做一个简单中间件:
const requirePermission = (permission) => async (ctx, next) => {
const allowed = await hasPermission(ctx.state.user, permission);
if (!allowed) {
ctx.status = 403;
ctx.body = { code: 403, message: '没有执行该操作的权限' };
return;
}
await next();
};
然后在路由旁边写清楚:
router.post('/articles/:id/publish', requirePermission('article.publish'), publishArticle);
这样以后查问题时,不需要再去一张很长的前缀映射表里猜某个接口对应什么权限。
第三步:真正遇到业务数据隔离时,再加入数据范围
如果只是博客文章后台,纯 RBAC 基本够用。如果跨境店铺、收入、售后等模块需要按店铺或部门隔离,再增加 data_scope,不要一开始就把所有接口改成复杂策略。
七、从当前方案迁移到权限编码方案的步骤
我会按下面顺序改,尽量减少一次改太多带来的风险。
1. 先盘点现有操作
把每个页面的操作列出来,不要先设计数据库:
用户:查看、新增、编辑、删除、强制下线
角色:查看、新增、编辑、删除、分配权限
文章:查看、新增、编辑、删除、发布、移入回收站
备份:查看、创建、下载、恢复
2. 给操作命名
命名规则尽量统一:
资源.动作
例如:
user.read
user.create
user.update
user.delete
user.session.invalidate
不要使用容易变化的中文名称,也不要把 URL 当成唯一权限编码。URL 是接口实现细节,权限编码是业务概念。
3. 先让服务端保护最危险的接口
优先改删除、发布、恢复备份、查看敏感凭据、强制下线等接口。每改一个接口,就用无权限账号实际请求一次,确认返回 403。
4. 前端改用相同的权限编码
把:
<el-button v-auth="'某个菜单名'">删除</el-button>
改成:
<el-button v-auth="'article.delete'">删除</el-button>
前端隐藏只是用户体验优化,不能删掉服务端校验。
5. 再考虑清理用户直接授权
如果确认没有单用户例外授权需求,可以先停止新增 access_user_items,再检查历史数据,最后删除代码中的并集逻辑。不要直接删表,先确认有没有业务依赖。
6. 给权限变更加测试
至少覆盖:
- 没有 token 返回 401。
- token 过期或会话撤销返回 401。
- 没有权限返回 403。
- 有角色权限可以访问。
- 有按钮权限可以执行操作。
- 角色禁用后旧会话不能继续访问。
- 超级管理员可以访问允许的管理接口。
八、我自己排查权限问题时的顺序
遇到“菜单不显示”或“按钮点不了”时,我不会先改 CSS,而是按这个顺序查:
- 浏览器 Cookie 里是否有
ADMIN_TK。 - localStorage 的
USER_INFO是否包含正确的用户 ID 和role_id。 permissionStore.load()调的到底是角色接口还是用户接口。- 权限接口返回的数据里,
type、key、path是否正确。 - 路由配置里有没有对应的 path 和 component。
- 按钮上的
v-authkey 是否和数据库 key 完全一致。 - Network 面板里真正的管理接口是否返回 401 或 403。
- 服务端
getRequiredPermissionKeys()对这个路径和方法算出了什么权限。 - 数据库中的角色关系是否启用,权限项是否
status = 1且没有软删除。
尤其要记住:
按钮消失 ≠ 接口安全
接口返回成功 ≠ 页面一定有权限
前端和后端必须放在一起验证。
九、最后整理成一句话
后台权限系统的核心不是“把没有权限的按钮藏起来”,而是建立一条完整链路:
用户身份
→ 角色
→ 权限编码
→ 页面和按钮展示
→ 服务端接口校验
→ 数据范围过滤
→ 权限变化后的会话失效
当前 admin 已经有了角色、菜单、按钮、接口中间件和会话失效这些基础能力。下一步最值得做的事情,是逐步减少菜单名称和接口前缀之间的模糊映射,把真正的业务操作改成稳定的权限编码;等数据隔离需求出现后,再加数据范围。这样改动可控,也比较适合我这种边维护现有系统、边继续增加功能的项目。



private note
交流
文章暂不开放公开评论。如果你有想法、问题或建议,欢迎私下联系站长。
联系站长