第一次构建API时,我没有考虑角色分配的问题,因此给所有登录用户都赋予了相同的访问权限。这种做法运行得很好,直到有位普通用户不小心触发了删除接口,导致测试数据被全部删除。就在那天,我真正开始认真学习基于角色的访问控制机制。

“基于角色的访问控制”这个概念听起来很高级,但其实原理很简单:你能做什么,取决于“你是谁”,而不仅仅在于“你是否已经登录”。管理员可以删除用户,编辑员可以创建内容,而普通用户只能阅读信息。同一个应用,根据使用者的身份不同,体验会截然不同。

我们正在开发的正是这样的系统。这个REST API设置了三种角色,通过JWT在每个请求中携带这些角色信息,并且有一对中间件函数会在路由处理程序执行之前检查用户的权限。这样,每次请求都不会涉及数据库操作,也不会在业务逻辑中出现复杂的if/else语句。

最终,你会得到三个功能完备的角色:admineditoruser>》,每个角色只能访问对应的接口。更重要的是,这种设计模式是可复用的:一旦掌握了这个方法,你就可以将其应用到下一个项目中,而无需再学习新的教程。

完整源代码可在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中间件

这是该系统的核心部分。我们需要两个独立的中间件函数:

  1. verifyToken用于验证JWT令牌的有效性,并将解码后的数据附加到req.user

  2. 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令牌中。每当有人访问受保护的路由时,这个角色信息都会随令牌一起被发送;因此无需每次都进行额外的数据库查询。用户在登录时其角色信息会被嵌入到令牌中,而在后续的每一次请求中都会通过加密方式对其进行验证。

中间件是可以组合使用的。verifyTokencheckRole都是独立且可重复使用的函数,您可以根据需要将它们以任意组合的方式应用于任何路由上。

权限控制是在路由层面进行的。router.delete(':id', verifyToken, checkRole('admin'), handler)这种写法可以让您在查看处理函数之前就了解所有的访问控制规则。

在将这个系统部署到生产环境之前,请注意以下事项:

  • 目前使用的内存数组只是为了帮助我们简化教学流程,在正式部署之前请将其替换为真正的数据库。因为当前系统中,服务器重启会导致所有用户信息丢失。

  • 目前令牌的有效期为24小时,这个时间间隔太长了。应该将其缩短至15分钟,并实现令牌的定期更新机制。否则,被窃取的令牌很快就会失效。

  • 在执行敏感操作时,请务必从数据库中重新验证用户的角色信息。因为角色的变更不会立即反映在现有的令牌中,直到令牌过期后才会更新。

  • 始终使用HTTPS协议进行通信。

  • 如果您的权限控制逻辑需要比“简单地检查用户角色”更复杂的处理方式,可以考虑使用casl这个工具。它能够非常清晰地处理属性级别的权限规则。

结论

其核心结构由两个中间件函数以及一个JWT负载组成。我在多个项目中都采用了这种设计模式。一旦你自己亲手实现了这个架构,就会发现它在各种系统中都非常常见——因为几乎所有的多用户应用程序都需要类似的功能。

完整的源代码可在GitHub上找到: github.com/ziaongit/nodejs-rbac-jwt-api

Comments are closed.