在Node.js中创建一个安全的REST API
应用程序编程接口(API)无处不在。它们使软件能够与其他软件--内部或外部--一致地进行通信,这是可扩展性的关键因素,更不用说可重用性了。
如今,在线服务拥有面向公众的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)firstNamelastNameemailpasswordpermissionLevel(这个用户可以做什么?)
我们还将使用JSON网络令牌(JWTs)作为访问令牌。为此,我们将创建另一个名为auth 的资源,它将期待用户的电子邮件和密码,作为回报,它将生成用于某些操作的认证的令牌。(Dejan Milosevic关于JWT在Java中用于安全REST应用的伟大文章对此做了进一步的详细介绍;其原理是相同的)。
REST API教程设置
首先,确保你安装了最新的Node.js版本。在这篇文章中,我将使用14.9.0版本;它也可能在旧版本上工作。
接下来,确保你安装了MongoDB。我们不会解释这里所使用的Mongoose和MongoDB的具体细节,但要让基本的东西运行起来,只需在交互式模式下启动服务器(即从命令行中以mongo ),而不是作为一个服务。这是因为,在本教程中,我们需要直接与MongoDB交互,而不是通过我们的Node.js代码。
注意:有了MongoDB,就不需要像某些RDBMS场景那样创建一个特定的数据库了。我们的Node.js代码的第一个插入调用将自动触发其创建。
本教程不包含一个工作项目所需的所有代码。如果你愿意的话,你也可以根据需要从repo中复制特定的文件和片段。
在你的终端导航到产生的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" : "marcos.henrique@toptal.com",
"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": "marcos.henrique@toptal.com",
"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" } 。我们还需要将createUser 方法添加到模型中,users/models/users.model.js 。
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": "marcos.henrique@toptal.com",
"password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==",
"permissionLevel": 1,
"id": "5b02c5c84817bf28049e58a3"
}
请注意,我们可以看到散列的密码。在本教程中,我们显示了密码,但明显的最佳做法是永远不要透露密码,即使它已被洗过。我们可以看到的另一件事是permissionLevel ,我们将在以后使用它来处理用户权限。
重复上面的模式,我们现在可以添加更新用户的功能。我们将使用PATCH 操作,因为它将使我们只发送我们想要改变的字段。因此,路由将是PATCH 到/users/:userid ,我们将发送我们想要改变的任何字段。我们还需要实现一些额外的验证,因为更改应该被限制在相关的用户或管理员身上,而且只有管理员能够更改permissionLevel 。我们现在先跳过这一点,等我们实现了授权模块再来讨论。现在,我们的控制器将看起来像这样。
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": "marcos.henrique@toptal.com",
"password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==",
"permissionLevel": 1,
"id": "5b02c5c84817bf28049e58a3"
},
{
"firstName": "Paulo",
"lastName": "Silva",
"email": "marcos.henrique2@toptal.com",
"password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==",
"permissionLevel": 1,
"id": "5b02d038b653603d1ca69729"
}
]
最后要实现的部分是DELETE ,在/users/:userId 。
我们用于删除的控制器将是
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网络令牌,你可以用它来让用户安全地提出几个请求,而不重复验证。它通常有一个过期时间,每隔几分钟就会重新创建一个新的令牌,以保持通信安全。不过,在本教程中,我们将放弃刷新令牌,保持简单,每次登录只用一个令牌。
POST 首先,我们将创建一个端点,用于向/auth 资源提出请求。请求主体将包含用户的电子邮件和密码。
{
"email" : "marcos.henrique2@toptal.com",
"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=="
}
在创建了令牌后,我们可以在Authorization 头内使用它,使用的形式是Bearer ACCESS_TOKEN 。
创建权限和验证中间件
我们应该定义的第一件事是谁可以使用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表示一个有效的请求,但有一个无效的token,或者有效的token有无效的权限
我们可以使用位数和运算符(bitmasking)来控制权限。如果我们把每个所需的权限设置为2的幂,我们就可以把32位整数的每一位作为一个权限。然后,一个管理员可以通过设置他们的权限值为2147483647来拥有所有权限。然后该用户可以访问任何路由。另一个例子,一个权限值被设置为7的用户将拥有对标有1、2、4(0、1、2的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();
}
};
};
该中间件是通用的。如果用户权限级别和所需的权限级别至少有一个位重合,结果将大于0,我们可以让行动继续进行;否则,将返回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运行和测试
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转移到一个非repo的、非基于环境的秘密分发机制上