个性化阅读
专注于IT技术分析

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

点击下载

本文概述

考虑一下你一生中使用的所有软件。无论你是开发人员还是普通用户, 都可以随意浏览互联网并检查社交网络。你可以识别的几乎所有软件都使用某种形式的API。

API(应用程序编程接口)使软件应用程序能够与其他软件保持一致的通信。内部连接到应用程序的组件, 或者外部连接到服务。

在开发中使用基于API的组件和服务也是保持可伸缩性和生产力的一种好方法, 因为它使你能够基于模块化和可重用的组件开发多个应用程序, 从而实现可伸缩性并促进维护。

我们还应考虑到多个在线服务具有前端API, 并且你可以使用它们轻松集成社交媒体登录, 信用卡付款, 行为跟踪和许多其他功能。

通过使用通用的通信平台, 由所有这些平台共享和支持的标准化协议, 可以极大地促进通过其API实现如此多种服务。我们将为此使用REST。

REST代表REpresentational State Transfer, 它用于通过几种无状态操作来访问和操纵数据。这些操作是HTTP协议不可或缺的, 代表了基本的CRUD功能(创建, 读取, 更新, 删除)。

可用的HTTP操作包括:

  • POST(创建资源或通常提供数据)
  • GET(获取资源或单个资源的索引)
  • PUT(创建或替换资源)
  • PATCH(更新/修改资源)
  • 删除(删除资源)

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

尽管可以使用多种平台和编程语言来构建REST API, 但在本文中, 我们将重点关注Node.js。

Node.js是在服务器端运行的JavaScript运行时环境。在此环境中, 我们可以使用JavaScript来构建软件, REST API, 并通过其API调用外部服务。对于正从前端开发过渡过来的开发人员来说, 这一事实特别方便, 因为他们应该已经熟悉JavaScript, 从而使过渡更加自然。它还具有在单一编程语言下统一所有代码库的好处。

作为基础结构, Node.js设计用于构建可扩展的网络应用程序。在本地计算机上进行设置也相对简单, 并且可以用几行代码来运行服务器。甚至AWS(Amazon Web Services)之类的某些云服务都运行Node.js, 使你能够运行无服务器应用程序。

现在, 当然, 在现实世界中, 没有什么事情是那么清晰明了的, 而且开发社区总是在讨论哪种编程语言是最好的, 哪种环境是最适合特定目的的讨论, 这是成熟的, 但是我将保留这一点。由你决定。不过, 如果你好奇的话, 还可以在ASP.NET Core, Laravel(PHP), Bottle(Python)以及许多其他文章中找到有关REST API的文章。

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

在本教程中, 我们将为称为用户的资源创建一个非常通用但实用的REST API。

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

  • id(自动生成的UUID)
  • 名字
  • 电子邮件
  • 密码
  • PermissionLevel(用于控制用户的权限)

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

  • [POST]端点/用户
  • [GET]端点/用户(列出用户)
  • [GET]端点/用户/:userId(获取特定用户)
  • [PATCH]端点/用户/:userId(更新指定用户的数据)
  • [DELETE]端点/用户/:userId(删除指定的用户)

我们还将使用JWT(JSON Web令牌)作为访问令牌, 为此, 我们将创建另一个名为auth的资源, 该资源将期望用户电子邮件和密码, 并相应地生成用于某些操作的身份验证的令牌。如果你有兴趣了解更多有关此方面的知识, Dejan Milosevic不久前就Java的安全REST应用程序撰写了一篇有关JWT的精彩文章;原理是一样的。

相关:是时候使用节点8了吗?

设定

首先, 请确保你安装了最新的Node.js版本。对于本文, 我将使用来自nodejs.org的8.11.2版本。版本8进行了一些巧妙的改进(如果你有兴趣, 可以在这里阅读更多内容)。

接下来, 确保已安装MongoDB, 或从www.mongodb.com安装它。

创建一个我们将用于项目的文件夹, 并将其命名为simple-rest-api。

在该文件夹中打开一个终端(或git CLI控制台), 然后运行npm init为项目创建package.json文件。

我们还将在此项目上使用Express。这是一个不错的Node.js框架, 带有一些内置加载项, 可加快我们的开发速度。

为了使本教程始终专注于主题, 这里是带有适当依赖项的package.json。

{
 "name": "rest-api-tutorial", "version": "1.0.0", "description": "", "main": "index.js", "scripts": {
   "test": "echo \"Error: no test specified\" && exit 1"
 }, "repository": {
   "type": "git", "url": "git+https://github.com/makinhs/rest-api-tutorial.git"
 }, "author": "", "license": "ISC", "bugs": {
   "url": "https://github.com/makinhs/rest-api-tutorial/issues"
 }, "homepage": "https://github.com/makinhs/rest-api-tutorial#readme", "dependencies": {
   "body-parser": "1.7.0", "express": "^4.8.7", "jsonwebtoken": "^7.3.0", "moment": "^2.17.1", "moment-timezone": "^0.5.13", "mongoose": "^5.1.1", "node-uuid": "^1.4.8", "swagger-ui-express": "^2.0.13", "sync-request": "^4.0.2"
 }
}

将代码粘贴到package.json文件中, 然后将其保存, 然后运行npm install。恭喜, 你现在已具有运行我们的简单REST API所需的所有依赖项和设置。

我们的项目将包含三个模块文件夹:

  • “通用”(处理用户模块之间的所有共享服务和信息)
  • “用户”(与用户有关的一切)
  • ” auth”(处理生成JWT和登录流的流程)

创建用户模块

我们将使用Mongoose(用于MongoDB的ODM(对象数据建模)库)在用户架构中创建用户模型。

首先, 我们需要在/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操作。

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

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

在这一点上, 我们可以通过运行服务器并使用一些JSON数据向/ users发送POST请求来测试我们的Mongoose模型:

{
   "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd"
}

我们可以使用控制器在/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});
       });
};

此时, 有效帖子的结果将只是创建的用户的ID:{” id”:” 5b02c5c84817bf28049e58a3″}

并且我们需要在用户/模型/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);
   });
};

最后在/users/models/users.model.js中将findById方法添加到模型中:

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操作, 因为它将使我们仅发送要更改的字段。因此, 该路由将是对/ users /:userid的PATCH, 我们将发送要更改的所有字段。我们还需要实施一些额外的验证, 因为更改应仅限于相关用户或管理员, 并且只有管理员才能更改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 new Promise((resolve, reject) => {
        User.findById(id, function (err, user) {
            if (err) reject(err);
            for (let i in userData) {
                user[i] = userData[i];
            }
            user.save(function (err, updatedUser) {
                if (err) return reject(err);
                resolve(updatedUser);
            });
        });
    })
};

用户列表将通过以下控制器在/ 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.remove({_id: userId}, (err) => {
            if (err) {
                reject(err);
            } else {
                resolve(err);
            }
        });
    });
};

现在, 我们已经拥有了处理用户资源所需的所有必要操作, 并且已经完成了用户控制器的操作。该代码的主要思想是为你提供使用REST模式的核心概念。我们需要返回此代码以对其进行一些验证和权限, 但是首先, 我们需要开始建立我们的安全性。让我们创建auth模块。

创建身份验证模块

在我们通过实现权限和验证中间件来确保用户模块安全之前, 我们需要能够为当前用户生成一个有效的令牌。为了响应用户提供有效的电子邮件和密码, 我们将生成一个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 = new Buffer(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标头中使用它。

创建权限和验证中间件

我们应该定义的第一件事是谁可以使用用户资源。这些是我们需要处理的方案:

  • 公共, 用于创建用户(注册过程)。在这种情况下, 我们将不使用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用于具有无效令牌的有效请求, 或具有无效权限的有效令牌

我们可以使用按位AND运算符(位掩码)来控制权限。如果将每个所需的权限设置为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的运行情况。

要创建用户, 我们只需要将必填字段过帐到适当的端点并存储生成的ID供以后使用。

请求提供适当的数据以创建用户

API将以用户ID响应:

带有用户标识的确认响应

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

请求登录数据

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

包含相应的JSON Web令牌的确认

抓住accessToken, 在Bearer前面加上前缀, 并将其添加到Authorization下的请求标头中:

设置要传输的标头包含身份验证的JWT

如果我们现在已经实现了权限中间件, 那么如果现在不执行此操作, 则除注册外的每个请求都将返回HTTP代码401。但是, 在具有有效令牌的情况下, 我们从/ users /:userId得到以下响应:

响应列出了指定用户的数据

另外, 如前所述, 出于教育目的和简单起见, 我们正在显示所有领域。密码(哈希或其他密码)在响应中永远不可见。

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

要求所有用户的清单

惊喜!我们收到403回应。

由于缺少适当的权限级别,操作被拒绝

我们的用户无权访问此端点。我们将需要在MongoDB中将用户的权限级别从1手动更改为7, 然后生成一个新的JWT。

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

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

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

包含要更新的部分数据的Requerst

我们希望收到204响应, 以确认操作成功, 但是我们可以再次要求用户进行验证。

成功更改后的响应

最后, 我们需要删除用户。我们需要如上所述创建一个新用户(不要忘了记录用户ID), 并确保我们为管理员用户提供了合适的JWT。

请求设置删除用户

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

总结

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

  • 实施适当的验证(例如, 确保用户电子邮件是唯一的)
  • 实施单元测试和错误报告
  • 防止用户更改自己的权限级别
  • 防止管理员将自己删除
  • 防止泄露敏感信息(例如, 哈希密码)

你可以在我的GitHub上获取源代码。

相关:使用REST规范从未做过的5件事

赞(0)
未经允许不得转载:srcmini » 在Node.js中创建安全的REST API

评论 抢沙发

评论前必须登录!