第一次构建API时,我没有考虑角色分配的问题,因此给所有登录用户都赋予了相同的访问权限。这种做法运行得很好,直到有位普通用户不小心触发了删除接口,导致测试数据被全部删除。就在那天,我真正开始认真学习基于角色的访问控制机制。
“基于角色的访问控制”这个概念听起来很高级,但其实原理很简单:你能做什么,取决于“你是谁”,而不仅仅在于“你是否已经登录”。管理员可以删除用户,编辑员可以创建内容,而普通用户只能阅读信息。同一个应用,根据使用者的身份不同,体验会截然不同。
我们正在开发的正是这样的系统。这个REST API设置了三种角色,通过JWT在每个请求中携带这些角色信息,并且有一对中间件函数会在路由处理程序执行之前检查用户的权限。这样,每次请求都不会涉及数据库操作,也不会在业务逻辑中出现复杂的if/else语句。
最终,你会得到三个功能完备的角色:admin、editor和user>》,每个角色只能访问对应的接口。更重要的是,这种设计模式是可复用的:一旦掌握了这个方法,你就可以将其应用到下一个项目中,而无需再学习新的教程。
完整源代码可在GitHub上获取: github.com/ziaongit/nodejs-rbac-jwt-api
目录
你将学到什么
-
什么是基于角色的访问控制,它与基本认证有何不同
-
如何将角色信息嵌入JWT数据中
-
如何编写可重用的Express中间件来实现令牌验证和角色检查
-
如何根据用户角色来保护API路由
先决条件
-
已安装Node.js(v18及以上版本)
-
具备Express.js的基本知识
-
了解JWT的工作原理(我们会涉及相关内容)
-
已安装npm
我们将构建什么
我们将为这个简单的内容管理系统构建一个REST API,并设置三种用户角色:
| 角色 | 权限 |
|---|---|
普通用户 |
仅可阅读内容 |
编辑员 |
可阅读和创建内容 |
管理员 |
拥有全部权限——可阅读、创建、删除内容,还可管理用户 |
该API将提供以下接口:
| 方法 | 接口地址 | 访问权限 |
|---|---|---|
| POST | /api/auth/register | 公开访问 |
| POST | /api/auth/login | 公开访问 |
| GET | /api/content | 普通用户、编辑员、管理员均可访问 |
| POST | /api/content | 仅编辑员和管理员可访问 |
| DELETE | /api/content/:id | 仅管理员可访问 |
| GET | /api/admin/users | 仅管理员可访问 |
项目设置
创建一个新的文件夹并初始化项目:
mkdir nodejs-rbac-jwt-api
cd nodejs-rbac-jwt-api
npm init -y
安装所需的依赖包:
npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
下面介绍一下这些包的功能:
-
express:用于构建API的Web框架
-
jsonwebtoken:用于生成和验证JSON Web Tokens
-
bcryptjs:用于安全地加密密码
-
dotenv:用于读取`.env`文件,从而避免在源代码中硬编码敏感信息
更新`package.json`文件以添加启动脚本:
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
}
构建项目结构:
nodejs-rbac-jwt-api/
├── src/
│ ├── middleware/
│ │ └── auth.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── content.js
│ │ └── admin.js
│ ├── data/
│ │ └── users.js
│ └── app.js
├── .env
├── .env.example
└── package.json
创建`.env`文件并设置相关参数:
JWT_SECRET=你的超级秘密密钥(在生产环境中请更换此值)
PORT=3000
重要提示:切勿将`.env`文件提交到版本控制系统中,应将其添加到`.gitignore`文件中。
设置内存数据存储机制
在这里我们没有使用数据库,而是直接在内存中存储数据。这样做的目的是让教程的重点集中在RBAC机制上,而不会花费大量时间讲解数据库配置。在实际项目中,可以将这个内存数组替换为你正在使用的任何数据库。
创建`src/data/users.js`文件:
// 在内存中存储用户信息
// 在生产环境中,应将这些数据存储到真实的数据库中(如MongoDB、PostgreSQL等)
const users = [];
const findUserByEmail = (email) => {
return users.find(u => u.email === email);
};
const findUserById = (id) => {
return users.find(u => u.id === id);
};
const createUser = (user) => {
users.push(user);
return user;
};
const getAllUsers = () => {
return users.map(user => user);
};
module.exports = { findUserByEmail, findUserById, createUser, getAllUsers };
有一点需要注意:getAllUsers方法会使用解构赋值来删除密码,然后再返回其他信息。在API响应中,绝对不要包含密码字段,即使是经过加密处理的密码也不行。
构建认证路由
这些认证路由负责处理注册和登录功能。在登录过程中,角色信息才会被首次使用——我们会将用户的角色直接嵌入到JWT令牌中。
创建文件src/routes/auth.js:
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser } = require('../data/users');
const router = express.Router();
// POST /api/auth/register
router.post('/register', async (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ message: '名称、电子邮件和密码都是必填项' });
}
if (findUserByEmail(email)) {
return res.status(409).json({ message: '该电子邮件已被注册' });
}
// 只允许有效的角色值——如果没有提供,则默认为“user”
const validRoles = ['user', 'editor', 'admin'];
const assignedRole = validRoles.includes(role) ? role : 'user';
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = {
id: Date.now().toString(),
name,
email,
password: hashedPassword,
role: assignedRole,
};
createUser(newUser);
res.status(201).json({
message: '用户注册成功',
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
role: newUser.role,
},
});
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: '需要提供电子邮件和密码' });
}
const user = findUserByEmail(email);
if (!user) {
return res.status(401).json({ message: '凭证无效' });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: '凭证无效' });
}
// 发放JWT令牌——将用户角色信息嵌入到令牌中
const token = jwt.sign(
{
id: user.id,
email: user.email,
role: user.role, // ← 这是实现RBAC的关键部分
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
message: '登录成功',
token,
});
});
module.exports = router;
其中最重要的一行代码就是生成JWT令牌时的那部分代码:
jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' })
由于在令牌中嵌入了用户的角色信息,因此后续的所有请求都不需要再查询数据库来获取用户的权限信息。服务器只需验证令牌的有效性,然后从令牌中读取用户角色即可。
构建RBAC中间件
这是该系统的核心部分。我们需要两个独立的中间件函数:
-
verifyToken用于验证JWT令牌的有效性,并将解码后的数据附加到req.user中 -
checkRole用于确认用户是否具备访问特定路由所需的角色
将这两个中间件分开使用可以带来更大的灵活性。有些路由只需要进行身份验证,而有些路由则既需要身份验证,还需要具备特定的角色。
创建文件src/middleware/auth.js:
const jwt = require('jsonwebtoken');
// 中间件1:验证JWT令牌
const verifyToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // 预期格式为:Bearer
if (!token) {
return res.status(401).json({ message: '访问被拒绝。未提供令牌。' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // 将解码后的数据(包括用户角色)附加到请求对象中
next();
} catch (err) {
return res.status(403).json({ message: '令牌无效或已过期。' });
}
};
// 中间件2:检查用户是否具有所需的角色之一
const checkRole = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: '未进行身份验证。' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message: `访问被拒绝。所需角色为:${allowedRoles.join(' 或 ')}. 当前用户角色为:${req.user.role}`,
});
}
next();
};
};
module.exports = { verifyToken, checkRole };
checkRole使用剩余参数...allowedRoles,因此你可以传入一个或多个角色:
checkRole('admin') // 仅限admin角色
checkRole('editor', 'admin') // editor或admin角色
checkRole('user', 'editor', 'admin') // 所有角色
这样的设计使得路由定义更加清晰易懂——权限要求在路由级别就能直接看出来。
构建受保护的路由
现在,让我们将这些中间件应用到具体的路由中。
创建文件src/routes/content.js:
const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const router = express.Router();
// 内存中的内容存储
const content = [
{ id: '1', title: 'Node.js入门', author: 'admin' },
{ id: '2', title: 'Express中间件详解', author: 'editor' },
];
// GET /api/content — 所有已认证的用户均可访问
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) => {
res.json({ content });
});
// POST /api/content — 仅限编辑者和管理员访问
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) => {
const { title } = req.body;
if (!title) {
return res.status(400).json({ message: '标题是必填项' });
}
const newItem = {
id: Date.now().toString(),
title,
author: req.user.email,
};
content.push(newItem);
res.status(201).json({ message: '内容创建成功', item: newItem });
});
// DELETE /api/content/:id — 仅限管理员访问
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) => {
const index = content.findIndex((c) => c.id === req.params.id);
if (index === -1) {
return res.status(404).json({ message: '未找到相应内容' });
}
content.splice(index, 1);
res.json({ message: '内容删除成功' });
});
module.exports = router;
请注意,每条路由的代码都非常易于阅读:
router.delete(':id', verifyToken, checkRole('admin'), handler)
即使不阅读处理函数的实现内容,也能理解其中的访问控制机制。这就是基于中间件的RBAC模型的一个关键优势:权限设置位于路由层,而不会隐藏在业务逻辑中。
创建文件`src/routes/admin.js`:
const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const { getAllUsers } = require('../data/users');
const router = express.Router();
// GET /api/admin/users — 仅管理员可访问
router.get('/users', verifyToken, checkRole('admin'), (req, res) => {
res.json({ users: getAllUsers() });
});
module.exports = router;
整合所有代码
创建文件`src/app.js`:
require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const contentRoutes = require('./routes/content');
const adminRoutes = require('./routes/admin');
const app = express();
app.use(express.json());
// 路由配置
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/admin', adminRoutes);
// 健康检查接口
app.get('/', (req, res) => {
res.json({ message: 'RBAC API正在运行' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在端口 ${PORT} 上`);
});
测试API
启动服务器:
npm run dev
步骤1:注册具有不同角色的用户
注册一名管理员:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}'
注册一名编辑员:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}'
注册一名普通用户(未指定角色,默认为“user”):
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}'
步骤2:登录并获取令牌
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password123"}'
您会收到如下形式的响应:
{
"message": "登录成功",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
复制这个令牌。
步骤3:测试基于角色的访问控制
以普通用户的身份尝试读取内容(应该能够成功):
curl http://localhost:3000/api/content \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
但尝试以普通用户的身份创建内容时将会失败——会收到403错误:
curl -X POST http://localhost:3000/api/content \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"title": "新文章"}'
响应结果如下:
{
"message": "访问被拒绝。所需角色为编辑者或管理员。您的角色是用户"
}
现在请以编辑者的身份登录,然后再次尝试发送这个POST请求,应该能够成功。接下来再以管理员的身份登录,尝试使用DELETE请求,只有管理员令牌才能使该操作生效。
步骤4:解码JWT令牌以查看用户角色
您可以将任何令牌粘贴到jwt.io网站上,从而查看其中包含的详细信息。您会看到类似以下的格式:
{
"id": "1720300000000",
"email": "admin@example.com",
"role": "admin",
"iat": 1720300000,
"exp": 1720386400
}
其中role字段正是checkRole函数在每次处理受保护的请求时所用来验证用户角色的信息。
关键要点
用户的角色信息被存储在JWT令牌中。每当有人访问受保护的路由时,这个角色信息都会随令牌一起被发送;因此无需每次都进行额外的数据库查询。用户在登录时其角色信息会被嵌入到令牌中,而在后续的每一次请求中都会通过加密方式对其进行验证。
中间件是可以组合使用的。verifyToken和checkRole都是独立且可重复使用的函数,您可以根据需要将它们以任意组合的方式应用于任何路由上。
权限控制是在路由层面进行的。router.delete(':id', verifyToken, checkRole('admin'), handler)这种写法可以让您在查看处理函数之前就了解所有的访问控制规则。
在将这个系统部署到生产环境之前,请注意以下事项:
-
目前使用的内存数组只是为了帮助我们简化教学流程,在正式部署之前请将其替换为真正的数据库。因为当前系统中,服务器重启会导致所有用户信息丢失。
-
目前令牌的有效期为24小时,这个时间间隔太长了。应该将其缩短至15分钟,并实现令牌的定期更新机制。否则,被窃取的令牌很快就会失效。
-
在执行敏感操作时,请务必从数据库中重新验证用户的角色信息。因为角色的变更不会立即反映在现有的令牌中,直到令牌过期后才会更新。
-
始终使用HTTPS协议进行通信。
-
如果您的权限控制逻辑需要比“简单地检查用户角色”更复杂的处理方式,可以考虑使用casl这个工具。它能够非常清晰地处理属性级别的权限规则。
结论
其核心结构由两个中间件函数以及一个JWT负载组成。我在多个项目中都采用了这种设计模式。一旦你自己亲手实现了这个架构,就会发现它在各种系统中都非常常见——因为几乎所有的多用户应用程序都需要类似的功能。
完整的源代码可在GitHub上找到: github.com/ziaongit/nodejs-rbac-jwt-api