13分钟阅读

在Node.js中创建一个安全的REST API

马科斯在IT和发展中有15年。他的激情包括休息架构,敏捷开发方法和js。

应用程序编程接口(API)无处不在。它们使软件能够与其他软件 - 内部或外部 - 始终如一的软件通信,这是一种可扩展性的关键成分,更不用说可重用性。

如今,它非常常见,以便在线服务面向公开的API。这些使其他开发人员能够轻松地集成社交媒体登录,信用卡付款和行为跟踪等功能。这 事实上 他们使用的标准称为代表性状态转移(REST)。

虽然众多平台和编程语言可以用于任务-e.g。 ASP.NET核心, LARAVEL(PHP), 或者 瓶子(Python) - 本教程,我们将使用以下堆栈构建一个基本但安全的REST API后端:

  • node.js,读者应该已经有一些熟悉
  • 表示,它大大简化了在Node.js下构建公共Web服务器任务,并且是构建REST API后端的标准票价
  • 猫鼬,它将将我们的后端连接到MongoDB数据库

此教程之后的开发人员也应该对终端(或命令提示提示)感到舒服。

注意:我们不会在此处覆盖前端代码库,但我们的后端用JavaScript编写的事实使得共享代码对象模型方便,例如 - 整个完整堆栈。

休息API的解剖学

休息 API用于使用常见的无状态操作来访问和操作数据。这些操作与HTTP协议是一体的,表示基本创建,读取,更新和删除(CRUD)功能,虽然不是清洁一对一的方式:

  • POST (创建资源或通常提供数据)
  • GET (检索资源索引或个人资源)
  • PUT (创建或替换资源)
  • PATCH (更新/修改资源)
  • DELETE (remove a resource)

使用这些HTTP操作和资源名称作为地址,我们可以通过为每个操作创建端点来构建REST API。并通过实施模式,我们将拥有一个稳定且易于理解的基础,使我们能够快速发展代码并以后维护。如前所述,相同的基础将用于整合第三方功能,其中大部分都同样使用RESP API,使得这种集成更快。

目前,让我们开始使用Node.js创建安全休息API!

In this tutorial, we are going to create a pretty common (and very practical) REST API for a resource called users.

我们的资源将具有以下基本结构:

  • id (一种自动生成的UUID)
  • 电子邮件
  • 密码
  • permissionlevel. (这个用户允许做什么?)

我们将为该资源创建以下操作:

  • POST on the endpoint /users (create a new user)
  • GET on the endpoint /users (list all users)
  • GET on the endpoint /用户/:userid (get a specific user)
  • PATCH on the endpoint /用户/:userid (update the data for a specific user)
  • DELETE on the endpoint /用户/:userid (remove a specific user)

We will also be using JSON web tokens (JWTs) for access tokens. To that end, we will create another resource called auth that will expect a user’s email and password and, in return, will generate the token used for authentication on certain operations. (Dejan Milosevic’s great article on JWT用于java中的安全休息应用程序 关于这个的更多细节;原则是一样的。)

休息 API教程设置

首先,请确保安装了最新的Node.js版本。对于本文,我将使用版本14.9.0;它也可能在旧版本上工作。

接下来,确保你有 MongoDB. installed. We won’t explain the specifics of Mongoose and MongoDB that are used here, but to get the basics running, simply start the server in interactive mode (i.e., from the command line as mongo) rather than as a service. That’s because, at one point in this tutorial, we’ll need to interact with MongoDB directly rather than via our Node.js code.

注意:使用MongoDB,无需创建特定数据库,如可能存在某些RDBMS方案。来自我们的node.js代码的第一个插入呼叫将自动触发其创建。

本教程不包含工作项目所需的所有代码。它意味着你克隆 伴侣回购 如果您愿意,您只需按照阅读时沿着亮点沿着亮点遵循亮点

Navigate to the resulting rest-api-tutorial/ folder in your terminal. You’ll see that our project contains three module folders:

  • common (处理所有共享服务,以及用户模块之间共享的信息)
  • users (关于用户的一切)
  • auth (处理JWT生成和登录流程)

Now, run npm install (or yarn if you have it.)

恭喜,您现在拥有运行Simple REST API后端所需的所有依赖项和设置。

创建用户模块

我们将使用 猫鼬,MongoDB的对象数据建模(ODM)库,用于在用户模式中创建用户模型。

First, we need to create the Mongoose schema in /users/models/users.model.js:

const userSchema = new Schema({
   firstName: String,
   lastName: String,
   email: String,
   password: String,
   permissionLevel: Number
});

一旦我们定义架构,我们就可以轻松地将架构附加到用户模型。

const userModel = mongoose.model('Users', userSchema);

之后,我们可以使用此模型来实现我们在快递端点内想要的所有CRUD操作。

Let’s start with the “create user” operation by defining the route in users/routes.config.js:

app.post('/users', [
   UsersController.insert
]);

This is pulled into our Express app in the main index.js file. The UsersController object is imported from our controller, where we hash the password appropriately, defined in /users/controllers/users.controller.js:

exports.insert = (req, res) => {
   let salt = crypto.randomBytes(16).toString('base64');
   让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});
       });
};

At this point, we can test our Mongoose model by running the server (NPM开始) and sending a POST request to /users with some JSON data:

{
   "名" : "马科斯",
   "姓" : "席尔瓦",
   "电子邮件" : "[电子邮件 protected]",
   "密码" : "s3cr3tp4sswo4rd."
}

There are several tools you can use for this. Insomnia (covered below) and Postman are popular GUI tools, and curl is a common CLI choice. You can even just use JavaScript, e.g., from your browser’s built-in development tools console:

fetch('http://localhost:3600/users', {
        method: 'POST',
        headers: {
            "Content-type": "application/json"
        },
        body: JSON.stringify({
            "名": "马科斯",
            "姓": "席尔瓦",
            "电子邮件": "[电子邮件 protected]",
            "密码": "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);
    });

At this point, the result of a valid post will be just the id from the created user: { "id": "5B02C5C84817BF28049E58A3" }. We need to also add the createUser method to the model in users/models/users.model.js:

exports.createUser = (userData) => {
    const user = new User(userData);
    return user.save();
};

全部 set, now we need to see if the user exists. For that, we are going to implement the “get user by id” feature for the following endpoint: users/:userId.

First, we create a route in /users/routes/config.js:

app.get('/users/:userId', [
    UsersController.getById
]);

Then, we create the controller in /users/controllers/users.controller.js:

exports.getById = (req, res) => {
   UserModel.findById(req.params.userId).then((result) => {
       res.status(200).send(result);
   });
};

And finally, add the findById method to the model in /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;
    });
};

响应将是这样的:

{
   "名": "马科斯",
   "姓": "席尔瓦",
   "电子邮件": "[电子邮件 protected]",
   "密码": "y + xzear7j8xaqcc37nf1rw == $ p8b5ykux6xpc6k8mrydpardxnclumu9mevabyldpoto66qjh0igvoverdqah + cuq4n / e0z48mp8sdtpx2ivuq ==",
   "permissionlevel.":1,
   "id": "5B02C5C84817BF28049E58A3"
}

Note that we can see the hashed password. For this tutorial, we are showing the password, but the obvious best practice is never to reveal the password, even if it has been hashed. Another thing we can see is the permissionlevel., which we will use to handle the user permissions later on.

Repeating the pattern laid out above, we can now add the functionality to update the user. We will use the PATCH operation since it will enable us to send only the fields we want to change. The route will, therefore, be PATCH to /users/:userid, and we’ll be sending any fields we want to change. We will also need to implement some extra validation since changes should be restricted to the user in question or an admin, and only an admin should be able to change the permissionlevel.. We’ll skip that for now and get back to it once we implement the auth module. For now, our controller will look like this:

exports.patchById = (req, res) => {
   if (req.body.password){
       let salt = crypto.randomBytes(16).toString('base64');
       让hash = crypto.createhmac('sha512',盐).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,没有响应主体以指示该请求成功。

And we’ll need to add the patchUser method to the model:

Exports.patchUser =(ID,UserData)=> {
    return user.findoneandupdate({
        _id: id
    }, userData);
};

The user list will be implemented as a GET at /users/ by the following controller:

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) => {
    返回新的承诺((解析,拒绝)=> {
        User.find()
            .limit(perPage)
            .skip(perPage * page)
            .exec(function (err, users) {
                if (err) {
                    reject(err);
                } else {
                    resolve(users);
                }
            })
    });
};

生成的列表响应将具有以下结构:

[
   {
       "名": "Marco",
       "姓": "席尔瓦",
       "电子邮件": "[电子邮件 protected]",
       "密码": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==",
       "permissionlevel.":1,
       "id": "5B02C5C84817BF28049E58A3"
   },
   {
       "名": "Paulo",
       "姓": "席尔瓦",
       "电子邮件": "[电子邮件 protected]",
       "密码": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==",
       "permissionlevel.":1,
       "id": "5b02d038b653603d1ca69729"
   }
]

And the last part to be implemented is the DELETE at /用户/:userid.

我们的删除控制器将是:

exports.removeById = (req, res) => {
   UserModel.removeById(req.params.userId)
       .then((result)=>{
           res.status(204).send({});
       });
};

与以前一样,控制器将返回HTTP代码204,没有内容身体作为确认。

相应的模型方法应该如下所示:

exports.removeById = (userId) => {
    返回新的承诺((解析,拒绝)=> {
        User.deleteMany({_id: userId}, (err) => {
            if (err) {
                reject(err);
            } else {
                resolve(err);
            }
        });
    });
};

我们现在拥有用于操纵用户资源的所有必要操作,我们正在使用用户控制器完成。此代码的主要思想是为您提供使用REST模式的核心概念。我们需要返回此代码以实现某些验证和权限,但首先,我们需要开始构建我们的安全性。让我们创建auth模块。

创建AUTH模块

Before we can secure the users module by implementing the permission and validation middleware, we’ll need to be able to generate a valid token for the current user. We will generate a JWT in response to the user providing a valid email and password. JWT is a remarkable JSON web token that you can use to have the user securely make several requests without validating repeatedly. It usually has an expiration time, and a new token is recreated every few minutes to keep the communication secure. For this tutorial, though, we will forgo refreshing the token and keep it simple with a single token per login.

First, we will create an endpoint for POST requests to /auth resource. The request body will contain the user email and password:

{
   "电子邮件" : "[电子邮件 protected]",
   "密码" : "s3cr3tp4sswo4rd.2"
}

Before we engage the controller, we should validate the user in /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];
               让hash = crypto.createhmac('sha512',盐).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');
       让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});
   }
};

即使我们不会在本教程中刷新令牌,也已设置控制器,以便在随后的开发中使其更容易实现它。

全部 we need now is to create the route and invoke the appropriate middleware in /authorization/routes.config.js:

    app.post('/auth', [
        VerifyUserMiddleware.hasAuthValidFields,
        VerifyUserMiddleware.isPasswordAndUserMatch,
        AuthorizationController.login
    ]);

响应将包含AccessToken字段中生成的JWT:

{
   "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY",
   "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ=="
}

Having created the token, we can use it inside the Authorization header using the form Bearer ACCESS_TOKEN.

创建权限和验证中间件

The first thing we should define is who can use the users resource. These are the scenarios that we’ll need to handle:

  • 公众创建用户(注册过程)。我们不会为此方案使用JWT。
  • 私有登录用户和管理员要更新该用户。
  • 仅用于删除用户帐户的管理员私有。

Having identified these scenarios, we will first require a middleware that always validates the user if they are using a valid JWT. The middleware in /common/middlewares/auth.validation.middleware.js can be as simple as:

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用于使用无效令牌的有效请求,或具有无效权限的有效令牌

我们可以使用按位和运算符(BitMasking)来控制权限。如果我们将每个所需的许可设置为2,我们可以将32位整数的每位视为单一权限。然后,管理员可以通过将其权限值设置为2147483647来拥有所有权限。然后,该用户可以访问任何路由。作为另一示例,其许可值被设置为7的用户将具有标记为值1,2和4的位的角色的权限(两个到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。

Now, we need to add the authentication middleware to the user’s module routes in /users/routes.config.js:

app.post('/users', [
   UsersController.insert
]);
app.get('/users', [
   ValidationMiddle.Validjwtneeded,
   PermissionMiddleware.minimumPermissionLevelRequired(PAID),
   UsersController.list
]);
app.get('/users/:userId', [
   ValidationMiddle.Validjwtneeded,
   permissionmiddleware.minimumpermistrylevelrequired(免费),
   permissionmiddle.onlysameuseroradmincandistachisaction,
   UsersController.getById
]);
app.patch('/users/:userId', [
   ValidationMiddle.Validjwtneeded,
   permissionmiddleware.minimumpermistrylevelrequired(免费),
   permissionmiddle.onlysameuseroradmincandistachisaction,
   Userscontroller.patchbyid
]);
app.delete('/users/:userId', [
   ValidationMiddle.Validjwtneeded,
   PermissionMiddleware.minimumPermissionLevelRequired(ADMIN),
   UsersController.removeById
]);

这结束了我们REST API的基本发展。剩下的是要做的就是全力以赴。

用失眠运行和测试

失眠 是一个非常休息的客户,具有良好的免费版本。当然,最好的做法是在项目中包含代码测试并实施正确的错误报告,但第三方REST客户端很好用于在错误报告和调试时测试和实施第三方解决方案。我们将在此处使用它来播放应用程序的角色,并在我们的API中欣赏到您正在进行的内容。

To create a user, we just need to POST the required fields to the appropriate endpoint and store the generated ID for subsequent use.

请求使用适当的数据来创建用户

API将响应用户ID:

使用UserID确认响应

We can now generate the JWT using the /auth/ endpoint:

请求使用登录数据

我们应该令牌作为我们的回复:

确认包含相应的JSON Web令牌

Grab the accessToken, prefix it with Bearer (remember the space), and add it to the request headers under Authorization:

设置标题传输包含验证JWT

If we don’t do this now that we have implemented the permissions middleware, every request other than registration would be returning HTTP code 401. With the valid token in place, though, we get the following response from /用户/:userid:

响应列出指示用户的数据

此外,如前所述,我们正在展示所有领域,用于教育目的,并为了简单起见。在响应中永远不会可见密码(散列或其他方式)。

让我们试着获取用户列表:

请求所有用户列表

惊喜!我们得到403回复。

由于缺乏适当的许可水平,行动拒绝

Our user does not have the permissions to access this endpoint. We will need to change the permissionlevel. of our user from 1 to 7 (or even 5 would do, since our free and paid permissions levels are represented as 1 and 4, respectively.) We can do this manually in MongoDB, at its interactive prompt, like this (with the ID changed to your local result):

db.users.update({"_ID" : ObjectId("5B02C5C84817BF28049E58A3")},{$set:{"permissionlevel.":5}})

然后,我们需要生成一个新的JWT。

完成后,我们得到了正确的回复:

对所有用户及其数据的响应

Next, let’s test the update functionality by sending a PATCH request with some fields to our /用户/:userid endpoint:

请求包含要更新的部分数据

我们预期为204个响应作为确认成功操作,但我们可以再次请求用户验证。

成功变革后的回应

Finally, we need to delete the user. We’ll need to create a new user as described above (don’t forget to note the user ID) and make sure that we have the appropriate JWT for an admin user. The new user will need their permissions set to 2053 (that’s 2048—ADMIN—plus our earlier 5) to be able to also perform the delete operation. With that done and a new JWT generated, we’ll have to update our Authorization request header:

请求设置删除用户

Sending a DELETE request to /用户/:userid, we should get a 204 response as confirmation. We can, again, verify by requesting /users/ to list all existing users.

您REST API的后续步骤

使用本教程中涵盖的工具和方法,您现在应该能够在Node.js上创建简单且安全的REST API。跳过了很多对过程不必要的最佳实践,所以不要忘记:

  • 实施适当的验证(例如,确保用户电子邮件是唯一的)
  • 实施单元测试和错误报告
  • 阻止用户更改自己的权限级别
  • 防止管理员移除自己
  • 防止披露敏感信息(例如,哈希密码)
  • Move the JWT secret from common/config/env.config.js to an off-repo, non-environment-based 秘密分配机制

读者的最后一次练习可以是将CodeBase转换为其使用JavaScript承诺 异步/等待 technique.

对于那些可能有兴趣的人,现在也有 打字版本 项目的可用性。