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

315 阅读12分钟

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

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

如今,在线服务拥有面向公众的API是很常见的。这些API使其他开发者能够轻松地整合各种功能,如社交媒体登录、信用卡支付和行为跟踪。他们在这方面使用的事实上的标准是REpresentational State Transfer(REST)。

虽然有许多平台和编程语言可用于这项任务--例如ASP.NET CoreLaravel(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 (这个用户可以做什么?)

我们还将使用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的、非基于环境的秘密分发机制