最近我在整理后台权限系统。这个问题看起来像是“不同角色显示不同菜单”,但真正做起来至少有两件事:

  1. 页面和按钮要不要给用户看。
  2. 用户直接调用接口时,服务端到底放不放行。

只做第一件事不算权限系统。因为浏览器里的 JavaScript、路由地址和按钮样式都可以被用户改掉。真正的权限边界必须放在服务端。

这篇文章先按当前项目的代码,把权限链路从登录一直拆到接口校验;然后再介绍几种常见的后台权限方案。我的目标不是把概念讲得很复杂,而是让刚接触后台权限的人能看懂:每一层是干什么的,为什么要这样做,什么时候应该换方案。

先说结论

当前项目实际采用的是一套“以角色为主、菜单和按钮为权限项、服务端按接口再次校验”的混合 RBAC 方案。

它的主要特点是:

  • 用户通过 role_id 关联角色。
  • 角色通过 role_permissions 关联菜单权限和按钮权限。
  • 系统还保留了 access_user_items,可以给单个用户额外绑定权限。
  • 前端根据权限过滤侧栏、拦截路由、隐藏按钮。
  • 服务端每次管理接口请求都会重新查数据库,按接口路径和请求方法判断权限。
  • 超级管理员由数据库角色字段 is_super 决定,可以跳过普通权限判断。
  • 登录会生成 JWT,同时生成后台会话记录;修改密码、角色、账号状态或强制下线后,旧会话会失效。

所以,这不是“前端拿到一个角色名,然后在页面里判断”的简单做法。前端负责体验,服务端负责安全。

一、我先把权限拆成五个问题

刚开始看权限代码时,我不建议直接搜索 permission 这个单词。更容易的方法是按下面五个问题去找:

1. 用户是谁

当前项目登录接口是:

Text
POST /api/admin/user/login

服务端在 service/routers/user.js 中校验用户名和密码。密码使用 bcrypt 比对,登录成功后返回 JWT,并把用户 ID、用户名、auth_version、会话 ID sid 等信息写进 token。

前端在 admin/src/views/Login/Index.vue 中拿到 token 后,做了两件事:

JavaScript
userStore.setToken(data.token, data.session_timeout_hours);
userStore.setUserInfo(data);

token 最终保存在名为 ADMIN_TK 的 Cookie 中,用户基本资料保存在 USER_INFO 这个 localStorage 项里。之后请求统一在 admin/src/utils/request.js 中添加:

HTTP
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. 用户能不能看到某个按钮

按钮权限使用指令:

Vue
<el-button v-auth="'users.session.invalidate'">
  强制下线
</el-button>

指令实现位于 admin/src/directives/auth.js。权限不满足时,默认会把元素设置为 display: none;也支持 removeinvisible 两种效果。

当前项目中比较典型的按钮 key 有:

Text
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. 用户直接调用接口时能不能成功

这一层在服务端,核心文件是:

Text
service/middleware/auth.js
service/middleware/admin-permissions.js

RouterContainer 会给每个路由注册两个前缀:

Text
/api/xxx
/api/admin/xxx

前端主要调用带 /api/admin 的地址。请求进入 Koa 后,鉴权中间件会:

  1. 读取 Authorization 里的 Bearer token。
  2. 使用环境变量 JWT_SECRET 和 HS256 验证 JWT。
  3. 根据 token 中的用户 ID 查询数据库中的用户和角色。
  4. 检查用户状态、角色状态和 auth_version
  5. 用 token 中的 sid 查询 admin_sessions,确认会话存在、没有被撤销、没有过期。
  6. 确认账号拥有启用中的管理员角色。
  7. 调用 hasAdminPermission 做具体接口权限判断。

具体接口权限不是从前端传来的,而是服务端根据请求路径和方法计算出来的。例如:

Text
POST /api/admin/user/8/invalidate-session
        ↓
users.session.invalidate

然后服务端从 access_route_itemsrole_permissionsaccess_user_items 查出当前用户拥有的权限 key、path 和 name,只要与要求的 key 匹配才放行。

5. 权限变化后,旧登录是否还有效

当前项目已经考虑了这个问题。

后台会话保存在 admin_sessions,登录历史保存在 admin_login_events。修改用户密码、角色或账号状态时,服务端会增加用户的 auth_version,并撤销该用户的会话。

保存角色权限时,setRoleRouteItems 会调用 invalidateRoleSessions,让这个角色下的用户重新登录。管理员在用户页面执行“强制下线”时,也会让目标账号的所有会话失效。

这样做的效果是:权限不是等 JWT 自然过期后才更新,而是可以主动让旧登录失效。

二、当前项目的权限数据是怎么连起来的

从前后端代码可以确认,当前权限关系大致如下:

Text
users
  └─ role_id → roles
                  └─ role_permissions → access_route_items

users
  └─ access_user_items ───────────────→ access_route_items

access_route_items 同时存菜单和按钮,通过 type 区分:

Text
type = menu    菜单、路由
type = button  按钮、操作

菜单或按钮还会通过 parent_id 组织成树,所以角色编辑界面可以直接使用 Element Plus 的树形复选框。

角色编辑组件是:

Text
admin/src/views/Users/components/AddEditRole.vue

保存角色时,前端会把选中的权限项 ID 发送给:

Text
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。例如很多文章接口只要拥有博客菜单权限就能访问。

这种方式能快速覆盖整个模块,但它无法天然区分:

Text
文章查看
文章新增
文章编辑
文章删除
文章发布

如果以后要做真正的只读角色,建议把接口权限直接命名成 article.readarticle.createarticle.updatearticle.deletearticle.publish,不要长期依赖菜单名称来充当接口权限。

2. access_user_items 会让权限关系变复杂

服务端查询权限时,会把角色权限和用户直接权限做并集。也就是说,用户即使换了角色,只要 access_user_items 里还留着某一项,仍然可能继续拥有这项权限。

当前保存角色权限时,代码还会把角色权限同步复制到这个角色下的用户的 access_user_items。这会让“角色继承”和“用户直接授权”混在一起。尤其是用户换角色时,应该重点检查旧的用户直接权限有没有清理。

如果项目没有明确的“给单个用户加例外权限”需求,我更建议只保留角色授权,暂时不要使用用户直接授权。模型越简单,排查权限问题越容易。

3. 覆盖式保存最好使用事务

现在设置角色权限的过程大致是:

Text
删除旧关系
逐条插入新关系
同步用户关系
撤销旧会话

如果中途数据库断开,可能出现旧权限已经删掉、新权限只保存了一部分的情况。后续可以把删除、插入、同步和版本更新放进一个数据库事务里,并且校验 role_id 和每一个权限项 ID 是否真实存在。

4. 新增菜单不等于新增前端页面

当前前端路由组件来自 src/router/routers.js,数据库权限项主要用来过滤路由。新增一个数据库菜单后,如果没有在前端补对应的路由组件,用户最多只能看到一条没有实际页面的权限数据。

如果未来希望完全动态菜单,就需要额外设计组件白名单、路由加载策略和动态路由刷新,不能只把数据库里的 component 字符串直接交给浏览器执行。

5. 隐藏路由不是安全措施

hidden: true 只是“不在侧栏显示”。账户中心、访问管理、文章详情等页面仍然可能被直接输入地址访问。真正的安全控制仍然要看服务端接口是否返回 401 或 403。

五、几种常见的后台权限实现方案

下面几种方案不是互相完全排斥的。实际项目经常是先用 RBAC,后面再叠加数据范围或策略判断。

方案一:纯 RBAC + 权限编码

这是我最推荐当前项目逐步演进的方向。

RBAC 的意思是 Role-Based Access Control,也就是“基于角色的访问控制”。用户不直接拥有一堆页面,而是先属于角色,角色再拥有权限。

权限不再主要使用菜单名称,而是使用稳定的业务编码:

Text
article.read
article.create
article.update
article.delete
article.publish
user.read
user.update
user.session.invalidate

数据库关系可以简化成:

Text
users → user_roles → roles → role_permissions → permissions

服务端路由里明确写要求的权限:

JavaScript
router.post('/articles', requirePermission('article.create'), createArticle);
router.put('/articles/:id', requirePermission('article.update'), updateArticle);
router.post('/articles/:id/publish', requirePermission('article.publish'), publishArticle);

前端只使用同一个权限编码控制显示:

Vue
<el-button v-auth="'article.publish'">发布</el-button>

优点:

  • 权限语义清楚,菜单改名不会影响接口授权。
  • 很容易做只读、编辑、审核等角色。
  • 服务端路由旁边就能看出权限要求。
  • 更适合自动化测试。

缺点:

  • 需要为接口整理权限清单。
  • 权限编码设计不好时,也会变成一堆难以维护的字符串。

适合:大多数中小型管理后台,也是我认为 admin 下一步最值得做的改造。

方案二:RBAC + 数据范围

普通 RBAC 只能回答“能不能访问这个接口”,但不能回答“能看到哪些数据”。

例如同一个订单列表接口:

  • 总管理员可以看全部订单。
  • 区域管理员只能看华南区订单。
  • 店铺管理员只能看自己负责的店铺。
  • 普通员工只能看自己创建的数据。

这时可以在角色上增加数据范围:

Text
scope = all       全部数据
scope = region    指定区域
scope = shop      指定店铺
scope = self      仅本人

服务端查询时把范围条件加进 SQL:

JavaScript
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,即“基于属性的访问控制”。它不只看角色,还会看用户、资源、动作和环境属性。

例如:

Text
用户:角色=运营,部门=华南
资源:店铺=深圳店,状态=草稿
动作:编辑
环境:当前时间=工作日白天

策略可以写成:

Text
运营人员可以编辑自己部门的草稿店铺
财务人员可以查看费用,但不能修改店铺凭据
只有安全管理员可以恢复数据库备份

代码形式可以简单写成:

JavaScript
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,可以理解为“直接给某个用户一张权限清单”。

比如:

Text
用户 Levi:article.read、article.update
用户 Alice:article.read、article.publish

它实现简单,临时给某个人开一个权限也很方便。当前项目里的 access_user_items 就具有这种能力。

但 ACL 的问题也很明显:

  • 用户多了以后,权限关系数量快速增加。
  • 很难回答“为什么这个人有权限”。
  • 换岗位时需要逐个清理旧权限。
  • 权限容易形成历史遗留。

适合:用户数量很少、权限例外很多、或者需要临时授权的场景。更常见的做法是“RBAC 为主,ACL 只作为少量例外”,并且必须记录授权人、原因和过期时间。

方案五:接入统一身份和权限平台

当公司已经有统一登录、LDAP、OAuth2、OIDC、Keycloak 或其他 IAM 平台时,后台可以不自己维护账号登录,而是:

  1. 跳转统一登录平台。
  2. 回调拿到身份信息。
  3. 根据部门、组或角色映射成本系统权限。
  4. 本地只保存必要的用户和权限缓存。

优点:统一登录、统一禁用账号、减少重复造登录系统。

缺点:部署和排查依赖外部系统;小项目接入成本可能大于收益。

适合:多系统、多人协作、企业内部平台。单个个人博客后台没有必要为了“看起来专业”强行接入。

六、我会怎么给当前项目选方案

如果这是我继续维护的项目,我不会马上推翻现有实现,而是分三步走。

第一步:保留当前菜单树,但补齐稳定的权限编码

菜单继续负责导航,按钮继续负责界面操作;接口权限改成明确的业务编码。

例如:

Text
菜单:/blog/article-list
查看:article.read
新增:article.create
编辑:article.update
删除:article.delete
发布:article.publish

菜单名称、路由路径可以调整,但 article.publish 尽量保持稳定。

第二步:服务端从“接口前缀映射”改成“路由声明权限”

现在的 ADMIN_PERMISSION_RULES 适合快速兜底,但长期维护时,最好让每个敏感接口声明自己的权限。

例如可以做一个简单中间件:

JavaScript
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();
};

然后在路由旁边写清楚:

JavaScript
router.post('/articles/:id/publish', requirePermission('article.publish'), publishArticle);

这样以后查问题时,不需要再去一张很长的前缀映射表里猜某个接口对应什么权限。

第三步:真正遇到业务数据隔离时,再加入数据范围

如果只是博客文章后台,纯 RBAC 基本够用。如果跨境店铺、收入、售后等模块需要按店铺或部门隔离,再增加 data_scope,不要一开始就把所有接口改成复杂策略。

七、从当前方案迁移到权限编码方案的步骤

我会按下面顺序改,尽量减少一次改太多带来的风险。

1. 先盘点现有操作

把每个页面的操作列出来,不要先设计数据库:

Text
用户:查看、新增、编辑、删除、强制下线
角色:查看、新增、编辑、删除、分配权限
文章:查看、新增、编辑、删除、发布、移入回收站
备份:查看、创建、下载、恢复

2. 给操作命名

命名规则尽量统一:

Text
资源.动作

例如:

Text
user.read
user.create
user.update
user.delete
user.session.invalidate

不要使用容易变化的中文名称,也不要把 URL 当成唯一权限编码。URL 是接口实现细节,权限编码是业务概念。

3. 先让服务端保护最危险的接口

优先改删除、发布、恢复备份、查看敏感凭据、强制下线等接口。每改一个接口,就用无权限账号实际请求一次,确认返回 403。

4. 前端改用相同的权限编码

把:

Vue
<el-button v-auth="'某个菜单名'">删除</el-button>

改成:

Vue
<el-button v-auth="'article.delete'">删除</el-button>

前端隐藏只是用户体验优化,不能删掉服务端校验。

5. 再考虑清理用户直接授权

如果确认没有单用户例外授权需求,可以先停止新增 access_user_items,再检查历史数据,最后删除代码中的并集逻辑。不要直接删表,先确认有没有业务依赖。

6. 给权限变更加测试

至少覆盖:

  • 没有 token 返回 401。
  • token 过期或会话撤销返回 401。
  • 没有权限返回 403。
  • 有角色权限可以访问。
  • 有按钮权限可以执行操作。
  • 角色禁用后旧会话不能继续访问。
  • 超级管理员可以访问允许的管理接口。

八、我自己排查权限问题时的顺序

遇到“菜单不显示”或“按钮点不了”时,我不会先改 CSS,而是按这个顺序查:

  1. 浏览器 Cookie 里是否有 ADMIN_TK
  2. localStorage 的 USER_INFO 是否包含正确的用户 ID 和 role_id
  3. permissionStore.load() 调的到底是角色接口还是用户接口。
  4. 权限接口返回的数据里,typekeypath 是否正确。
  5. 路由配置里有没有对应的 path 和 component。
  6. 按钮上的 v-auth key 是否和数据库 key 完全一致。
  7. Network 面板里真正的管理接口是否返回 401 或 403。
  8. 服务端 getRequiredPermissionKeys() 对这个路径和方法算出了什么权限。
  9. 数据库中的角色关系是否启用,权限项是否 status = 1 且没有软删除。

尤其要记住:

Text
按钮消失 ≠ 接口安全
接口返回成功 ≠ 页面一定有权限

前端和后端必须放在一起验证。

九、最后整理成一句话

后台权限系统的核心不是“把没有权限的按钮藏起来”,而是建立一条完整链路:

Text
用户身份
  → 角色
  → 权限编码
  → 页面和按钮展示
  → 服务端接口校验
  → 数据范围过滤
  → 权限变化后的会话失效

当前 admin 已经有了角色、菜单、按钮、接口中间件和会话失效这些基础能力。下一步最值得做的事情,是逐步减少菜单名称和接口前缀之间的模糊映射,把真正的业务操作改成稳定的权限编码;等数据隔离需求出现后,再加数据范围。这样改动可控,也比较适合我这种边维护现有系统、边继续增加功能的项目。

阅读进度 0%