在 Node.js 中创建安全 REST API
已发表: 2022-03-11应用程序编程接口 (API) 无处不在。 它们使软件能够与其他软件(内部或外部)一致地进行通信,这是可伸缩性的关键因素,更不用说可重用性了。
如今,在线服务拥有面向公众的 API 非常普遍。 这些使其他开发人员能够轻松集成社交媒体登录、信用卡支付和行为跟踪等功能。 他们为此使用的事实上的标准称为 REpresentational State Transfer (REST)。
虽然可以使用多种平台和编程语言来完成任务,例如 ASP.NET Core、Laravel (PHP) 或 Bottle (Python),但在本教程中,我们将使用以下方法构建一个基本但安全的 REST API 后端以下堆栈:
- Node.js,读者应该已经熟悉了
- Express,它极大地简化了在 Node.js 下构建常见 Web 服务器任务,并且是构建 REST API 后端的标准费用
- Mongoose,它将我们的后端连接到 MongoDB 数据库
遵循本教程的开发人员也应该熟悉终端(或命令提示符)。
注意:我们不会在这里介绍前端代码库,但我们的后端是用 JavaScript 编写的这一事实使得在整个堆栈中共享代码(例如对象模型)变得很方便。
REST API 剖析
REST API 用于使用一组通用的无状态操作来访问和操作数据。 这些操作是 HTTP 协议的组成部分,代表了基本的创建、读取、更新和删除 (CRUD) 功能,尽管不是以一种干净的一对一方式:
-
POST
(创建资源或一般提供数据) -
GET
(检索资源索引或单个资源) -
PUT
(创建或替换资源) -
PATCH
(更新/修改资源) -
DELETE
(删除资源)
使用这些 HTTP 操作和资源名称作为地址,我们可以通过为每个操作创建端点来构建 REST API。 通过实现该模式,我们将拥有一个稳定且易于理解的基础,使我们能够快速发展代码并在之后进行维护。 如前所述,将使用相同的基础来集成第三方功能,其中大部分功能同样使用 REST API,从而使这种集成更快。
现在,让我们开始使用 Node.js 创建我们的安全 REST API!
在本教程中,我们将为名为users
的资源创建一个非常常见(且非常实用)的 REST API。
我们的资源将具有以下基本结构:
-
id
(自动生成的 UUID) -
firstName
-
lastName
-
email
-
password
-
permissionLevel
(允许此用户做什么?)
我们将为该资源创建以下操作:
- 在端点
/users
POST
发布(创建一个新用户) - 在端点
/users
上GET
(列出所有用户) - 在端点
/users/:userId
上GET
(获取特定用户) - 端点
/users/:userId
上的PATCH
(更新特定用户的数据) - 在端点
/users/:userId
上DELETE
(删除特定用户)
我们还将使用 JSON Web 令牌 (JWT) 作为访问令牌。 为此,我们将创建另一个名为auth
的资源,该资源需要用户的电子邮件和密码,作为回报,将生成用于某些操作的身份验证的令牌。 (Dejan Milosevic 关于 JWT 在 Java 中的安全 REST 应用程序的精彩文章对此进行了更详细的介绍;原理是相同的。)
REST API 教程设置
首先,确保您安装了最新的 Node.js 版本。 对于本文,我将使用 14.9.0 版本; 它也可能适用于旧版本。
接下来,确保您已安装 MongoDB。 我们不会解释这里使用的 Mongoose 和 MongoDB 的具体细节,但要让基础知识运行起来,只需以交互模式(即,从命令行作为mongo
启动服务器)而不是作为服务启动服务器。 这是因为,在本教程的某一时刻,我们需要直接与 MongoDB 交互,而不是通过我们的 Node.js 代码。
注意:使用 MongoDB,不需要像在某些 RDBMS 场景中那样创建特定的数据库。 我们的 Node.js 代码中的第一个插入调用将自动触发它的创建。
本教程不包含工作项目所需的所有代码。 相反,您可以克隆伴随的存储库,并在阅读时简单地跟随重点 - 但如果您愿意,您也可以根据需要从存储库中复制特定文件和片段。
导航到终端中生成的rest-api-tutorial/
文件夹。 您会看到我们的项目包含三个模块文件夹:
-
common
(处理所有共享服务,以及用户模块之间共享的信息) -
users
(关于用户的一切) -
auth
(处理 JWT 生成和登录流程)
现在,运行npm install
(或yarn
,如果你有的话。)
恭喜,您现在拥有运行我们简单的 REST API 后端所需的所有依赖项和设置。
创建用户模块
我们将使用 Mongoose,一个用于 MongoDB 的对象数据建模 (ODM) 库,在用户模式中创建用户模型。
首先,我们需要在/users/models/users.model.js
中创建 Mongoose 模式:
const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });
一旦我们定义了模式,我们就可以轻松地将模式附加到用户模型。
const userModel = mongoose.model('Users', userSchema);
之后,我们可以使用此模型在 Express 端点中实现我们想要的所有 CRUD 操作。
让我们从“创建用户”操作开始,在users/routes.config.js
中定义路由:
app.post('/users', [ UsersController.insert ]);
这被拉入我们的 Express 应用程序的主index.js
文件中。 UsersController
对象是从我们的控制器导入的,我们在其中适当地散列密码,在/users/controllers/users.controller.js
中定义:
exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };
此时,我们可以通过运行服务器( npm start
)并向/users
发送带有一些 JSON 数据的POST
请求来测试我们的 Mongoose 模型:
{ "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }
为此,您可以使用多种工具。 Insomnia(如下所述)和 Postman 是流行的 GUI 工具,而curl
是常见的 CLI 选择。 您甚至可以只使用 JavaScript,例如,从浏览器的内置开发工具控制台:
fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });
此时,有效帖子的结果将只是来自创建用户的 id: { "id": "5b02c5c84817bf28049e58a3" }
。 我们还需要在users/models/users.model.js
中的模型中添加createUser
方法:
exports.createUser = (userData) => { const user = new User(userData); return user.save(); };
一切就绪,现在我们需要查看用户是否存在。 为此,我们将为以下端点实现“按 id 获取用户”功能: users/:userId
。
首先,我们在/users/routes/config.js
中创建一个路由:
app.get('/users/:userId', [ UsersController.getById ]);
然后,我们在/users/controllers/users.controller.js
中创建控制器:
exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };
最后,将findById
方法添加到/users/models/users.model.js
中的模型中:
exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };
响应将是这样的:
{ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }
请注意,我们可以看到散列密码。 对于本教程,我们将显示密码,但明显的最佳做法是永远不要泄露密码,即使它已经过哈希处理。 我们可以看到的另一件事是permissionLevel
,我们稍后将使用它来处理用户权限。
重复上面列出的模式,我们现在可以添加更新用户的功能。 我们将使用PATCH
操作,因为它使我们能够只发送我们想要更改的字段。 因此,路由将PATCH
到/users/:userid
,我们将发送我们想要更改的任何字段。 我们还需要实施一些额外的验证,因为更改应仅限于相关用户或管理员,并且只有管理员才能更改permissionLevel
。 我们现在将跳过它,一旦我们实现了 auth 模块,我们就会回到它。 现在,我们的控制器看起来像这样:
exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };
默认情况下,我们将发送一个没有响应正文的 HTTP 代码 204 以指示请求成功。
我们需要将patchUser
方法添加到模型中:
exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };
用户列表将通过以下控制器在/users/
处实现为GET
:
exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };
相应的模型方法将是:
exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };
生成的列表响应将具有以下结构:
[ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]
最后要实现的部分是/users/:userId
的DELETE
。
我们的删除控制器将是:
exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };
和以前一样,控制器将返回 HTTP 代码 204 并且没有内容正文作为确认。

对应的模型方法应该是这样的:
exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };
现在我们已经完成了操作用户资源的所有必要操作,并且我们完成了用户控制器。 这段代码的主要思想是为您提供使用 REST 模式的核心概念。 我们需要返回这段代码来实现一些验证和权限,但首先,我们需要开始构建我们的安全性。 让我们创建 auth 模块。
创建身份验证模块
在我们通过实现权限和验证中间件来保护users
模块之前,我们需要能够为当前用户生成一个有效的令牌。 我们将生成一个 JWT 以响应用户提供有效的电子邮件和密码。 JWT 是一个卓越的 JSON Web 令牌,您可以使用它让用户安全地发出多个请求,而无需重复验证。 它通常有一个过期时间,每隔几分钟就会重新创建一个新令牌以保持通信安全。 但是,对于本教程,我们将放弃刷新令牌,并保持简单,每次登录只需一个令牌。
首先,我们将为对/auth
资源的POST
请求创建一个端点。 请求正文将包含用户电子邮件和密码:
{ "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }
在我们使用控制器之前,我们应该在/authorization/middlewares/verify.user.middleware.js
中验证用户:
exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };
完成后,我们可以继续使用控制器并生成 JWT:
exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };
尽管我们不会在本教程中刷新令牌,但已设置控制器以启用此类生成,以便在后续开发中更轻松地实现它。
我们现在需要的是创建路由并在/authorization/routes.config.js
中调用适当的中间件:
app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);
响应将在 accessToken 字段中包含生成的 JWT:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }
创建令牌后,我们可以使用表单Bearer ACCESS_TOKEN
在Authorization
标头中使用它。
创建权限和验证中间件
我们应该定义的第一件事是谁可以使用users
资源。 这些是我们需要处理的场景:
- 公共用于创建用户(注册过程)。 我们不会在这种情况下使用 JWT。
- 专用于登录用户和管理员更新该用户。
- 管理员专用,仅用于删除用户帐户。
确定了这些场景后,我们首先需要一个中间件,该中间件始终验证用户是否使用有效的 JWT。 /common/middlewares/auth.validation.middleware.js
中的中间件可以很简单:
exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };
我们将使用 HTTP 错误代码来处理请求错误:
- 无效请求的 HTTP 401
- 带有无效令牌的有效请求或带有无效权限的有效令牌的 HTTP 403
我们可以使用按位与运算符(位掩码)来控制权限。 如果我们将每个所需权限设置为 2 的幂,我们可以将 32 位整数的每一位视为单个权限。 然后,管理员可以通过将其权限值设置为 2147483647 来获得所有权限。然后该用户可以访问任何路由。 作为另一个示例,权限值设置为 7 的用户将具有对用值 1、2 和 4 位标记的角色的权限(2 的 0、1 和 2 次方)。
中间件看起来像这样:
exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };
中间件是通用的。 如果用户权限级别和所需权限级别至少有一位一致,则结果将大于零,我们可以让操作继续进行; 否则,将返回 HTTP 代码 403。
现在,我们需要将身份验证中间件添加到/users/routes.config.js
中的用户模块路由:
app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);
我们REST API的基本开发到此结束。 剩下要做的就是全部测试。
失眠症的运行和测试
Insomnia 是一个不错的 REST 客户端,有一个很好的免费版本。 最佳实践当然是在项目中包含代码测试并实现适当的错误报告,但第三方 REST 客户端非常适合在错误报告和调试服务不可用时测试和实施第三方解决方案。 我们将在这里使用它来扮演应用程序的角色,并深入了解我们的 API 发生了什么。
要创建用户,我们只需要将必填字段POST
到适当的端点并存储生成的 ID 以供后续使用。
API 将使用用户 ID 进行响应:
我们现在可以使用/auth/
端点生成 JWT:
我们应该得到一个令牌作为我们的响应:
获取accessToken
,为其添加前缀Bearer
(记住空格),并将其添加到Authorization
下的请求标头中:
如果我们现在已经实现了权限中间件,那么如果我们现在不这样做,那么除了注册之外的每个请求都将返回 HTTP 代码 401。但是,有了有效的令牌,我们会从/users/:userId
获得以下响应:
此外,如前所述,出于教育目的和简单起见,我们展示了所有字段。 密码(散列或其他)不应该在响应中可见。
让我们尝试获取用户列表:
惊喜! 我们收到 403 响应。
我们的用户没有访问此端点的权限。 我们需要将用户的permissionLevel
级别从 1 更改为 7(甚至 5 也可以,因为我们的免费和付费权限级别分别表示为 1 和 4。)我们可以在 MongoDB 的交互式提示下手动执行此操作,像这样(将 ID 更改为您的本地结果):
db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})
然后,我们需要生成一个新的 JWT。
完成后,我们得到正确的响应:
接下来,让我们通过向/users/:userId
端点发送带有一些字段的PATCH
请求来测试更新功能:
我们期望 204 响应作为成功操作的确认,但我们可以再次请求用户进行验证。
最后,我们需要删除用户。 我们需要如上所述创建一个新用户(不要忘记记下用户 ID),并确保我们拥有适合管理员用户的 JWT。 新用户需要将他们的权限设置为 2053(即 2048- ADMIN
加上我们之前的 5)才能执行删除操作。 完成此操作并生成新的 JWT,我们将不得不更新Authorization
请求标头:
向/users/:userId
发送DELETE
请求,我们应该得到 204 响应作为确认。 我们可以再次通过请求/users/
列出所有现有用户来进行验证。
REST API 的后续步骤
使用本教程中介绍的工具和方法,您现在应该能够在 Node.js 上创建简单且安全的 REST API。 跳过了许多对该过程不重要的最佳实践,所以不要忘记:
- 实施适当的验证(例如,确保用户电子邮件是唯一的)
- 实施单元测试和错误报告
- 防止用户更改自己的权限级别
- 防止管理员删除自己
- 防止泄露敏感信息(例如散列密码)
- 将 JWT 机密从
common/config/env.config.js
到非回购、非基于环境的机密分发机制
读者的最后一个练习可以是将代码库从使用 JavaScript Promise 转换为 async/await 技术。
对于那些可能感兴趣的人,现在还提供了该项目的 TypeScript 版本。