认证(Authentication)确认了“你是谁”,而授权(Authorization)决定了“你能做什么”。在大多数后台管理系统中,权限控制需要做到足够灵活,既能管理不同角色的访问范围,又能对单个用户做细粒度调整。RBAC(Role-Based Access Control,基于角色的访问控制)是目前最成熟、应用最广泛的授权模型,本节我们将详细拆解它的设计思路并给出可落地的 Node.js 实现方案。
RBAC 的核心概念
RBAC 将权限分为三个核心实体:
- 用户(User):系统的使用者,可以是管理员、编辑、普通员工等。
- 角色(Role):一组权限的集合,例如“超级管理员”“内容编辑”“只读用户”。一个用户可以拥有一个或多个角色。
- 权限(Permission):对某个资源(Resource)执行某个操作(Action)的许可。例如“编辑文章”“删除评论”“查看订单列表”。权限通常以
资源:操作的格式表示(如article:update、order:read)。
它们的关系为:用户通过角色间接获得权限。这样设计的好处是,当需要调整某个岗位的权限时,只需修改角色所绑定的权限,而不必逐个修改用户。遇到特殊情况,也可以直接为用户授予或撤销某个特定权限,实现“用户-权限”的细粒度覆盖。
数据库表结构设计
典型的 RBAC 需要 5 张基础表(以 MySQL 为例):
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 角色表
CREATE TABLE roles (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE, -- 角色标识,如 admin、editor
display_name VARCHAR(100), -- 显示名称,如“内容编辑”
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 权限表
CREATE TABLE permissions (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(100) NOT NULL UNIQUE, -- 权限标识,如 article:create
display_name VARCHAR(100), -- 显示名称,如“创建文章”
resource VARCHAR(50), -- 资源
action VARCHAR(50), -- 操作
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 用户-角色关联表(多对多)
CREATE TABLE user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
-- 角色-权限关联表(多对多)
CREATE TABLE role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
如果还需要支持“用户-权限”的直接授予,可以再增加一张 user_permissions 表,结构与 role_permissions 类似。
这套设计遵循标准的多对多关联,查询用户的最终权限时,只需将用户角色的权限合并去重即可。
权限校验的两种模式
在实际编码中,权限校验通常有两种模式:路由守卫和菜单/按钮级控制。
路由守卫
在 API 层通过中间件拦截请求,判断当前用户是否有访问该接口的权限。例如,一个更新文章的接口可能要求 article:update 权限。
// 权限中间件
function requirePermission(...permissions) {
return async (req, res, next) => {
// req.user 应由认证中间件注入,包含用户信息和权限列表
const userPermissions = req.user.permissions; // 假设是['article:read','article:update']
const hasPermission = permissions.some(p => userPermissions.includes(p));
if (!hasPermission) {
return res.status(403).json({ message: '无权限访问' });
}
next();
};
}
// 使用
router.put('/articles/:id', requirePermission('article:update'), updateArticle);
如果接口权限要求较复杂(如必须同时拥有多个权限),可以将 some 改为 every。
菜单与按钮控制
前端需要根据用户权限动态显示菜单和操作按钮。这通常通过后端在登录成功后返回用户的权限列表(以及角色信息)实现。前端在渲染时根据权限代码判断是否展示:
// 前端简易示例
<button v-if="permissions.includes('article:delete')">删除</button>
这样做可以提升用户体验,但必须明确:前端控制仅仅是 UI 层面的优化,真正的安全防线永远是后端权限校验。攻击者可以绕过前端直接请求 API,因此后端守卫必不可少。
权限数据的缓存与性能优化
用户的权限列表在每次请求时都可能被查询,若每次都要连接数据库并进行多表 join,会成为性能瓶颈。最常见的优化手段是在认证阶段将权限数据载入 JWT 或 Redis。
方案一:放入 JWT 载荷
在用户登录时,查询其所有权限,将其编码进 JWT 的 payload 中。后续请求中,权限中间件直接从解码后的 JWT 中读取权限,无需查库。
优点:无状态,无需额外存储。
缺点:JWT 体积增大;权限变更后需要用户重新登录才能生效,不适合对实时性要求高的系统。
方案二:存储在 Redis
登录后将用户权限列表以用户 ID 为 key 缓存到 Redis,并设置合理的过期时间(如 1 小时)。权限中间件先从 Redis 获取,未命中则查询数据库并回写缓存。权限变更时主动删除对应 Redis 键。
// 登录时缓存权限
await redis.setex(`user:${userId}:permissions`, 3600, JSON.stringify(permissions));
// 中间件获取
let perms = await redis.get(`user:${userId}:permissions`);
if (!perms) {
perms = await fetchPermissionsFromDB(userId);
await redis.setex(`user:${userId}:permissions`, 3600, JSON.stringify(perms));
}
这种方式兼顾了性能与实时性,推荐在多数项目中使用。
权限的动态管理
权限码通常是开发阶段预定义的(如 article:create),但角色和角色的权限分配应由管理员通过后台页面配置。需要提供以下功能:
- 角色管理:创建、编辑、删除角色。
- 权限列表:展示系统中所有可用的权限码(可通过扫描接口或手动维护)。
- 角色赋权:为角色勾选/取消权限。
- 用户分配角色:为用户分配一个或多个角色。
实现上,这些都只是对 roles、permissions、role_permissions、user_roles 表的 CRUD 操作。需要注意的是,删除角色或权限时应当考虑外键约束,避免留下悬空关联。
一个完整的权限校验流程示例
假设使用 Koa + JWT + Redis 的典型实现,请求流程如下:
- 认证中间件:解析 Authorization 头中的 Bearer token,验证签名,取出用户 ID,从 Redis 或 JWT 中获取用户权限列表,挂载到
ctx.state.user。 - 权限中间件:如
requirePermission('article:update'),检查ctx.state.user.permissions是否包含所需权限,不包含则直接返回 403。 - 业务逻辑:通过认证和授权的请求进入控制器,执行具体业务。
// 认证中间件(JWT版本)
async function authMiddleware(ctx, next) {
const token = ctx.headers.authorization?.split(' ')[1];
if (!token) return ctx.throw(401, '未登录');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 若使用Redis缓存权限
let permissions = await redis.get(`user:${decoded.id}:permissions`);
if (!permissions) {
const user = await User.findByPk(decoded.id, { include: [...] });
permissions = extractPermissions(user);
await redis.setex(`user:${decoded.id}:permissions`, 3600, JSON.stringify(permissions));
} else {
permissions = JSON.parse(permissions);
}
ctx.state.user = { id: decoded.id, username: decoded.username, permissions };
} catch (err) {
return ctx.throw(401, 'token无效');
}
await next();
}
这样的设计使得权限校验逻辑高度集中,业务开发者只需关心接口应该被什么权限保护,而无需重复编写权限判断代码。
落地的注意事项与扩展
- 默认拒绝原则:没有明确允许的操作一律禁止。权限中间件应设置为“白名单”模式。
- 超级管理员:可以硬编码一个角色(如
super_admin),该角色拥有一切权限,不参与常规权限校验。 - 数据级权限:RBAC 本身不解决“用户只能看到自己部门的数据”这类行级权限,通常需要结合业务逻辑,在查询时动态追加过滤条件(如
where department_id = ?)。 - 权限码的维护:随着功能增多,权限码会膨胀。建议将权限码统一定义在一个常量文件中,并配合代码注释,便于后端和前端共享。
- 测试:为权限中间件编写单元测试,确保每种角色和权限组合的行为符合预期。
RBAC 模型虽然简单,但它为绝大部分应用提供了清晰、可扩展的授权框架。将这套模型与 JWT、Redis、现有框架中间件深度结合后,你将获得一个安全且高效的权限控制体系,足以应对从个人项目到企业级后台的授权需求。