如何在Node.js中创建安全的REST API?

2021年11月30日00:07:57 发表评论 823 次浏览

应用程序编程接口 (API) 无处不在。它们使软件能够与其他软件部分(内部或外部)一致地进行通信,这是可扩展性的关键因素,更不用说可重用性了。

如今,在线服务拥有面向公众的 API 已经很普遍了。这些使其他开发人员能够轻松集成社交媒体登录、信用卡支付和行为跟踪等功能。在事实上,他们使用这个标准被称为具象状态传输(REST)。

Node.js如何创建REST API?虽然可以使用多种平台和编程语言来完成这项任务——例如,ASP.NET Core、Laravel (PHP)或Bottle (Python) ——在本教程中,我们将构建一个基本但安全的 REST API 后端使用以下栈:

  • Node.js,读者应该已经熟悉了
  • Express,它极大地简化了在 Node.js 下构建常见的 Web 服务器任务,并且是构建 REST API 后端的标准费用
  • Mongoose,它将我们的后端连接到 MongoDB 数据库

遵循本教程的开发人员也应该对终端(或命令提示符)感到满意,本文包括详细的Node.js创建REST API示例

注意:我们不会在这里介绍前端代码​​库,但我们的后端是用 JavaScript 编写的这一事实使得在整个堆中共享代码(例如对象模型)很方便。

Node.js创建REST API教程:REST API 剖析

REST API 用于使用一组通用的无状态操作来访问和操作数据。这些操作是 HTTP 协议不可或缺的一部分,代表了基本的创建、读取、更新和删除 (CRUD) 功能,尽管不是以干净的一对一方式:

  • POST (创建资源或一般提供数据)
  • GET (检索资源索引或单个资源)
  • PUT (创建或替换资源)
  • PATCH (更新/修改资源)
  • DELETE (删除资源)

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

现在,让我们开始使用 Node.js 创建我们的安全 REST API!

在本教程中,我们将为名为users.

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

  • id (自动生成的 UUID)
  • firstName
  • lastName
  • email
  • password
  • permissionLevel (该用户可以做什么?)

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

  • POST在端点上/users(创建一个新用户)
  • GET在端点上/users(列出所有用户)
  • GET在端点上/users/:userId(获取特定用户)
  • PATCH在端点上/users/:userId(更新特定用户的数据)
  • DELETE在端点上/users/:userId(删除特定用户)

我们还将使用 JSON Web 令牌 (JWT) 作为访问令牌。为此,我们将创建另一个名为的资源auth,该资源需要用户的电子邮件和密码,作为回报,将生成用于某些操作的身份验证的令牌。(Dejan Milosevic 关于JWT 用于 Java 中的安全 REST 应用程序的伟大文章对此进行了更详细的介绍;原理是相同的。)

REST API 教程设置

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

接下来,确保你已安装MongoDB。我们不会解释这里使用的 Mongoose 和 MongoDB 的具体细节,但为了让基础运行起来,只需以交互模式(即,从命令行 as mongo)而不是作为服务启动服务器。这是因为,在本教程的某个时刻,我们需要直接与 MongoDB 交互,而不是通过我们的 Node.js 代码。

注意:使用 MongoDB,无需像在某些 RDBMS 场景中那样创建特定的数据库。Node.js 代码中的第一个插入调用将自动触发它的创建。

本教程不包含工作项目所需的所有代码。相反,你打算克隆配套存储库,并在阅读时简单地按照重点进行操作 - 但如果你愿意,也可以根据需要从存储库中复制特定文件和片段。

导航到rest-api-tutorial/终端中的结果文件夹。你会看到我们的项目包含三个模块文件夹:

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

现在,运行npm install(或者yarn如果你有的话。)

恭喜,你现在拥有运行我们简单的 REST API 后端所需的所有依赖项和设置。

创建用户模块

Node.js如何创建REST API?我们将使用Mongoose,一个用于 MongoDB 的对象数据建模 (ODM) 库,在用户模式中创建用户模型。

首先,我们需要在以下位置创建 Mongoose 模式/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);

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

让我们从“创建用户”操作开始,在 中定义路由users/routes.config.js

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

这在主index.js文件中被拉入我们的 Express 应用程序。该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) 并使用一些 JSON 数据发送POST请求来测试我们的 Mongoose 模型/users

{
   "firstName" : "Marcos",
   "lastName" : "Silva",
   "email" : "marcos.henrique@toptal.com",
   "password" : "s3cr3tp4sswo4rd"
}

Node.js创建REST API示例:有几种工具可以用于此目的。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. 我们暂时跳过它,一旦我们实现了 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);
};

用户列表将由以下控制器实现为GETat /users/

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"
   }
]

最后要实现的部分是DELETEat /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);
            }
        });
    });
};

Node.js创建REST API教程:我们现在拥有操作用户资源所需的所有操作,并且我们完成了用户控制器。这段代码的主要思想是为你提供使用 REST 模式的核心概念。我们需要返回此代码以对其实施一些验证和权限,但首先,我们需要开始构建我们的安全性。让我们创建 auth 模块。

创建身份验证模块

Node.js创建REST API示例:在我们users通过实现权限和验证中间件来保护模块之前,我们需要能够为当前用户生成一个有效的令牌。我们将生成 JWT 以响应提供有效电子邮件和密码的用户。JWT 是一个非凡的 JSON Web 令牌,你可以使用它让用户安全地发出多个请求,而无需重复验证。它通常有一个到期时间,每隔几分钟就会重新创建一个新令牌以确保通信安全。但是,对于本教程,我们将放弃刷新令牌,并通过每次登录使用单个令牌来保持简单。

Node.js如何创建REST API?首先,我们将为资源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 用于具有无效令牌的有效请求,或具有无效权限的有效令牌

我们可以使用按位与运算符(位掩码)来控制权限。如果我们将每个必需的权限设置为 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 的基本开发到此结束。剩下要做的就是全部测试。

Node.js创建REST API教程:运行和测试REST

Node.js如何创建REST API?Insomnia是一个不错的 REST 客户端,有一个很好的免费版本。当然,最佳实践是在项目中包含代码测试并实施正确的错误报告,但是当错误报告和调试服务不可用时,第三方 REST 客户端非常适合测试和实施第三方解决方案。我们将在这里使用它来扮演应用程序的角色,并深入了解我们的 API 发生了什么。

要创建用户,我们只需要将POST必填字段添加到相应的端点并存储生成的 ID 以供后续使用。

如何在Node.js中创建安全的REST API?

API 将使用用户 ID 进行响应:

如何在Node.js中创建安全的REST API?

我们现在可以使用/auth/端点生成 JWT :

如何在Node.js中创建安全的REST API?

我们应该得到一个令牌作为我们的回应:

如何在Node.js中创建安全的REST API?

获取accessToken,加上前缀Bearer (记住空格),然后将其添加到 下的请求标头中Authorization

如何在Node.js中创建安全的REST API?

如果现在我们已经实现了权限中间件,如果我们不这样做,除了注册之外的每个请求都将返回 HTTP 代码 401。但是,有了有效的令牌,我们会得到以下响应/users/:userId

如何在Node.js中创建安全的REST API?

此外,如前所述,出于教育目的和简单起见,我们将显示所有字段。密码(散列或其他方式)不应在响应中可见。

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

如何在Node.js中创建安全的REST API?

惊喜!我们收到 403 响应。

如何在Node.js中创建安全的REST API?

我们的用户无权访问此端点。我们需要将permissionLevel用户的权限从 1更改为 7(甚至 5 也可以,因为我们的免费和付费权限级别分别表示为 1 和 4。)我们可以在 MongoDB 中的交互式提示下手动执行此操作,像这样(将 ID 更改为你的本地结果):

db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})

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

完成后,我们得到正确的响应:

如何在Node.js中创建安全的REST API?
Node.js创建REST API教程

接下来,让我们通过向端点发送PATCH包含一些字段的请求来测试更新功能/users/:userId

如何在Node.js中创建安全的REST API?

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

如何在Node.js中创建安全的REST API?
Node.js创建REST API教程

Node.js创建REST API示例:最后,我们需要删除用户。我们需要如上所述创建一个新用户(不要忘记记下用户 ID),并确保我们拥有适合管理员用户的 JWT。新用户需要将他们的权限设置为 2053(即 2048——ADMIN加上我们之前的 5)才能执行删除操作。完成并生成新的 JWT 后,我们必须更新我们的Authorization请求标头:

如何在Node.js中创建安全的REST API?

向 发送DELETE请求/users/:userId,我们应该得到 204 响应作为确认。我们可以再次通过请求/users/列出所有现有用户来验证。

Node.js创建REST API教程的后续步骤

使用本教程中介绍的工具和方法,你现在应该能够在 Node.js 上创建简单且安全的 REST API。许多对流程不重要的最佳实践被跳过,所以不要忘记:

  • 实施适当的验证(例如,确保用户电子邮件是唯一的)
  • 实施单元测试和错误报告
  • 防止用户更改自己的权限级别
  • 防止管理员删除自己
  • 防止泄露敏感信息(例如,散列密码)
  • 将 JWT 秘密从common/config/env.config.js非存储库、非基于环境的秘密分发机制移至

读者的最后一项练习是将代码库从使用 JavaScript 承诺转换为 异步/等待技术。

对于那些可能感兴趣的人,现在还提供了该项目的 TypeScript 版本。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: