作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
马科斯·恩里克·达席尔瓦's profile image

By 马科斯·恩里克·达席尔瓦

Marcos has 17+ years in IT 和 development. 他的爱好包括休息架构、敏捷开发方法和JS.

Years of Experience

12

分享

At 这 point in our series on how to 创建 a 节点.. js 休息 API with 表达.js 和 TypeScript, 我们已经构建了一个工作后端,并将我们的代码分离到路由配置中, 服务, middleware, controllers, models. 如果你准备好从那里开始,克隆 the 例子 repo 和运行 Git checkout total -article 02.

A 休息 API 与猫鼬,身份验证和自动化测试

在这第三篇也是最后一篇文章中,我们将通过添加以下内容继续开发休息 API:

  • 猫鼬 允许我们使用MongoDB并将内存中的DAO替换为真实的数据库.
  • Authentication 和权限功能,以便API消费者可以使用JSON Web令牌(JWT)安全地访问我们的端点.
  • Automated 测试ing 使用Mocha(一个测试框架), 柴(一个断言库), 和SuperTest(一个HTTP抽象模块),以帮助检查代码库增长和变化时的回归.

Along the way, 我们将添加验证和安全库, 获得一些使用Docker的经验, 并提出几个进一步的主题, libraries, 以及读者在构建和扩展自己的休息 api时应该学习的技能.

安装MongoDB作为容器

让我们首先用一个真实的数据库替换上一篇文章中的内存数据库.

要创建用于开发的本地数据库,我们可以在本地安装MongoDB. 但是环境(操作系统发行版和版本)之间的差异, 例如)会带来问题. To avoid 这, 我们将利用这个机会来利用一个行业标准工具:Docker容器.

的 only thing 读ers need to do is inst所有 Docker 然后 inst所有 Docker Compose. 安装完成后,开始运行 码头工人- v 应该生成Docker版本号.

现在,为了运行MongoDB,我们将在项目的根目录下创建一个名为 docker-compose.yml 载有下列内容:

version: '3'
服务:
  蒙戈:
    image: 蒙戈
    卷:
      - ./data:/data/db
    港口:
      - "27017:27017"

Docker Compose允许我们使用一个配置文件同时运行多个容器. 在本文的最后, 我们也会看看在Docker中运行休息 API后端, but for now, 我们将使用它来运行MongoDB,而无需在本地安装它:

Sudo docker-compose up -d

up 命令将启动定义的容器,监听MongoDB的标准端口27017. 的 -d Switch将从终端中分离命令. 如果一切正常运行,我们应该看到这样的消息:

使用默认驱动程序创建网络"toptal-rest-series_default"
创建toptal-rest-series_蒙戈_1 ... 完成

它还将创造一个新的 data 目录,所以我们应该添加一个 data.gitignore.

现在,如果我们需要关闭MongoDB Docker容器,我们只需要运行 Sudo docker-compose down 和 we 应该 see the following output:

Stopping toptal-rest-series_蒙戈_1 ... 完成
删除toptal-rest-series_蒙戈_1 ... 完成
删除网络toptal-rest-series_default

这就是我们启动节点所需要知道的一切.. js/MongoDB 休息 API后端. 确保我们用过 Sudo docker-compose up -d so MongoDB is 读y for our 应用程序 to use.

使用猫鼬访问MongoDB

为了与MongoDB通信,我们的后端将利用一个对象 data modeling (ODM) library 呼叫。ed 猫鼬. 虽然猫鼬很容易使用,但值得一试 documentation 学习它为现实世界的项目提供的所有高级可能性.

To inst所有 猫鼬, we use the following:

npm i 猫鼬

让我们配置一个猫鼬服务来管理到MongoDB实例的连接. 由于此服务可以在多个资源之间共享,因此我们将其添加到 常见的 folder of our project.

的 配置uration is straightforward. 虽然没有严格要求,但我们会有一个 猫鼬Options 对象来自定义以下内容 猫鼬连接选项:

  • useNewUrlParser: Without 这 set to 真正的, 猫鼬 prints a deprecation warning.
  • useUnifiedTopology: 猫鼬文档建议将其设置为 真正的 使用较新的连接管理引擎.
  • 服务器SelectionTimeoutMS:用于本演示项目的用户体验, 比默认的30秒更短的时间意味着任何忘记在节点之前启动MongoDB的读者.Js将很快看到有用的反馈,而不是一个明显无响应的后端.
  • useFindAndModify: Setting 这 to 也避免了弃用警告,但在 deprecations section 而不是在猫鼬连接选项中. More specifi呼叫。y, 这导致猫鼬使用较新的本机MongoDB功能,而不是旧的猫鼬 shim.

将这些选项与一些初始化和重试逻辑结合起来,这是最后一个 常见的/服务/猫鼬.服务.ts 文件:

从'猫鼬'输入猫鼬;
从'debug'中导入debug;

const log: debug.IDebugger = debug('应用程序:猫鼬-服务');

类猫鼬Service {
    private count = 0;
    private 猫鼬Options = {
        useNewUrlParser: 真正的,
        useUnifiedTopology:没错,
        服务器SelectionTimeoutMS: 5000,
        useFindAndModify:假的,
    };

    constructor() {
        这.connectWithRetry();
    }

    get猫鼬() {
        return 猫鼬;
    }

    connectWithRetry = () => {
        日志('尝试连接MongoDB(如果需要将重试)');
        猫鼬
            .连接(“蒙戈db: / / localhost: 27017 / api-db’,这一点.猫鼬Options)
            .then(() => {
                日志('MongoDB已连接');
            })
            .catch((犯错) => {
                const retrySeconds = 5;
                日志(
                    ' MongoDB连接失败(将重试#${++这
                        .count} after ${retrySeconds} seconds):`,
                    犯错
                );
                setTimeout(这.connectWithRetry, retrySeconds * 1000);
            });
    };
}
出口 default new 猫鼬Service();

一定要把两者的区别弄清楚 connect() 从猫鼬和我们自己的功能 connectWithRetry() 服务 函数:

  • 猫鼬.connect() 尝试连接到我们的本地MongoDB服务(运行与 docker-compose),之后会暂停 服务器SelectionTimeoutMS milliseconds.
  • 猫鼬Service.connectWithRetry() 如果我们的应用程序启动,但MongoDB服务尚未运行,则重试上述操作. Since it’s in a singleton constructor, connectWithRetry() 将只运行一次,但它将重试 connect() 无限的呼唤,伴随着停顿 retrySeconds 超时时的秒数.

下一步是用MongoDB替换之前的内存数据库!

删除我们的内存数据库并添加MongoDB

Previously, 我们使用了内存数据库,使我们能够专注于我们正在构建的其他模块. 要使用猫鼬,我们必须完全重构 用户.刀.ts. We’ll need one more 进口 声明, to start:

导入猫鼬Service../../共同/服务/猫鼬.服务”;

Now let’s remove everything from the 用户Dao class definition except the constructor. 我们可以通过创建用户来重新填充它 模式 for 猫鼬 before the constructor:

模式 = 猫鼬Service.get猫鼬().模式;

用户模式 = new 这.模式({
    _id: String,
    电子邮件: String,
    密码: { type: String, select: 假 },
    firstName: String,
    lastName: String,
    permissionFlags:数量,
}, { id: 假 });

用户 = 猫鼬Service.get猫鼬().model('用户', 这.用户模式);

这定义了我们的MongoDB集合,并添加了一个我们的内存数据库没有的特殊特性: select: 假密码 字段将在获取用户或列出所有用户时隐藏此字段.

我们的用户模式看起来很熟悉,因为它类似于我们的DTO实体. 主要区别在于我们定义了哪些字段应该存在于我们的MongoDB集合中 用户,而DTO实体则定义在HTTP请求中接受哪些字段.

我们方法的这一部分没有改变,因此仍然在顶部导入我们的三个dto 用户.刀.ts. 但是在实现CRUD方法操作之前,我们将以两种方式更新dto.

DTO Change No. 1: id vs. _id

Because 猫鼬 automati呼叫。y makes an _id 字段可用,我们将删除 id field from the DTOs. 它将来自路由请求的参数.

注意,猫鼬模型提供了一个虚拟的 id getter by default, so we’ve disabled that option above with { id: 假 } to avoid confusion. 但这打破了我们的参考 用户.id in our 用户 middleware validateSameEmailBelongToSame用户 ()我们需要 用户._id there instead.

一些数据库使用该约定 id, others use _id,所以没有完美的界面. For our 例子 project using 猫鼬, 我们只是注意在代码的哪个点使用哪个, 但这种不匹配仍然会暴露给API消费者:

五种请求类型的路径. 对/用户的非参数化得到请求通过list用户()控制器并返回一个对象数组, 每个都有一个_id键. 2. 对/用户的非参数化帖子请求通过创建用户()控制器,这 uses a newly generated ID value, returning it in an object with an id key. 3. 对/身份验证的非参数化请求通过verify用户Password()中间件,这 does a MongoDB lookup to set 要求的事情.body.用户标识; from there, the 请求 goes through the 创建JWT() controller,这 uses 要求的事情.body.用户标识,并返回一个带有accessToken和refreshToken密钥的对象. 4. 对/身份验证/refresh-token的非参数化请求将通过validJWTNeeded()中间件,这 sets res.当地人.jwt.用户标识和validRefreshNeeded()中间件,后者使用res . 用户标识.当地人.jwt.用户标识,也做一个MongoDB查找设置要求的事情.body.用户标识; from there, the path goes through the same controller 和 response as the previous case. 5. 对/用户的参数化请求通过用户Routes配置,该配置填充要求的事情.参数个数.用户标识 通过 表达.然后是validJWTNeeded()中间件,它设置res . js.当地人.jwt.用户标识,然后是其他中间件函数(使用要求的事情 . id).参数个数.用户标识, res.当地人.jwt.用户标识, or both; 和/or do a MongoDB lookup 和 use result._id),最后通过用户Controller函数使用要求的事情.body.Id并返回无主体或带有_id键的对象.
在最终的休息 API项目中使用和公开用户id. 注意,各种内部约定意味着用户ID数据的不同来源:一个直接请求参数, JWT-encoded data, 或者一个 freshly fetched database record.

我们把它留给读者作为练习来实现其中的一个 可用的实际解决方案 在项目结束时.

DTO Change No. 2: Preparing for Flags-based Permissions

We’ll also rename permissionLevel to permissionFlags 以反映我们将实现的更复杂的权限系统, 以及上述猫鼬 用户模式 definition.

dto: DRY原则呢?

还记得, DTO只包含我们希望在API客户机和数据库之间传递的字段. 这可能看起来很不幸,因为模型和dto之间有一些重叠,但要注意不要以“默认安全性”为代价过多地推动DRY.如果添加一个字段只需要在一个地方添加, 开发人员可能会无意中在API中公开它,而它本来只是内部的. 这是因为该流程不会强迫他们将数据存储和数据传输视为两个独立的上下文,具有两组可能不同的需求.

完成DTO更改后,我们可以实现CRUD方法操作(在 用户Dao 构造函数),从 创建:

异步 add用户(用户Fields: 创建用户Dto) {
    const 用户标识 = shortid.generate();
    const 用户 = new 这.用户({
        _id: 用户标识,
        ...用户Fields,
        permissionFlags: 1,
    });
    等待 用户.save ();
    return 用户标识;
}

请注意,无论API使用者发送的目的是什么 permissionFlags 通过 用户Fields, we then ov犯错ide it with the value 1.

Next we have ,通过ID获取用户,通过电子邮件获取用户,并通过分页列出用户的基本功能:

异步 get用户ByEmail(电子邮件: string) {
    return 这.用户.findOne({电子邮件: 电子邮件}).exec ();
}

异步 get用户ById(用户标识: string) {
    return 这.用户.findOne({_id: 用户标识}).populate('用户').exec ();
}

异步 get用户(限制 = 25, 页面 = 0) {
    return 这.用户.find ()
        .限制(限制)
        .skip(限制 * 页面)
        .exec ();
}

To 更新 对于用户来说,一个DAO函数就足够了,因为底层的猫鼬 findOneAndUpdate() 函数可以更新整个文档或只是其中的一部分. Note that our own 函数 will take 用户Fields as either a Patch用户Dto 或者一个 Put用户Dto, using a TypeScript union type (signified by |):

异步 更新用户ById(
    用户标识: string,
    用户Fields: Patch用户Dto | Put用户Dto
) {
    const existing用户 =等待这个.用户.findOneAndUpdate(
        { _id: 用户标识 },
        { $set: 用户Fields },
        { new: 真正的 }
    ).exec ();

    return existing用户;
}

new: 真正的 选项告诉猫鼬返回更新后的对象, rather than how it origin所有y had been.

删除 与猫鼬简洁:

异步 remove用户ById(用户标识: string) {
    return 这.用户.deleteOne({_id: 用户标识}).exec ();
}

读者可能会注意到,每个调用 用户 成员函数链接到 exec () 呼叫。. This is optional, 但猫鼬开发人员推荐它,因为它在调试时提供了更好的堆栈跟踪.

编写完DAO之后,我们需要稍微更新一下 用户.服务.ts 来匹配我们上一篇文章中的新函数. 不需要进行大的重构,只需要做三个补充:

@@ -16,3 +16,3 @@类用户Service实现CRUD {
     异步 list(限制: number, 页面: number) {
-返回用户Dao.get用户();
+返回用户Dao.get用户(限制, 页面);
     }
@@ -20,3 +20,3 @@类用户Service实现CRUD {
     异步 patchById(id: string, resource: Patch用户Dto): Promise {
-返回用户Dao.patch用户ById (id、资源);
+返回用户Dao.更新用户ById (id、资源);
     }
@@ -24,3 +24,3 @@类用户Service实现CRUD {
     异步 putById(id: string, resource: Put用户Dto): Promise {
-返回用户Dao.put用户ById (id、资源);
+返回用户Dao.更新用户ById (id、资源);
     }

大多数函数调用保持完全相同,因为当我们重构 用户Dao,我们维护了在上一篇文章中创建的结构. But why the exceptions?

  • We’re using 更新用户ById() 对于这两个 补丁 as we hinted at above. (如第2部分所述, 我们遵循典型的休息 API实现,而不是试图遵循 具体的rfc. Among other things, 这 means 不 having 请求s 创建 new entities if they don’t exist; 这 way, 我们的后端不会把ID生成的控制权交给API消费者.)
  • We’re passing the 限制页面 parameters along to get用户() 因为我们的新DAO实现将使用它们.

这里的主要结构是一个相当健壮的模式. For instance, 如果开发人员想要将猫鼬和MongoDB换成像TypeORM和PostgreSQL这样的东西,它可以被重用. 如上所述, 这种替换只需要重构DAO的各个函数,同时维护它们的签名以匹配其余代码.

Testing 我们的 猫鼬-backed 休息 API

让我们启动API后端 npm开始. 然后我们将尝试创建一个用户:

curl——请求 帖子 'localhost:3000/用户' \
——header 'Content-Type: 应用程序lication/json' \
--data-raw '{
    "密码":"secr3tPass!23",
    "电子邮件":"marcos.henrique@jlc866.com”
}'

的 response object contains a new 用户 ID:

{
    "id": "7WYQoVZ3E"
}

与前一篇文章中一样,使用环境变量将使剩余的手动测试更容易:

休息_API_EXAMPLE_ID="put_your_id_here"

更新用户看起来像这样:

curl——include——请求 补丁 "localhost:3000/用户/$休息_API_EXAMPLE_ID" \
——header 'Content-Type: 应用程序lication/json' \
--data-raw '{
    "firstName": "Marcos",
    "lastName": "Silva"
}'

回应应该从 HTTP / 1.1 204 No Content. (Without the ——包括 切换时,没有响应就会打印出来,这是符合我们的实现的.)

如果我们现在让用户检查上面的更新…

curl——请求 得到“localhost:3000/用户/$休息_API_EXAMPLE_ID”\
——header 'Content-Type: 应用程序lication/json' \
--data-raw '{
    "firstName": "Marcos",
    "lastName": "Silva"
}'

响应显示预期的字段,包括 _id field discussed above:

{
    "_id": "7WYQoVZ3E",
    "电子邮件": "marcos.henrique@jlc866.com”,
    "permissionFlags": 1,
    “__v”:0,
    "firstName": "Marcos",
    "lastName": "Silva"
}

还有一个特殊的领域, __v, used by 猫鼬 for versioning; it will be incremented each time 这 record is 更新d.

接下来,让我们列出用户:

curl --请求 得到 'localhost:3000/用户' \
--header 'Content-Type: 应用程序lication/json'

预期的响应是相同的,只是包装在 [].

现在我们的密码已经被安全地存储了,让我们来确保可以删除用户:

curl——include——请求 DELETE“localhost:3000/用户/$休息_API_EXAMPLE_ID”\
--header 'Content-Type: 应用程序lication/json'

我们预计会再次收到204的回复.

读者可能想知道密码字段是否正常工作,因为我们的 select: 假 在 猫鼬 模式 定义隐藏了它 得到 output as intended. 我们重复一下我们的开头 帖子 要再次创建用户,请选中. (不要忘记存储新ID以备以后使用.)

隐藏密码和直接数据调试MongoDB容器

检查密码是否已安全储存(例如.e.(散列,而不是纯文本),开发人员可以直接检查MongoDB数据. 一种方法是访问标准 蒙戈 从运行的Docker容器内的CLI客户端:

命令docker执行命令-它的total -rest-series_蒙戈_1

From there, executing use api-db followed by db.用户.find ().漂亮的() 会列出所有用户数据,包括密码吗.

那些喜欢GUI的人可以安装一个单独的MongoDB客户端,像 无袖长衫3 t:

左边的边栏显示数据库连接, 每一个都包含一个层次结构,比如数据库, 功能, 和用户. 的 main pane has tabs for running queries. 的 current tab is connected to the api-db database of localhost:27017 with the query "db.getCollection('用户').find({})" with one result. 结果包含四个字段:_id、密码、电子邮件和__v. 的 密码 field starts with "argon2美元$i$v=19$m=4096,t=3,p=1$" 和 ends with a 盐 和 hash, 用美元符号分隔并以64进制编码.
使用无袖长衫3 t直接检查MongoDB数据.

的 密码 prefix (argon2美元...) is part of the PHC string format, 如果黑客设法窃取了数据库,那么提到Argon2及其一般参数的事实将无法帮助他们确定原始密码. 存储的密码可以进一步加强使用 , a technique we’ll use below with JWTs. 当两个用户输入相同的密码时,存储值之间的差异将留给读者作为练习.

现在我们知道猫鼬成功地将数据发送到MongoDB数据库. 但是我们怎么知道我们的API消费者将在他们的请求中向我们的用户路由发送适当的数据呢?

添加表达-validator

有几种方法可以完成字段验证. 在本文中,我们将使用表达-validator,这 is quite stable, easy to use, 并且有完整的记录. 虽然我们可以使用验证功能 那是猫鼬的表达-validator提供了额外的特性. For 例子, 它附带了一个开箱即用的电子邮件地址验证器, 在猫鼬中需要我们编写一个自定义验证器吗.

Let’s inst所有 it:

NPM I 表达-validator

要设置要验证的字段,我们将使用 身体() 方法,我们将在 用户.路线.配置.ts. 的 身体() 方法将验证字段并生成错误列表(存储在 表达.请求 失败情况下的对象.

然后,我们需要自己的中间件来检查和使用错误列表. 由于这个逻辑对于不同的路由可能以相同的方式工作,让我们创建 常见的/middleware/body.validation.middleware.ts with the following:

从“表达”中输入表达;
从“表达-validator”中导入{validationResult};

类Body有效的ationMiddleware {
    verifyBodyFieldsErrors (
        要求的事情: 表达.请求,
        res: 表达.Response,
        next: 表达.NextFunction
    ) {
        const 错误 = validationResult(要求的事情);
        if (!错误.isEmpty()) {
            return res.status(400).发送({ 错误: 错误.array() });
        }
        next ();
    }
}

导出默认的新的Body有效的ationMiddleware();

方法生成的任何错误都可以处理 身体() 函数. 让我们重新添加以下内容 用户.路线.配置.ts:

进口 Body有效的ationMiddleware from '../共同/中间件/身体.validation.middleware';
进口 { body } from '表达-validator';

现在我们可以用下面的代码更新我们的路由:

@@ -15,3 +17,6 @@导出类用户Routes扩展CommonRoutesConfig {
             .邮报》(
-                用户Middleware.validateRequired用户BodyFields,
+                身体(电子邮件).isEmail (),
+                身体(密码)
+                    .isLength({ min: 5 })
+                    .withMessage('必须包含密码(5+字符)'),
+                Body有效的ationMiddleware.verifyBodyFieldsErrors,
                 用户Middleware.validateSameEmailDoesntExist,
@@ -28,3 +33,10 @@导出类用户Routes扩展CommonRoutesConfig {
         这.应用程序.put(`/用户/:用户标识`, [
-            用户Middleware.validateRequired用户BodyFields,
+            身体(电子邮件).isEmail (),
+            身体(密码)
+                .isLength({ min: 5 })
+                .withMessage('必须包含密码(5+字符)'),
+            身体(“firstName”).isString(),
+            身体(姓).isString(),
+            body('permissionFlags').isInt (),
+            Body有效的ationMiddleware.verifyBodyFieldsErrors,
             用户Middleware.validateSameEmailBelongToSame用户,
@@ -34,2 +46,11 @@导出类用户Routes扩展CommonRoutesConfig {
         这.应用程序.补丁(/用户/:用户标识, [
+            身体(电子邮件).isEmail ().optional(),
+            身体(密码)
+                .isLength({ min: 5 })
+                .withMessage('密码必须是5+字符')
+                .optional(),
+            身体(“firstName”).isString().optional(),
+            身体(姓).isString().optional(),
+            body('permissionFlags').isInt ().optional(),
+            Body有效的ationMiddleware.verifyBodyFieldsErrors,
             用户Middleware.validatePatchEmail,

Be sure to add Body有效的ationMiddleware.verifyBodyFieldsErrors 在每条路线之后 身体() 行,否则它们都不会产生影响.

注意我们是如何更新的 帖子 使用表达-validator而不是我们自己开发的 validateRequired用户BodyFields 函数. 由于这些路由是唯一使用此函数的路由,因此可以从 用户.middleware.ts.

That’s it! 读取器可以重启节点.使用他们最喜欢的休息客户端来测试结果,看看它是如何处理各种输入的. 不要忘记探索表达验证器 documentation for further possibilities; our 例子 is just a starting point for 请求 validation.

有效的 data is one aspect to ensure; valid 用户 和 actions are a不her.

Authentication vs. Permissions (or “Authorization”) Flow

我们的节点.Js应用程序公开了一套完整的 用户/ 端点,允许API使用者创建、更新和列出用户. 但是每个端点都允许无限制的公共访问. 这是一种常见的模式,可以防止用户更改彼此的数据,防止外部人员访问我们不希望公开的任何端点.

这些限制涉及两个主要方面,它们都可以缩写为“授权”.” Authentication is about who the 请求 is from 和 身份验证orization 是关于他们是否被允许做他们所要求的. 重要的是要知道正在讨论的是哪一个. 即使没有简短的形式,标准的HTTP响应代码 设法混淆这个问题: 401 Un身份验证orized 是关于认证和 403 Forbidden is about 身份验证orization. 在模块名中,我们宁可让“身份验证”代表“身份验证entication”, 并且使用“许可”来表示授权事项.

<块quote>

即使没有简短的表单,标准HTTP响应代码也会混淆这个问题: 401 Un身份验证orized 是关于认证和 403 Forbidden is about 身份验证orization.

有很多身份验证方法需要探索, 包括像Auth0这样的第三方身份提供商. 在本文中,我们选择了一个基本但可扩展的实现. It’s based on JWTs.

JWT由加密的JSON和一些与身份验证无关的元数据组成, 在我们的例子中包括用户的电子邮件地址和权限标志. JSON还将包含一个秘密,用于验证元数据的完整性.

其思想是要求客户端在每个非公共请求中发送一个有效的JWT. 这让我们可以验证客户端最近是否拥有想要使用的端点的有效凭据, 而不必在每个请求中通过网络发送凭据本身.

但是,这在我们的示例API代码库中适合哪里呢? 简单:我们可以在我们的路由配置中使用中间件!

添加认证模块

让我们首先配置jwt中的内容. 这里我们将开始使用 permissionFlags 字段,但这只是因为它方便在jwt中加密元数据,而不是因为jwt本身与细粒度权限逻辑有任何关系.

在创建jwt生成中间件之前,我们需要添加一个特殊的函数 用户.刀.ts 检索密码字段,因为我们将猫鼬设置为通常避免检索密码字段:

异步 get用户ByEmailWithPassword (电子邮件: string) {
    return 这.用户.findOne({电子邮件: 电子邮件})
        .select('_id 电子邮件 permissionFlags +密码')
        .exec ();
}

And 在 用户.服务.ts:

异步 get用户ByEmailWithPassword (电子邮件: string) {
    return 用户Dao.get用户ByEmailWithPassword (电子邮件);
}

Now, let’s 创建 an 身份验证 文件夹——我们将添加一个端点,以允许API使用者生成jwt. 首先,让我们为它创建一个中间件 身份验证/middleware/身份验证.middleware.ts, as a singleton 呼叫。ed AuthMiddleware.

We’ll need some 进口s:

从“表达”中输入表达;
导入用户Service../../用户/服务/用户.服务”;
从'argon2'导入* as argon2;

AuthMiddleware class, 我们将创建一个中间件函数来检查API用户是否在其请求中包含了有效的登录凭据:

异步verify用户Password (
    要求的事情: 表达.请求,
    res: 表达.Response,
    next: 表达.NextFunction
) {
    const 用户: any = 等待 用户Service.get用户ByEmailWithPassword (
        要求的事情.body.电子邮件
    );
    if (用户) {
        const 密码Hash = 用户.密码;
        if (等待 argon2.验证(密码Hash点播.body.密码)) {
            要求的事情.Body = {
                用户标识: 用户._id,
                电子邮件: 用户.电子邮件,
                permissionFlags: 用户.permissionFlags,
            };
            return next ();
        }
    }
    // Giving the same message in both cases
    //帮助防止破解尝试:
    res.status(400).发送({错误:['无效电子邮件和/或密码']});
}

至于中间件要保证 电子邮件密码 存在于 要求的事情.body,我们将在稍后配置路由时使用表达-validator verify用户Password() 函数.

Storing JWT Secrets

To generate a JWT, 我们需要一个JWT秘密, 我们将使用它来对生成的jwt进行签名,并验证来自客户机请求的传入jwt. 而不是在TypeScript文件中硬编码JWT秘密的值, 我们将把它存储在一个单独的“环境变量”文件中, .env,这 应该 never be pushed to a code repository.

As is 常见的 practice, we’ve added an .env.例子 文件到repo中,以帮助开发人员了解在创建真正的 .env. In our case, we want a variable 呼叫。ed JWT_SECRET 将JWT秘密存储为字符串. 等到本文结束并使用repo的最后一个分支的读者需要这样做 remember to change these values lo呼叫。y.

现实世界的项目尤其需要跟进 JWT best practices by 根据环境区分JWT秘密 (开发、准备、制作等.).

我们的 .env 文件(在项目的根目录下)必须使用以下格式,但不应该保留相同的秘密值:

JWT_SECRET=My!@!Se3cr8tH4sh3

将这些变量加载到应用程序的一个简单方法是使用一个名为dotenv的库:

npm i dotenv

惟一需要的配置是调用 dotenv.配置() 函数,只要我们启动应用程序. At the very top of 应用程序.ts, we’ll add:

从'dotenv'导入dotenv;
const dotenvResult = dotenv.配置();
if (dotenvResult.错误){
    throw dotenvResult.错误;
}

认证控制器

生成JWT的最后一个先决条件是安装jsonwebtoken库和它的TypeScript类型:

npm i jsonwebtoken
npm i --save-dev @types/jsonwebtoken

Now, let’s 创建 the /身份验证 controller at 身份验证/controllers/身份验证.controller.ts. 我们不需要导入dotenv库,因为导入它 应用程序.ts 的内容 .env 文件可通过节点在整个应用程序中获得.Js的全局对象 过程:

从“表达”中输入表达;
从'debug'中导入debug;
从'jsonwebtoken'导入JWT;
从'加密'导入加密;

const log: debug.IDebugger = debug('应用程序:身份验证-controller');

/**
*该值自动从 .Env,一个你将拥有的文件
*在项目的根部为自己创建.
*
*看到 .env.示例中的所需格式的repo.
*/
// @ts-预计-犯错or
const jwtSecret: string = 过程.env.JWT_SECRET;
const tokenExpirationInSeconds = 36000;

class AuthController {
    异步创建jwt (要求的事情: 表达.请求, res: 表达.Response) {
        尝试{
            const refreshId = 要求的事情.body.用户标识 + jwtSecret;
            const 盐 = 加密.创建SecretKey(加密.r和omBytes(16));
            const hash = 加密
                .创建Hmac(“sha512”、盐)
                .更新(refreshId)
                .digest('base64');
            要求的事情.body.refreshKey = 盐.出口();
            const token = jwt.号(要求.body, jwtSecret, {
                expiresIn: tokenExpirationInSeconds,
            });
            return res
                .status(201)
                .发送({accessToken: token, refreshToken: hash});
        } catch (犯错) {
            日志('创建JWT错误:%O', 犯错);
            return res.status(500).send ();
        }
    }
}

出口 default new AuthController();

jsonwebtoken库将使用我们的 jwtSecret. 我们还将使用节点生成一个盐和一个散列.js-native 加密 模块,然后使用它们来创建 refreshToken API消费者可以用它来刷新当前的jwt——这种设置对于应用程序来说是特别好的 be able to scale.

两者有什么区别 refreshKey, refreshToken, accessToken? 的 *令牌发送给我们的API消费者的想法是 accessToken 用于任何超出公众可用范围的请求,并且 refreshToken 用于申请更换过期的产品 accessToken. 的 refreshKey, on the other h和, is used to pass the 变量加密 refreshToken回到我们的刷新中间件,我们将在下面讨论.

注意,我们的实现让jsonwebtoken为我们处理令牌过期. 如果JWT过期,客户端将需要再次进行身份验证.

Initial 节点.. js 休息 API鉴权路由

现在让我们配置端点 身份验证/身份验证.路线.配置.ts:

进口 { CommonRoutesConfig } from '../常见/常见.路线.配置”;
导入身份验证Controller./controllers/身份验证.controller';
导入身份验证Middleware./middleware/身份验证.middleware';
从“表达”中输入表达;
进口 Body有效的ationMiddleware from '../共同/中间件/身体.validation.middleware';
进口 { body } from '表达-validator';

导出类AuthRoutes扩展CommonRoutesConfig {
    构造函数(应用程序:表达.Application) {
        超级(应用,“AuthRoutes”);
    }

    配置ureRoutes():表达.Application {
        这.应用程序.邮报》(`/身份验证`, [
            body('电子邮件').isEmail (),
            body('密码').isString(),
            Body有效的ationMiddleware.verifyBodyFieldsErrors,
            身份验证Middleware.verify用户Password,
            身份验证Controller.创建JWT,
        ]);
        return 这.应用程序;
    }
}

别忘了把它加到我们的 应用程序.ts 文件:

// ...
导入{AuthRoutes}./身份验证/身份验证.路线.配置”;
// ...
路线.push(new AuthRoutes(应用程序)); // independent: can go before 或者一个fter 用户Route
// ...

我们已经准备好重新启动节点.Js和现在测试,确保我们匹配我们之前用来创建测试用户的任何凭据:

curl --请求 帖子 'localhost:3000/身份验证' \
——header 'Content-Type: 应用程序lication/json' \
--data-raw '{
    "密码":"secr3tPass!23",
    "电子邮件":"marcos.henrique@jlc866.com”
}'

的 response will be something 就像:

{
    :“accessToken eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8”,
    :“refreshToken cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ = = "
}

和之前一样,为了方便起见,让我们使用上面的值设置一些环境变量:

休息_API_EXAMPLE_ACCESS = " put_your_access_token_here "
休息_API_EXAMPLE_REFRESH = " put_your_refresh_token_here "

伟大的! 我们有访问令牌和刷新令牌, 但是我们需要一些中间件来处理它们.

JWT Middleware

我们需要一个新的TypeScript类型来处理JWT结构的解码形式. 创建 常见的/types/jwt.ts with 这 in it:

出口 type Jwt = {
    refreshKey: string;
    用户标识: string;
    permissionFlags:字符串;
};

让我们实现中间件函数来检查是否存在刷新令牌, 验证刷新令牌, to verify a JWT. 这三个都可以放到一个新文件里, 身份验证/middleware/jwt.middleware.ts:

从“表达”中输入表达;
从'jsonwebtoken'导入JWT;
从'加密'导入加密;
进口 { Jwt } from '../../常见/types/jwt';
导入用户Service../../用户/服务/用户.服务”;

// @ts-预计-犯错or
const jwtSecret: string = 过程.env.JWT_SECRET;

class JwtMiddleware {
    verifyRefreshBodyField (
        要求的事情: 表达.请求,
        res: 表达.Response,
        next: 表达.NextFunction
    ) {
        如果要求的事情.body && 要求的事情.body.refreshToken) {
            return next ();
        } 其他的 {
            return res
                .status(400)
                .发送({错误: ['Missing 要求的事情uired field: refreshToken']});
        }
    }

    异步validRefreshNeeded (
        要求的事情: 表达.请求,
        res: 表达.Response,
        next: 表达.NextFunction
    ) {
        const 用户: any = 等待 用户Service.get用户ByEmailWithPassword (
            res.当地人.jwt.电子邮件
        );
        const 盐 = 加密.创建SecretKey(
            缓冲.从(res.当地人.jwt.refreshKey.数据)
        );
        const hash = 加密
            .创建Hmac(“sha512”、盐)
            .更新(res.当地人.jwt.用户标识 + jwtSecret)
            .digest('base64');
        if (hash === 要求的事情.body.refreshToken) {
            要求的事情.Body = {
                用户标识: 用户._id,
                电子邮件: 用户.电子邮件,
                permissionFlags: 用户.permissionFlags,
            };
            return next ();
        } 其他的 {
            return res.status(400).发送({错误:['无效的刷新令牌']});
        }
    }

    validJWTNeeded(
        要求的事情: 表达.请求,
        res: 表达.Response,
        next: 表达.NextFunction
    ) {
        如果要求的事情.标题(“授权”)){
            尝试{
                Const 身份验证orization = 要求的事情.标题(“授权”).split(' ');
                if (身份验证orization[0] !== 'Bearer') {
                    return res.status(401).send ();
                } 其他的 {
                    res.当地人.JWT = JWT.验证(
                        身份验证orization[1],
                        jwtSecret
                    )为Jwt;
                    next ();
                }
            } catch (犯错) {
                return res.status(403).send ();
            }
        } 其他的 {
            return res.status(401).send ();
        }
    }
}

出口 default new JwtMiddleware();

validRefreshNeeded() 函数还验证刷新令牌对于特定用户ID是否正确. 如果是,那么下面我们将重用 身份验证Controller.创建JWT 为用户生成新的JWT.

We also have validJWTNeeded(),它验证API使用者是否在HTTP头中发送了一个有效的JWT convention Authorization: Bearer . (是的,这是另一个不幸的“身份验证”混淆.)

现在配置一个新路由来刷新令牌和其中编码的权限标志.

JWT Refresh Route

In 身份验证.路线.配置.ts 我们将导入新的中间件:

导入jwtMiddleware./middleware/jwt.middleware';

然后我们将添加以下路由:

这.应用程序.邮报》(' /认证/ refresh-token ',
    jwtMiddleware.validJWTNeeded,
    jwtMiddleware.verifyRefreshBodyField,
    jwtMiddleware.validRefreshNeeded,
    身份验证Controller.创建JWT,
]);

现在我们可以测试它是否可以正常工作 accessTokenrefreshToken we received earlier:

curl——请求 帖子 'localhost:3000/身份验证/refresh-token' \
——header 'Content-Type: 应用程序lication/json' \
——header "Authorization: Bearer $休息_API_EXAMPLE_ACCESS" \
--data-raw "{
    休息_API_EXAMPLE_REFRESH美元\“refreshToken \”,\“\”
}"

我们应该会收到一份新的 accessToken 和 a new refreshToken to be used later. 我们把它留给读者作为练习,以确保后端使以前的令牌无效,并限制新令牌的请求频率.

现在,我们的API使用者能够创建、验证和刷新jwt. Let’s look at some permissions concepts, 然后实现一个,并将其与用户路由中的JWT中间件集成.

用户 Permissions

一旦我们知道API客户端是谁, 我们想知道他们是否被允许使用他们所请求的资源. 管理每个用户的权限组合是很常见的. 不增加太多复杂性, 这比传统的“访问级”策略具有更大的灵活性. 不管我们对每个权限使用的业务逻辑是什么, 创建处理它的通用方法非常简单.

Bitwise AND (&) 和 Powers of Two

为了管理权限,我们将利用JavaScript的内置功能 bitwise AND 运营商, &. 这种方法使我们能够将一整套权限信息存储为单个信息, per-用户 number, 它的每个二进制数字表示用户是否有权限做某事. 但是没有必要过于担心它背后的数学问题——关键是它很容易使用.

我们所需要做的就是定义每一种权限(一个权限) 国旗)作为… power of 2 (1, 2, 4, 8, 16, 32, …). 然后我们可以将业务逻辑附加到每个标志上,最多31个标志. 例如,一个音频可访问的国际博客可能具有以下权限:

  • 1: 作者 can edit text.
  • 2: Illustrators 可以代替插图.
  • 4: 叙述者 可以替换任何段落对应的音频文件吗.
  • 8: Translators can edit translations.

这种方法允许用户使用各种权限标志组合:

  • 作者(或编辑)的权限标志值将仅为数字1.
  • 插图画家的许可标志将是数字2. But some 身份验证ors are also illustrators. 在这种情况下,我们将相关权限值相加:1 + 2 = 3.
  • 叙述者的旗子为4. 在作者叙述自己的作品的情况下,它将是1 + 4 = 5. If they also illustrate, it’s 1 + 2 + 4 = 7.
  • 翻译器的权限值为8. 多语种作者的标志是1 + 8 = 9. 同时担任旁白的翻译(但不是作者)将拥有4 + 8 = 12.
  • 如果我们想要一个sudo管理员, 拥有所有组合权限, we can 简单的 use 2,147,483,647, 32位整数的最大安全值是多少.

读者可以用纯JavaScript测试这个逻辑:

  • 权限5的用户试图编辑文本(权限标志1):

输入: 5 & 1

输出: 1

  • 权限为1的用户试图叙述(权限标志为4):

输入: 1 & 4

输出: 0

  • 用户 with permission 12 trying to narrate:

输入: 12 & 4

输出: 4

当 the output is 0, we 块 the 用户; otherwise, 我们允许他们访问他们想要访问的内容.

权限标志实现

We’ll store permissions 国旗s inside the 常见的 文件夹,因为业务逻辑可以与未来的模块共享. 我们从加an开始 枚举 来持有一些权限标志 常见/中间件/常见.permission国旗.枚举.ts:

出口 枚举 PermissionFlag {
    FREE_PERMISSION = 1,
    PAID_PERMISSION = 2,
    A不her_paid_permission = 4,
    ADMIN_PERMISSION = 8,
    All_permissions = 2147483647,
}

注意:由于这是一个示例项目,因此我们保持旗标名称相当通用.

在我们忘记之前,现在是快速回到 add用户 () 函数将临时幻数1替换为 PermissionFlag.FREE_PERMISSION. 我们还需要一个对应的 进口 声明.

我们也可以将它导入到一个新的中间件文件中 常见/中间件/常见.permission.middleware.ts 的单例类 CommonPermissionMiddleware:

从“表达”中输入表达;
导入{PermissionFlag}./常见.permission国旗.枚举 ';
从'debug'中导入debug;

const log: debug.IDebugger = debug('应用程序:常见的-permission-middleware');

而不是创建几个类似的中间件函数,我们将使用 工厂 pattern to 创建 a special 工厂 method (or 工厂 函数 或者简单地 工厂). 我们的工厂函数将允许我们在路由配置时生成中间件函数,以检查所需的任何权限标志. With that, 我们避免了在添加新的权限标志时手动复制中间件功能.

下面是一个工厂,它将生成一个中间件函数,检查我们传递给它的任何权限标志:

PermissionFlag 要求的事情uired (要求的事情uiredPermissionFlag: PermissionFlag) {
    回报(
        要求的事情: 表达.请求,
        res: 表达.Response,
        next: 表达.NextFunction
    ) => {
        尝试{
            const 用户PermissionFlags = parseInt(
                res.当地人.jwt.permissionFlags
            );
            如果(用户PermissionFlags & 要求的事情uiredPermissionFlag) {
                next ();
            } 其他的 {
                res.status(403).send ();
            }
        } catch (e) {
            日志(e);
        }
    };
}

更自定义的情况是,应该能够访问特定用户记录的唯一用户是相同的用户或管理员:

异步 onlySame用户OrAdminCanDoThisAction(
    要求的事情: 表达.请求,
    res: 表达.Response,
    next: 表达.NextFunction
) {
    const 用户PermissionFlags = parseInt(res.当地人.jwt.permissionFlags);
    if (
        要求的事情.参数个数 &&
        要求的事情.参数个数.用户标识 &&
        要求的事情.参数个数.用户标识 == = res.当地人.jwt.用户标识
    ) {
        return next ();
    } 其他的 {
        如果(用户PermissionFlags & PermissionFlag.ADMIN_PERMISSION) {
            return next ();
        } 其他的 {
            return res.status(403).send ();
        }
    }
}

我们将添加最后一个中间件,这次是 用户.middleware.ts:

异步用户CantChangePermission (
    要求的事情: 表达.请求,
    res: 表达.Response,
    next: 表达.NextFunction
) {
    if (
        请求中的'permissionFlags'.body &&
        要求的事情.body.permissionFlags != = res.当地人.用户.permissionFlags
    ) {
        res.status(400).发送({
            犯错or:['用户不能更改权限标志'],
        });
    } 其他的 {
        next ();
    }
}

And because the above 函数 depends on res.当地人.用户,我们可以把这个值填充进去 validate用户Exists() before the next () 电话:

// ...
if (用户) {
    res.当地人.用户 = 用户;
    next ();
} 其他的 {
// ...

In fact, doing 这 in validate用户Exists() 会使它变得不必要吗 validateSameEmailBelongToSame用户 (). We can eliminate our database 呼叫。 there, 将其替换为我们可以指望被缓存的值 res.当地人:

-        const 用户 = 等待 用户Service.get用户ByEmail(要求的事情.body.电子邮件);
-        if (用户 && 用户.id === 要求的事情.参数个数.用户标识) {
+        if (res.当地人.用户._id === 要求的事情.参数个数.用户标识) {

现在我们准备将权限逻辑集成到 用户.路线.配置.ts.

Requiring Permissions

First, we’ll 进口 our new middleware 和 枚举:

导入jwtMiddleware../身份验证/middleware/jwt.middleware';
导入permissionMiddleware../共同/中间件/常见.permission.middleware';
导入{PermissionFlag}../共同/中间件/常见.permission国旗.枚举 ';

我们希望用户列表只能由具有管理权限的人提出的请求访问, 但我们仍然希望能够创建一个公开的新用户, 就像正常的用户体验期望流程一样. 让我们在控制器之前先使用工厂函数来限制用户列表:

这.应用程序
    .route(`/用户`)
    .get (
        jwtMiddleware.validJWTNeeded,
        permissionMiddleware.permissionFlagRequired (
            PermissionFlag.ADMIN_PERMISSION
        ),
        用户Controller.list用户
    )
// ...

还记得 that the 工厂 呼叫。 here ((...)) 返回中间件函数-因此所有正常的,非工厂中间件被引用而不调用(()).

A不her 常见的 restriction is that for 所有 路线 that include a 用户标识,我们希望只有相同的用户或管理员具有访问权限:

             .路线(/用户/:用户标识)
-            .(用户Middleware.validate用户Exists)
+            .(
+                用户Middleware.validate用户Exists,
+                jwtMiddleware.validJWTNeeded,
+                permissionMiddleware.onlySame用户OrAdminCanDoThisAction
+            )
             .get (用户Controller.get用户ById)

我们还将阻止用户通过添加 用户Middleware.用户CantChangePermission, just before the 用户Controller 函数引用在每个 补丁 路线.

但是让我们进一步假设我们的休息 API业务逻辑只允许用户使用 PAID_PERMISSION 来更新他们的信息. 这可能与其他项目的业务需求一致,也可能不一致:这只是为了测试付费和免费许可之间的差异.

方法之后添加另一个生成器调用可以实现这一点 用户CantChangePermission 我们刚刚添加的参考:

permissionMiddleware.permissionFlagRequired (
    PermissionFlag.PAID_PERMISSION
),

With that, we’re 读y to restart 节点.js 和 try it out.

手动权限测试

为了测试路线,让我们试着 得到 the 用户 list without an access token:

curl——include——请求 得到 'localhost:3000/用户' \
--header 'Content-Type: 应用程序lication/json'

我们收到一个HTTP 401响应,因为我们需要使用有效的JWT. 让我们尝试使用先前身份验证中的访问令牌:

curl——include——请求 得到 'localhost:3000/用户' \
——header 'Content-Type: 应用程序lication/json' \
——header "Authorization: Bearer $休息_API_EXAMPLE_ACCESS"

这次我们得到一个HTTP 403. 我们的令牌是有效的,但是我们被禁止使用这个端点,因为我们没有 ADMIN_PERMISSION.

We 应该n’t need it to 得到 我们自己的用户记录:

curl——请求 得到“localhost:3000/用户/$休息_API_EXAMPLE_ID”\
——header 'Content-Type: 应用程序lication/json' \
——header "Authorization: Bearer $休息_API_EXAMPLE_ACCESS"

的 response:

{
    "_id": "UdgsQ0X1w",
    "电子邮件": "marcos.henrique@jlc866.com”,
    "permissionFlags": 1,
    “__v”:0
}

In contrast, trying to 更新 我们自己的用户记录应该失败,因为我们的权限值是1 (FREE_PERMISSION 只):

curl——include——请求 补丁 "localhost:3000/用户/$休息_API_EXAMPLE_ID" \
——header 'Content-Type: 应用程序lication/json' \
——header "Authorization: Bearer $休息_API_EXAMPLE_ACCESS" \
--data-raw '{
    "firstName": "Marcos"
}'

响应如预期的那样是403.

作为读者练习,我推荐 changing the 用户 permissionFlags at the local database doing a new post to /身份验证 生成一个令牌 permissionFlags), then trying to 补丁 the 用户 again. 请记住,您需要将标志设置为其中一个的数值 PAID_PERMISSION or ALL_PERMISSIONS, since our business logic specifies that ADMIN_PERMISSION 它本身不能让你给其他用户甚至你自己打补丁.

对新岗位的要求是 /身份验证 提出了一个值得记住的安全方案. 例如,当站点所有者更改用户的权限时, 试图锁定行为不端的用户——直到下次刷新JWT时,用户才会看到此操作生效. 这是因为权限检查使用JWT数据本身来避免额外的数据库访问.

Auth0之类的服务可以通过提供自动令牌旋转来提供帮助, 但用户仍然会在旋转之间的时间体验到意想不到的应用行为, 不管这通常有多短. 为了缓解这种情况,开发人员必须积极地注意 revoke refresh tokens 响应权限更改.


在使用休息 API时, 开发人员可以通过定期运行一堆cURL命令来防止潜在的错误. 但这是缓慢且容易出错的,而且很快就会变得乏味.

Automated Testing

As an API grows, 维护软件质量变得很困难, 尤其是在业务逻辑频繁变化的情况下. 尽可能减少API错误,并自信地部署新的更改, 为应用的前端和/或后端设置测试套件是很常见的.

Rather than dive into 编写测试和可测试代码,我们将展示一些基本的机制,并为读者提供一个可用的测试套件.

处理剩余测试数据

在我们进行自动化之前,有必要考虑一下测试数据会发生什么.

我们使用Docker Compose来运行本地数据库, 期望使用此数据库进行开发, 不 as a live production data source. 我们将在这里运行的测试将影响本地数据库,因为每次运行时都会留下一组新的测试数据. 在大多数情况下,这应该不是一个问题,但如果是,我们留给读者改变的练习 docker-compose.yml 为测试目的创建一个新数据库.

在现实世界中,开发人员经常将自动化测试作为测试的一部分来运行 持续集成管道. To do that, 在管道级别配置一种为每个测试运行创建临时数据库的方法是有意义的.

我们将使用Mocha, 柴和SuperTest来创建我们的测试:

NPM I -save-dev chai 摩卡 super测试 @types/chai @types/表达 @types/摩卡 @types/super测试 ts-node

Mocha将管理我们的应用程序并运行测试, 柴将允许更可读的测试表达式, 和SuperTest将通过像休息客户端那样调用我们的API来促进端到端(E2E)测试.

We’ll need to 更新 our scripts at 包.json:

"scripts": {
// ...
    "测试": "摩卡 -r ts-node/register '测试/**/* ..测试.ts '——unh和led-rejections =严格”,
    "测试-debug": "出口 DEBUG=* . && npm测试”
},

这将允许我们在创建的文件夹中运行测试,该文件夹名为 测试.

A Meta-测试

为了测试我们的测试基础设施,让我们创建一个文件, 测试/应用程序.测试.ts:

从'chai'导入{预计};
describe('Index Test', 函数 () {
    it('应该 always pass', 函数 () {
        预计(真正的).to.equal(真正的);
    });
});

这里的语法可能看起来不寻常,但它是正确的. We define 测试s by 期望()ing behavior within it() -我们指的是要传递给的函数体 it()——它们被称为内在 describe() 块.

现在,在终端,我们将运行:

npm run 测试

We 应该 see 这:

> 摩卡 -r ts-node/register '测试/**/*.测试.ts '——unh和led-rejections =严格

  Index Test
    ✓ 应该 always pass


  1 passing (6ms)

伟大的! 我们的测试库已经安装好,可以使用了.

Streamlining Testing

保持测试输出干净, 我们希望在正常测试运行期间完全关闭Winston请求日志记录. 这就像快速更改非调试一样简单 其他的 分公司 应用程序.ts to detect whether the it() 来自Mocha的函数存在:

if (!过程.env.调试){
     loggerOptions.meta = 假; // when 不 debugging, make terse
+    if (typeof global.it === '函数') {
+        loggerOptions.level = 'http'; // for non-debug 测试 runs, squelch entirely
+    }
 }

我们需要添加的最后一个操作是导出我们的 应用程序.ts 被我们的测试吞噬. At the end of 应用程序.ts, we’ll add 出口 default just before 服务器.听(),因为 听() returns our 节点.js http.服务器 object.

With a quick npm run 测试 为了检查我们没有破坏堆栈,我们现在准备测试我们的API.

我们的 First Real 休息 API Automated Test

要开始配置我们的用户测试,让我们创建 测试/用户/用户.测试.ts,从需要的导入和测试变量开始:

进口 应用程序 from '../../应用程序”;
从'super测试'中导入super测试;
从'chai'导入{预计};
从'shortid'中导入shortid;
从'猫鼬'输入猫鼬;

let first用户IdTest = ''; // will later hold a value returned by our API
const first用户Body = {
    电子邮件: `marcos.henrique+${shortid.generate()}@jlc866.com”,
    密码: 'Sup3rSecret!23',
};

let accessToken = '';
let refreshToken = '';
const newFirstName = 'Jose';
const newFirstName2 = '保罗';
const newLastName2 = 'Faraco';

接下来,我们将创建一个最外层 describe() 带有一些setup和teardown定义的块:

描述('用户和认证端点',函数(){
    let 请求: super测试.SuperAgentTest;
    before(函数 () {
        请求 = super测试.agent(应用程序);
    });
    After (函数 (完成) {
        //关闭表达.然后,关闭我们的MongoDB连接
        //告诉Mocha我们完成了:
        应用程序.close(() => {
            猫鼬.connection.close(完成);
        });
    });
});

我们传递给的函数 (之前)后() 在所有测试之前和之后调用,我们将通过调用来定义 it() with在 same describe() 块. 的 函数 passed to 后() takes a 呼叫。back, 完成,我们确保只有在清理完应用程序及其数据库连接后才调用该函数.

Note: Without our 后() 即使测试成功完成,摩卡也会被吊死. 的 advice is often to 简单的 always 呼叫。 Mocha with ——退出 为了避免这种情况,有一个(通常未被提及的)警告. 如果测试套件由于其他原因而挂起——比如测试套件中错误构建的承诺或应用程序本身——那么就挂起 ——退出, Mocha不会等待,无论如何都会报告成功,这给调试增加了微妙的复杂性.

控件中添加单独的端到端测试 describe() 布洛克:

它('应该允许帖子到/用户', 异步函数(){
    Const res =等待请求.邮报》('/用户').send(first用户Body);

    预计(res.状态).to.equal(201);
    预计(res.身体).不.to.be.空的;
    预计(res.身体).to.be.an('object');
    预计(res.body.id).to.be.a('string');
    first用户IdTest = res.body.id;
});

第一个函数将为我们创建一个新用户——一个惟一的用户, 由于我们的用户电子邮件是在使用 shortid. 的 请求 变量包含一个SuperTest代理,允许我们向API发出HTTP请求. We make them using 等待,这就是为什么我们要传递的函数 it() 必须是 异步. We then use 期望() 从柴测试各个方面的结果.

An npm run 测试 此时应该显示我们的新测试正在工作.

A 柴n of Tests

我们将添加以下所有内容 it() 块 inside our describe() 块. 我们必须按照给出的顺序添加它们,这样它们才能处理我们正在改变的变量, 如 first用户IdTest.

它('应该允许帖子到/身份验证', 异步函数(){
    Const res =等待请求.邮报》('/身份验证').send(first用户Body);
    预计(res.状态).to.equal(201);
    预计(res.身体).不.to.be.空的;
    预计(res.身体).to.be.an('object');
    预计(res.body.accessToken).to.be.a('string');
    accessToken = res.body.accessToken;
    refreshToken = res.body.refreshToken;
});

这里我们为新创建的用户获取一个新的访问和刷新令牌.

它('应该允许从/用户/:用户标识获取带有访问令牌的得到 ', 异步函数(){
    Const res =等待请求
        .get (' /用户/ $ {first用户IdTest}”)
        .set({Authorization: ' Bearer ${accessToken} '})
        .send ();
    预计(res.状态).to.equal(200);
    预计(res.身体).不.to.be.空的;
    预计(res.身体).to.be.an('object');
    预计(res.body._id).to.be.a('string');
    预计(res.body._id).to.平等(first用户IdTest);
    预计(res.body.电子邮件).to.equal(first用户Body.电子邮件);
});

这是一种象征 得到 请求 to the :用户标识 路由来检查用户数据响应是否与我们最初发送的匹配.

嵌套、跳过、隔离和放弃测试

在摩卡, it() 块也可以包含它们自己的 describe() 块,所以我们将把下一个测试嵌套在另一个测试中 describe() 块. 这将使我们的依赖级联在测试输出中更加清晰,正如我们将在最后展示的那样.

描述('具有有效的访问令牌',函数(){
    它('应该允许从/用户获取',异步函数(){
        Const res =等待请求
            .get (`/用户`)
            .set({Authorization: ' Bearer ${accessToken} '})
            .send ();
        预计(res.状态).to.equal(403);
    });
});

有效的测试不仅包括我们期望工作的内容,还包括我们期望失败的内容. 这里我们尝试列出所有用户,并期望得到403响应,因为我们的用户(具有默认权限)不允许使用该端点.

Within 这 new describe() 块, we can continue writing 测试s. 因为我们已经讨论了其余测试代码中使用的特性, 它的起始点是 这 行 the repo.

Mocha提供了一些在开发和调试测试时可以方便使用的功能:

  1. .跳过() 方法可用于避免运行单个测试或整个测试块. 当 it() is replaced with it.跳过() (就像wise for describe()), 有问题的测试将不会运行,但在Mocha的最终输出中将被计数为“pending”.
  2. 对于更临时的使用, .只有() 函数导致所有非-.只有()-将测试标记为完全忽略,并且不会导致任何标记为“pending”.”
  3. 的 invocation of 摩卡 as defined in 包.json 可以使用 ——保释 作为命令行参数. 设置此选项后,只要有一个测试失败,Mocha就会停止运行测试. 这在我们的休息 API示例项目中特别有用, since the 测试s are set up to cascade; if only the first 测试 is broken, 摩卡正是这样报道的, 而不是抱怨所有依赖的(但没有损坏的)测试现在因为它而失败.

如果我们现在用 npm run 测试我们会看到三个不合格的测试. (如果我们要让他们所依赖的功能暂时未实现, 这三个测试将是很好的候选 .跳过().)

失败的测试依赖于我们的应用程序目前缺少的两个部分. 的 first is in 用户.路线.配置.ts:

这.应用程序.把(' /用户/:用户标识 / permissionFlags: permissionFlags”,(
    jwtMiddleware.validJWTNeeded,
    permissionMiddleware.onlySame用户OrAdminCanDoThisAction,

    //注意:上面的两个中间件是必需的
    类中对它们的引用 .() 呼叫。,因为 that only covers
    // /用户/:用户标识,而不是层次结构中它下面的任何东西

    permissionMiddleware.permissionFlagRequired (
        PermissionFlag.FREE_PERMISSION
    ),
    用户Controller.更新PermissionFlags,
]);

的 second file we need to 更新 is 用户.controller.ts,因为我们只是引用了一个不存在的函数. We’ll need to add 导入{Patch用户Dto}../dto/patch.用户.dto '; 靠近顶部,以及类缺少的函数:

异步 更新PermissionFlags(要求的事情: 表达.请求, res: 表达.Response) {
    const patch用户Dto: Patch用户Dto = {
        permissionFlags:方法(点播.参数个数.permissionFlags),
    };
    日志(等待 用户Service.patchById(要求的事情.body.id, patch用户Dto));
    res.status(204).send ();
}

添加这种特权升级功能对测试很有用,但并不适合大多数实际需求. 这里有两个练习给读者:

  1. 考虑让代码再次禁止用户更改自己的代码的方法 permissionFlags 同时仍然允许测试受权限限制的端点.
  2. 如何创建和实现业务逻辑(以及相应的测试) permissionFlags 应该 能够通过API进行更改. (这里有一个先有鸡还是先有蛋的难题:一个特定的用户首先是如何获得更改权限的权限的?)

With that, npm run 测试 现在应该可以成功地输出如下格式的输出:

  Index Test
    ✓ 应该 always pass

  用户和认证端点
    ✓ 应该 所有ow a 帖子 to /用户 (76ms)
    应该允许帖子到/验证
    ✓应该允许来自/用户/:用户标识的带有访问令牌的得到
    使用有效的访问令牌
      应该允许从/用户获取
      应该禁止对/用户/:用户标识进行补丁
      应该禁止对/用户/:用户标识执行一个不存在的ID的把操作
      应禁止对/用户/:用户标识进行把操作,试图更改权限标志
      ✓应该允许把到/用户/:用户标识/permissionFlags/2进行测试
      使用新的权限级别
        应该允许帖子 /身份验证/refresh-token
        应该允许对/用户/:用户标识进行把操作来修改姓和名
        ✓应该允许从/用户/:用户标识获取,并且应该有一个新的全名
        ✓应该允许从/用户/:用户标识中执行DELETE操作


  13 passing (231ms)

现在我们有了一种快速验证休息 API是否按预期工作的方法.

Debugging (With) Tests

面对意外测试失败的开发人员可以轻松地利用Winston和节点.. Js的调试模块.

例如,很容易关注通过调用来执行哪些猫鼬查询 调试=mquery npm运行测试. (注意这个命令缺少 出口 prefix 和 && 在中间,这将使环境持久化到以后的命令.)

这也是可以展示的 所有 debug output with npm run 测试-debug,这要感谢我们早些时候增加的 包.json.

With that, we have a working, 可伸缩的, 蒙戈db支持的休息 API, with a convenient automated 测试 suite. But it’s still missing some essentials.

安全(所有项目都要戴头盔)

使用表达时.Js的文档是必读的,尤其是它的 security best practices. 至少,这是值得追求的。

  • Configuring TLS 支持
  • 添加 病原的中间件
  • 确保npm依赖是安全的(读者可能想从 npm审计 or go deeper with snyk)
  • Using the 头盔 库来帮助防止常见的安全漏洞

最后一点可以直接添加到我们的示例项目中:

npm i --save helmet

然后,在 应用程序.ts, we need only 进口 it 和 add a不her 应用程序.使用() 电话:

从'helmet'中导入头盔;
// ...
应用程序.use(helmet());

As its docs point out, 头盔(就像任何安全装置一样)并不是万能的, but every bit of prevention does help.

用Docker包含我们的休息 API项目

在这个系列中,我们没有深入 Docker containers 但我们确实在Docker Compose的容器中使用了MongoDB. 不熟悉Docker但想进一步尝试的读者可以创建一个名为 Dockerfile (with no extension) 在 project root:

FROM node:14-slim

执行mkdir -p /usr/src/应用程序

WORKDIR /usr/src/应用程序

复制 . .

RUN npm inst所有

EXPOSE 3000

CMD ["node", "./ dist /应用程序.js"]

这个配置从 node:14-slim official image 并在容器中构建和运行我们的示例休息 API. 配置可以因情况而异, 但是这些看起来通用的默认值适用于我们的项目.

要构建映像,我们只需在项目根目录下运行此命令(替换 tag_your_image_here as desired):

docker build . -t tag_your_image_here

然后,运行后端程序的一种方法是——假设使用完全相同的文本替换——

docker run -p 3000:3000 tag_your_image_here

此时,MongoDB和节点.js都可以使用Docker,但我们必须以两种不同的方式启动它们. 我们把添加主节点留给读者作为练习.Js应用程序 docker-compose.yml 所以整个应用程序可以用一个 docker-compose comm和.

进一步的休息 API技能探索

In 这 article, 我们对休息 API进行了广泛的改进:我们添加了一个容器化的MongoDB, 配置ured 猫鼬 和 表达-validator, 添加了基于jwt的身份验证和灵活的权限系统, wrote a battery of automated 测试s.

这对于新手和高级后端开发人员来说都是一个坚实的起点. 然而,在某些方面,我们的项目可能不适合生产使用、扩展和维护. 除了我们在本文中穿插的读者练习之外,还有什么可以学习的呢?

在API级别,我们建议仔细阅读如何创建 OpenAPI兼容的规范. 对追求企业发展特别感兴趣的读者也可以尝试一下 NestJS. 它是建立在表达之上的另一个框架.js, 但是它更加健壮和抽象——这就是为什么最好使用我们的示例项目来熟悉表达.js basics first. No less 进口ant, the GraphQL api的方法作为休息的替代方案具有广泛的吸引力.

当涉及到权限, 我们介绍了一种使用中间件生成器手动定义标志的按位标志方法. 为了进一步方便扩展,值得研究CASL库,它 与猫鼬集成. It extends the flexibility of our 应用程序roach, 允许对特定标志应该允许的能力进行简洁的定义, 就像 可以(“更新”,“删除”,“(型号名称)”,{创造者:‘我’}); 代替整个自定义中间件功能.

我们在这个项目中提供了一个实用的自动化测试跳板, 但是一些重要的话题超出了我们的范围. 我们建议读者:

  1. 探索 unit 测试ing to 分别测试组件—Mocha 和 柴 can be used for 这, too.
  2. 查看代码覆盖工具, 通过显示测试期间未运行的代码行来帮助识别测试套件中的漏洞. With such tools, 然后,读者可以补充示例测试, 根据需要,但它们可能无法揭示所有缺失的场景, 例如用户是否可以通过 补丁 to /用户/:用户标识.
  3. Try other 应用程序roaches to automated 测试ing. We’ve used the 行为驱动开发 (BDD)-style 预计 interface from 柴, but it also 支持s 应该()断言. 其他测试库也值得学习,比如 开玩笑.

除了这些主题,我们的节点.js/TypeScript的休息 API已经准备好了. Particularly, 读者可能希望实现更多中间件,以围绕标准用户资源强制执行公共业务逻辑. 这里我就不深入讲了, 但我很乐意为那些发现自己被屏蔽的读者提供指导和建议——请在下面留言.

这个项目的完整代码是开源的 GitHub repo.

了解基本知识

  • Is 猫鼬 an ORM?

    Not quite. 猫鼬是一个对象数据/文档建模(ODM)库. 它提供了一个直接的基于模式的解决方案,在底层使用本机MongoDB驱动程序.

  • MongoDB有什么好处?

    作为一个文档数据库,MongoDB允许快速开发和易于扩展.

  • 为什么MongoDB被称为MongoDB?

    的 name MongoDB came from “hu蒙戈us,,因为它的扩展能力,也因为该公司计划对MongoDB的全球使用产生巨大影响.

  • 什么是JWT令牌身份验证?

    JWT来自RFC标准(RFC 7519),描述了JSON结构与自包含加密的使用. 基于jwt的身份验证可用于通过可由服务器验证的秘密令牌以安全的方式管理请求.

  • JWT身份验证是如何工作的?

    JWT包含标头、有效负载和签名. 签名包含一个用模式编码的秘密. 另一方(服务器)知道这个秘密, 它可以通过解密和验证签名来验证有效负载.

  • 自动化测试是如何工作的?

    与测试库/框架,如Mocha, 柏树, SuperTest, 开发人员可以模拟应用程序的使用情况并验证预期的响应. 自动化后端测试的范围从单元测试(单个类)到集成测试(多个类)再到端到端(E2E)测试(使用真实的HTTP调用).

  • What is the purpose of automated 测试ing?

    自动化测试的目的是在进一步推进代码之前(例如向其他团队成员或实时产品)提供一种检测问题的方法。. 它还通过避免手工测试减少了测试时间和工作量.

  • 自动化测试值得吗?

    Automated 测试ing is definitely worth it! Companies 就像 Google, 亚马逊, 脸谱网, 苹果, 如果没有自动化测试,微软也不可能有这么大的规模. 但即使是最小的应用程序也受益于自动化测试带来的更短的开发周期和更高的可靠性.

就这一主题咨询作者或专家.
Schedule a 呼叫。
马科斯·恩里克·达席尔瓦's profile image
马科斯·恩里克·达席尔瓦

Located in 莱科,意大利莱科省

Member since February 25, 2017

关于 the 身份验证or

Marcos has 17+ years in IT 和 development. 他的爱好包括休息架构、敏捷开发方法和JS.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Years of Experience

12

World-class articles, delivered weekly.

订阅意味着同意我们的 privacy policy

World-class articles, delivered weekly.

订阅意味着同意我们的 privacy policy

Toptal Developers

Jo在 Toptal® 社区.