六、添加认证和连接数据库

在前一章中,我们在应用中添加了一个 HTTP 框架,极大地简化了代码。 之后,我们将用户的概念添加到应用中,并开发了注册端点。 在它的当前状态下,我们的应用已经存储了一些东西,但有一个小问题,它将这些东西存储在内存中。 我们将在本章中讨论这个具体问题。

我们在实现 oak(选择的 HTTP 框架)时使用的另一个概念是中间件函数。 我们将从学习什么是中间件功能开始这一章,以及为什么它们是所有 Node.js 和 Deno 框架的标准中的代码重用。

然后,我们将使用中间件函数并实现登录和授权。 此外,我们还将学习如何使用中间件为应用添加标准特性,如请求日志记录和定时。

由于我们的应用在需求方面已经非常接近完整,我们将在本章的剩余部分学习如何连接到真正的持久性引擎。 对于这本书,我们将使用 MongoDB。 我们将使用之前构建的抽象来确保转换是平稳的。 然后,我们将创建一个新的用户存储库,以便它能够以与内存解决方案相同的方式连接到数据库。

在本章结束时,我们将拥有一个完整的应用,支持注册和用户登录。 登录后,用户还可以获得博物馆列表。 这都是通过来自 HTTP 和持久性实现的业务逻辑完成的。

在本章之后,我们只会回到应用,添加测试并部署它,从而完成构建应用的整个周期。

在本章中,我们将涵盖以下主题:

  • 使用中间件功能
  • 添加身份验证
  • 使用 JWT 添加授权
  • 连接 MongoDB

让我们开始吧!

技术要求

本章所需代码可在以下 GitHub 链接:https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06

使用中间件功能

如果您使用过任何 HTTP 框架,无论是 JavaScript 还是其他,您可能都熟悉中间件功能的概念。 如果你不是,那也没关系——这就是我们在这一节要解释的。

让我们从 Express.js 文档(http://expressjs.com/en/guide/writing-middleware.html)中的定义开始:

中间件函数可以访问应用请求-响应周期中的请求对象(req)、响应对象(res)和下一个中间件函数。 下一个中间件函数通常用一个名为 next 的变量表示。”

中间件功能拦截请求并能够对请求进行操作。 它们可以在许多不同的用例中使用,如下所示:

  • 更改请求和响应对象
  • 结束请求-响应生命周期(响应请求或跳过其他处理程序)
  • 调用下一个中间件函数

中间件功能通常用于检查身份验证令牌并根据结果自动响应、记录请求、向请求添加特定头、使用上下文丰富请求对象以及错误处理等任务。

我们将在应用中实现其中一些示例。

中间件是如何工作的?

中间件作为堆栈处理,每个函数都可以控制响应流,并能够在堆栈的其余部分执行之前和之后运行代码。

在 oak 中,中间件功能是通过use功能注册的。 现在,你可能还记得我们之前对 oak 的路由做了什么。 来自 oak 的Router对象所做的是为已注册路由创建处理程序,并导出两个具有该行为的中间件函数,以便在主应用上注册。 它们被称为routesallowedMethods(https://github.com/PacktPublishing/Deno-Web-Development/blob/43b7f7a40157212a3afbca5ba0ae20f862db38c4/ch5/sections/2-2-handling-routes-in-an-oak-application/museums-api/src/web/index.ts#L38)。

为了更好地理解中间件功能,我们将实现其中几个功能。 我们将在下一节中做这个。

通过中间件添加请求时间

让我们使用一些中间件为请求添加基本的日志记录。 Oak 中间件函数(https://github.com/oakserver/oak#application-middleware-and-context)接收两个参数。 第一个是 context 对象,这是所有路由获得的同一个对象,而第二个是next函数。 该函数可用于执行堆栈中的其他中间件,从而允许我们控制应用流。

我们要做的第一件事是添加一个中间件,将X-Response-Time标头添加到响应中。 遵循以下步骤:

  1. Go to src/web/index.ts and register a middleware that executes the rest of the stack by calling next.

    这将在响应中添加一个从请求开始到请求被处理的毫秒差值的报头:

    const app = new Application(); app.use(async (ctx, next) => {   const start = Date.now();   await next();   const ms = Date.now() - start;   ctx.response.headers.set("X-Response-Time", `${ms}ms`); }); … app.use(apiRouter.routes()); app.use(apiRouter.allowedMethods());

    这个中间件应该在任何其他.use调用之前添加; 这样,一旦执行了该命令,所有其他中间件功能都将运行。

    第一行是在路由处理程序(和其他中间件函数)开始处理请求之前执行的。 然后,调用next确保路由处理程序执行; 只有到那时,中间件代码才会执行,从而计算初始值与当前日期的差值,并将其添加为报头。

  2. 执行以下代码让服务器运行:

  3. Perform a request and check whether the desired header is there:

    $ curl -i http://localhost:8080/api/museums HTTP/1.1 200 OK content-length: 472 x-response-time: 50ms content-type: application/json; charset=utf-8

    好了! 我们有x-response-time标题。 注意,我们使用了-i标志,以便能够看到curl上的响应标头。

因此,我们在第一次完全理解了中间件功能之后就使用了它们。 我们使用它们来控制应用的流,通过使用next,并使用报头来丰富请求。

接下来,我们将在刚刚创建的中间件上进行组合,并添加逻辑以记录向服务器发出的请求。

通过中间件添加请求日志

现在我们有了计算已经构建的请求时间的逻辑,我们可以将请求日志记录添加到应用中了。

最终目标是将对应用发出的每个请求记录到控制台,包括其路径、HTTP 方法和应答时间; 就像下面的例子:

GET http://localhost:8080/api/museums - 65ms

当然,我们可以在每个请求中单独执行这一操作,但由于这是我们希望在跨应用中执行的操作,所以我们将它作为一个中间件添加到Application对象中。

我们在上一节中编写的中间件需要运行处理程序(和中间件函数)来添加响应时间(它在执行部分逻辑之前调用下一个函数)。 我们需要先注册当前的日志中间件,然后再注册前面添加了定时请求的日志中间件。 让我们开始:

  1. Go to src/web/index.ts and add the code for logging the request method, the path, and the timing to the console:

    app.use(async (ctx, next) => {   await next();   const rt = ctx.response.headers.get("X-Response-Time"); console.log(`${ctx.request.method} ${ctx.request.url}     - ${rt}`); }); … app.use(apiRouter.routes()); app.use(apiRouter.allowedMethods());

    注意,我们是如何依赖于X-Response-Time报头的,它将由之前的中间件设置,以将请求记录到控制台。 我们还使用next来确保所有处理程序(和中间件函数)在登录到控制台之前运行。 我们特别需要这样做,因为头部是由另一个中间件设置的。

  2. 执行以下代码让服务器运行:

  3. 向终端执行请求:

    $ curl http://localhost:8080/api/museums

  4. 检查服务器进程是否将请求记录到控制台:

    $ deno run --allow-net src/index.ts Application running at http://localhost:8080 GET http://localhost:8080/api/museums - 46ms

这样,我们的中间件功能就可以一起工作了!

这里,我们直接在主应用对象上注册了中间件函数。 然而,通过调用相同的use方法,也可以在特定的 oak 路由上实现这一点。

举个例子,我们将注册一个只在/api路由上执行的中间件。 我们将做与之前完全相同的事情,但不是调用Application对象,而是调用 APIRouter对象的use方法,如下面的示例所示:

const apiRouter = new Router({ prefix: "/api" })
apiRouter.use(async (_, next) => {
  console.log("Request was made to API Router");
  await next();
}))

app.use(apiRouter.routes());
app.use(apiRouter.allowedMethods());

想要应用流正常的中间件函数必须调用next函数。 如果不这样做,堆栈中的其余中间件和路由处理程序将不会执行,因此请求将不会得到响应。

还有另一种使用中间件函数的方法:直接在请求处理程序之前添加它们。

假设我们想要创建一个中间件,将X-Test头添加到一些路由中。 我们可以在应用对象上编写中间件,也可以直接在路由本身上使用它,如下面的 ng 代码所示:

import { Application, Router, RouterMiddleware } from
  "../deps.ts";

const addTestHeaderMiddleware: RouterMiddleware = async (ctx,
   next) => {
  ctx.response.headers.set("X-Test", "true");
  await next();
}
apiRouter.get("/museums", addTestHeaderMiddleware, async (ctx)
  => {
  ctx.response.body = {
    museums: await museum.getAll()
  }
});

为了让前面的代码工作,我们需要导出src/deps.ts中的RouterMiddleware类型:

export type { RouterMiddleware } from
  "https://deno.land/x/oak@v6.3.1/mod.ts";

在这个中间件中,每当我们想要添加X-Test报头时,我们只需要在路由处理程序之前包含addTestHeaderMiddleware。 它将在处理程序代码之前执行。 这不是一个中间软件所独有的,因为可以注册多个中间件功能。

这就是中间件的功能!

我们已经学习了基础知识,通过使用 web 框架的这一常见特性,我们可以创建和共享功能。 在进入下一节时,我们将继续使用它们,在下一节中,我们将处理身份验证、验证令牌和授权用户。

让我们开始实现应用的身份验证!

添加认证

在前一章中,我们为应用添加了创建新用户的功能。 这本身就是一个很酷的特性,但如果我们不能使用它进行身份验证,那么它就没有多大价值。 这就是我们在这里要做的。

我们将首先创建一个检查用户名和密码组合是否正确的逻辑,然后我们将实现一个端点来做到这一点。

在此之后,我们将通过从登录端点返回一个令牌转换到授权主题,然后使用该令牌检查用户是否通过了身份验证。

让我们一步一步来,从业务逻辑和持久化层开始。

创建登录业务逻辑

在编写新功能时,从业务逻辑开始,这已经是我们的惯例了。 我们相信这是直观的,因为您首先考虑“业务”和用户,然后才进入技术细节。 这就是我们在这里要做的。

我们将从添加登录逻辑开始,回到UserController:

  1. 在我们开始之前,让我们将login方法添加到src/users/types.ts:

    export type RegisterPayload = { username: string;   password: string }; export type LoginPayload = { username: string; password:   string }; export interface UserController {   register: (payload: RegisterPayload) =>     Promise<UserDto>;   login: (     { username, password }: LoginPayload,   ) => Promise<{ user: UserDto }>; }

    中的UserController接口中。 2. Declare the login method on the controller; it should receive a username and a password:

    public async login(payload: LoginPayload) { }

    让我们停下来思考一下登录流程应该是怎样的:

    • 用户发送他们的用户名和密码。
    • 应用通过用户名从数据库中获取用户。
    • 应用使用数据库中的 salt 对用户发送的密码进行编码。
    • 该应用比较两种加盐密码。
    • The application returns a user and a token.

      我们暂时不担心令牌。 然而,流程的其余部分应该设置当前部分的需求,帮助我们考虑login方法的代码。

      只要看看这些要求,我们就可以理解,我们将需要一个方法上的UserRepository,以获得用户的用户名。 让我们来看看这个。

  2. src/users/types.ts中,在UserRepository中加入getByUsername方法; 它应该通过用户名从数据库获取一个用户:

    export interface UserRepository {   create: (user: CreateUser) => Promise<User>;     exists: (username: string) => Promise<boolean>   getByUsername: (username: string) => Promise<User> }

  3. Implement the getByUsername method in src/users/repository.ts:

    export class Repository implements UserRepository { …   async getByUsername(username: string) {     const user = this.storage.get(username);     if (!user) {       throw new Error("User not found");     }     return user;   } }

    现在,我们可以回到UserController,使用最近创建的方法从数据库中获取用户。

  4. Use the getByUsername method from the repository inside the login method of UserController:

    public async login(payload: LoginPayload) {   try {     const user = await      this.userRepository.getByUsername(payload.username);   } catch (e) {     throw new Error("Username and password combination is       not correct")   } }

    现在我们有了数据库中的用户,我们必须将其哈希值与用户发送的哈希密码进行比较。 在上一章中,当我们实现寄存器逻辑时,我们创建了一个名为hashPassword的函数,所以让我们使用它。

  5. Create a comparePassword method inside UserController.

    它应该接收一个密码和一个user对象。 然后,它应该比较用户发送的经过腌制和散列处理的密码与存储在数据库中的密码:

    import {   LoginPayload,   RegisterPayload,   User,   UserController,   UserRepository, } from "./types.ts"; import { hashWithSalt } from "./util.ts" … private async comparePassword(password: string, user:   User) {   const hashedPassword = hashWithSalt (password,     user.salt);   if (hashedPassword === user.hash) {     return Promise.resolve(true);   }   return Promise.reject(false); }

  6. UserController:

    public async login(payload: LoginPayload) {   try {     const user = await      this.userRepository.getByUsername(payload.username);     await this.comparePassword(payload.password, user);     return { user: userToUserDto(user) };   } catch (e) {     console.log(e);     throw new Error('Username and password combination is       not correct')   } }

    login方法上使用comparePassword方法

这样,我们就有了login方法!

它接收一个用户名和密码,通过用户名获取用户,比较散列密码,如果一切按照计划进行,返回用户。

现在我们应该准备好实现登录端点了——它将使用我们刚刚创建的登录方法。

创建登录端点

现在我们已经创建了业务逻辑和数据获取逻辑,我们可以开始在 web 层中使用它了。 让我们创建POST /api/login路由,它应该让用户使用他们的用户名和密码登录。 遵循以下步骤:

  1. src/web/index.ts中,创建登录路由:

    apiRouter.post("/login", async (ctx) => { })

  2. Get the body of the request by using the request.body function (https://doc.deno.land/https/raw.githubusercontent.com/oakserver/oak/main/request.ts#Request) and then send the username and password to the login method:

    apiRouter.post("/login", async (ctx) => {   const { username, password } = await     ctx.request.body().value;   try {     const { user: loginUser } = await user.login({       username, password });   } catch (e) {     ctx.response.body = { message: e.message };     ctx.response.status = 400;   } })

    注意我们是如何处理错误的,使用 try-catch 并返回正确的错误代码(400``Bad``Request),如果事情进展不顺利的话。

  3. If the login succeeds, it should return our user:

    … const { user: loginUser } = await user.login({ username,   password }); ctx.response.body = { user: loginUser }; ctx.response.status = 201; …

    这样,我们就可以让用户登录了! 让我们试一下。

  4. 运行以下命令运行应用:

    $ deno run --allow-net src/index.ts Application running at http://localhost:8080

  5. 执行一个请求在/api/users/register注册用户,然后尝试在/api/login:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register {"user":{"username":"asantos00","createdAt":"2020-10-19T21:30:51.012Z"}}

    使用创建的用户登录。 6. 现在,尝试使用创建的用户登录:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw" }' -H 'Content-Type: application/json' http://localhost:8080/api/login {"user":{"username":"asantos00","createdAt":"2020-10-19T21:30:51.012Z"}}

和它的工作原理! 我们在注册表上创建用户,并能够在之后用他们登录。

在本节中,我们学习了如何向应用添加身份验证逻辑,并实现了login方法,该方法允许用户使用已注册用户登录。

在下一节中,我们将学习如何使用我们创建的这个身份验证来获得允许我们处理授权的令牌。 我们将使博物馆路径只对经过身份验证的用户可用,而不是公开可用。 为此,我们需要开发授权特性。 让我们跳!

加入 JWT 授权

我们现在有一个应用,它允许我们登录并返回登录的用户。 但是,如果我们想在任何 API 中使用登录,就必须创建授权机制。 该机制应该允许 API 的用户身份验证,获取令牌,并使用该令牌标识自己和访问资源。

我们这样做是因为我们想关闭应用的部分路由,以便它们只对经过身份验证的用户可用。

我们将通过使用JSON Web Tokens(JWT)开发与 token 认证集成所需的内容,这是目前 api 中的一个标准。

如果你不熟悉 JWT,我将留给你一个来自JWT 的解释。 :

“JSON Web 令牌是一种开放的行业标准 RFC 7519 方法,用于在双方之间安全地表示索赔。”

它主要用于当您希望您的客户机连接到身份验证服务,并且它们向您的服务器提供验证该身份验证是否由您信任的服务发出的能力时。

避免重复jwt 已经解释得很好的内容的风险。 io,我将留给你一个链接,完美地解释这个标准是什么:https://jwt.io/introduction/。 一定要读一遍; 我相信你已经知道我们接下来要如何使用它了。

在本节中,由于本书的范围,我们将不实现生成和验证 JWT 令牌的整个逻辑。 代码可以在本书的 GitHub 存储库(https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/jwt-auth)中找到。

我们在这里要做的是将我们当前的应用与一个模块集成,该模块具有生成和验证 JWT 令牌的功能,这对我们的应用很重要。 然后,我们将使用该令牌来决定是否允许用户访问博物馆路由。

我们走吧!

从登录返回令牌

在上一节中,我们实现了登录功能。 我们开发了一些逻辑来验证用户名和密码的组合,如果成功就返回用户。

为了授权用户并让他们访问私有资源,我们需要知道经过身份验证的用户是谁。 一种常见的方法是通过令牌。 有很多方法可以做到这一点。 它们是基本 HTTP 身份验证、会话令牌、JWT 令牌等的替代品。 我们选择了 JWT,因为我们相信这是一个被广泛使用的解决方案,您可能在行业中遇到过。 如果没有,也不要担心; 这很容易理解。

我们需要做的第一件事是在用户登录时向他们返回一个令牌。 我们的UserController将必须返回该令牌与userDto

在提供的jwt-auth模块(https://github.com/PacktPublishing/Deno-Web-Development/tree/master/Chapter06/jwt-auth)中,您可以检查我们是否正在导出存储库。

如果我们访问文档,使用Deno 文档网站 https://doc.deno.land/https/raw.githubusercontent.com/PacktPublishing/Deno-Web-Development/master/Chapter06/jwt-auth/repository.ts,我们可以看到它出口两个方法:getTokengenerateToken

通过阅读该方法的文档,我们可以理解其中一个方法为用户 ID 获取一个令牌,而另一个方法分别生成一个新令牌。

让我们使用这个方法在我们的登录用例中生成一个新的令牌,步骤如下:

  1. Start by adding the token to the return type of UserController in src/users/types.ts:

    export interface UserController {   register: (payload: RegisterPayload) =>     Promise<UserDto>   login: ({ username, password }: LoginPayload) =>     Promise<{ user: UserDto, token: string }> }

    现在,我们需要确保UserController知道如何返回一个令牌。 看一下它的逻辑,我们可以看到它应该能够通过调用返回该令牌的方法来委托该责任。

    从前面的章节中,我们知道我们不想直接导入依赖项; 我们宁愿把它们注射到我们的 T0 中。 这就是我们在这里要做的。

    我们知道的另一件事是,我们希望使用这个处理身份验证的“第三方模块”。 我们需要将它添加到依赖文件中。

  2. 进入src/deps.ts,添加jwt-auth模块的导出,运行deno cache更新锁文件并下载依赖项:

    export type {   Algorithm, } from "https://raw.githubusercontent.com/PacktPublishing/ Deno-Web-Development/master/Chapter06/jwt-auth/mod.ts"; export {   Repository as AuthRepository, } from "https://raw.githubusercontent.com/PacktPublishing/   Deno-Web-Development/master/Chapter06/jwt-auth/mod.ts";

  3. Use the AuthRepository type to define the UserController constructor's dependencies:

    import { AuthRepository } from "../deps.ts"; interface ControllerDependencies {   userRepository: UserRepository   authRepository: AuthRepository } export class Controller implements UserController {   userRepository: UserRepository;   authRepository: AuthRepository;   constructor({ userRepository, authRepository }:     ControllerDependencies) {     this.userRepository = userRepository;     this.authRepository = authRepository;   }

    现在,是时候开始使用我们刚刚导入的authRepository了。 我们之前发现它公开了一个generateToken方法,这将对登录UserController有帮助。

  4. Go to the login method in src/users/controller.ts and use the generateToken method from authRepository to get a token and return it:

    public async login(payload: LoginPayload) {     try {       const user = await         this.userRepository.getByUsername           (payload.username);       await this.comparePassword(payload.password, user);       const token = await         this.authRepository.generateToken(user.username);       return { user: userToDto(user), token }; …

    这里,我们使用authRepository来获取令牌。

    如果我们尝试运行这段代码,我们知道它会失败。 事实上,我们只需要打开src/index.ts就可以看到编辑的警告。 它在抱怨我们没有把authRepository发送到UserController,我们应该这么做。

  5. Go back to src/index.ts and instantiate AuthRepository from jwt-auth:

    import { AuthRepository } from "./deps.ts"; … const authRepository = new AuthRepository({   configuration: {     algorithm: "HS512",     key: "my-insecure-key",     tokenExpirationInSeconds: 120   } });

    你也可以通过模块的文档进行检查,因为它要求发送一个带有三个属性的configuration对象; 即:algorithmkeytokenExpirationInSeconds

    key应该是用于创建和验证 JWT 的秘密值,algorithm是令牌将被编码的加密算法(支持 HS256、HS512 和 RS256),tokenExpirationInSeconds是令牌过期的时间。

    关于那些机密的、不应该存在于代码中的值,比如我们刚刚提到的key变量,我们将在下一章学习如何处理它们,在那里我们将讨论应用配置。

    我们现在有了一个AuthRepository的实例! 我们应该能够把它发送到我们的UserController,并让它工作。

  6. In src/index.ts, send authController into the UserController constructor:

    const userController = new UserController({   userRepository, authRepository });

    现在,您应该能够运行该应用了!

    现在,如果您创建一些请求来测试它,您将注意到POST /login端点仍然没有返回令牌。 让我们解决这个问题!

  7. 进入src/web/index.ts,在login路线上,确保我们从响应

    apiRouter.post("/login", async (ctx) => {   const { username, password } = await     ctx.request.body().value;   try {     const { user: loginUser, token } = await user.login({       username, password });     ctx.response.body = { user: loginUser, token };     ctx.response.status = 201;   } catch (e) {     ctx.response.body = { message: e.message };     ctx.response.status = 400;   } })

    login方法返回token

我们几乎完成了! 我们设法完成了第一个目标:让login端点返回一个令牌。

我们要实现的下一件事是确保用户在试图访问经过身份验证的路由时发送令牌的逻辑。

我们去完成授权逻辑吧。

配置经过认证的路由

有了为用户获取令牌的能力,我们现在需要保证只有登录的用户才能访问博物馆路由。

用户必须按照 JWT 令牌标准的定义,在Authorization报头中发送令牌。 如果令牌无效或不存在,则应该向用户提供一个401 Unauthorized状态码。

对于中间件功能来说,验证用户在请求中发送的令牌是一个很好的用例。

为了做到这一点,由于我们使用的是oak,我们将使用一个名为oak-middleware-jwt的第三方模块。 这只不过是一个中间件,它根据密钥自动验证 JWT 令牌,并提供对我们有用的功能。

您可以在https://nest.land/package/oak-middleware-jwt查看其文档。

让我们在 web 代码中使用这个中间件,使博物馆路径只对经过身份验证的用户可用。 遵循以下步骤:

  1. deps.ts文件中添加oak-middleware-jwt,导出jwtMiddleware功能:

    export {   jwtMiddleware, } from "https://x.nest.land/    oak-middleware-jwt@2.0.0/mod.ts";

  2. Back in src/web/index.ts, use jwtMiddleware in the museums route, sending the key and the algorithm there.

    不要忘记我们在前面提到的-中间件函数可以在任何路由中使用,只要在路由处理程序之前发送它:

    import { Application, jwtMiddleware, Router } from   "../deps.ts"; … const authenticated = jwtMiddleware   ({ algorithm: "HS512", key: "my-insecure-key" }) apiRouter.get("/museums", authenticated, async (ctx) => {   ctx.response.body = {     museums: await museum.getAll()   } });

    你可能已经注意到我们又在发送算法和密钥了。 即使它目前可以工作,这也是我们的应用可能会失败的地方。 想象一下,我们改变了src/index.ts中当前的配置,却忘了改变这个。

    这正是为什么我们应该提取这个,并期望它作为createServer函数的参数。

  3. Add authorization as a parameter inside configuration in the createServer function:

    import { Algorithm, Application, jwtMiddleware, Router }   from "../deps.ts"; interface CreateServerDependencies {   configuration: {     port: number,     authorization: {       key: string,       algorithm: Algorithm     }   },   museum: MuseumController,   user: UserController } export async function createServer({   configuration: {     port,     authorization   }, …

    注意我们是如何从deps.ts文件导入Algorithm类型的,该文件从jwt-auth模块导出Algorithm类型。 我们这样做是为了通过类型,确保发送的算法只是受支持的算法。

  4. Now, still in src/web/index.ts, use the authorization params to send the values that will be injected to jwtMiddleware:

    const authenticated = jwtMiddleware(authorization)

    我们唯一缺少的是将authorization值实际发送到createServer函数的能力。

  5. src/index.ts中,将认证配置提取到一个变量中,以便我们可以重用它:

  6. Let's reuse that same variable to send the required parameters to createServer:

    createServer({   configuration: {     port: 8080,     authorization: {       key: authConfiguration.key,       algorithm: authConfiguration.algorithm     }   },   museum: museumController,   user: userController })

    我们完成了! 让我们测试一下应用,看看它是否按照预期工作。

    注意,期望的行为是只有经过身份验证的用户才能访问 museums 路由并查看所有的博物馆。

  7. 让我们运行以下命令运行应用:

    $ deno run --allow-net src/index.ts Application running at http://localhost:8080

  8. 让我们注册一个用户,这样我们就可以登录:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register {"user":{"username":"asantos00","createdAt" :"2020-10-27T19:14:01.984Z"}}

  9. 现在,让我们登录以便获得我们的令牌:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/login {"user":{"username":"asantos00","createdAt":"2020-10-27T19:14:01.984Z"},"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtdXNldW1zIiwiZXhwIjoxNjAzODI2NTEzLCJ1c2VyIjoi YXNhbnRvczAwIn0.XV1vaHDpTu2SnavFla5q8eIPKCRIfDw_Kk-j8gi1 mqcz5UN3sVnk61JWCapwlh0IJ46fJdc7cw2WoMMIh-ypcg"}

  10. Finally, let's try to access the museums route with the token that was returned from the previous request:

    $ curl -H 'content-type: application/json' -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXV CJ9.eyJpc3MiOiJtdXNldW1zIiwiZXhwIjoxNjAzODI2NTEzLCJ1c2Vy IjoiYXNhbnRvczAwIn0.XV1vaHDpTu2SnavFla5q8eIPKCRIfDw_Kk-j8gi1mqcz5UN3sVnk61JWCapwlh0IJ46fJdc7cw2WoMMIh-ypcg' http://localhost:8080/api/museums {"museums":[{"id":"fixture-1","name":"Most beautiful museum in the world","description":"One I really like","location":{"lat":"12345","lng":"54321"}}]}

    我们已经成功了!

    注意我们是如何用Bearer作为前缀发送Authentication报头的,正如 JWT 规范所指定的。

  11. Just to make sure it works as expected, let's try to do the same request without the Authorization header, expecting an unauthorized response:

    $ curl -i -H 'content-type: application/json' http://localhost:8080/api/museums HTTP/1.1 401 Unauthorized content-length: 21 content-type: text/plain; charset=utf-8

    我们得到了预期的回应! 注意我们是如何使用-i标志curl,以便它记录请求状态码和报头。

就是这样! 这样,我们就成功创建了一个只有经过身份验证的用户才能访问的路由。 这在任何包含用户的应用中都是非常常见的。

如果我们要更深入地研究这个问题,我们可以研究 JWTrefreshToken,甚至如何从 JWT 令牌读取用户信息,但这超出了本书的范围。 我会让你自己去探索的。

在本节中,我们实现了我们的目标,并研究了 API 的许多不同部分。

不过,这里缺少一件事:与真正的持久性引擎的连接。 这就是我们接下来要做的——将我们的应用连接到 NoSQL 数据库!

连接 MongoDB

到目前为止,我们已经实现了一个应用,它列出了博物馆,并包含用户,允许他们进行身份验证。 这些特性已经就位,但它们都有一个问题:它们都是针对内存中的数据库运行的。

为了简单起见,我们决定这样做。 然而,由于我们的大多数实现并不依赖于交付机制,因此如果数据库发生变化,它也不应该发生太大的变化。

您可能已经从本节的标题猜到了,我们将学习如何将一个应用实体移动到数据库中。 为了做到这一点,我们将利用已经创建的抽象。 这个过程将与所有实体非常相似,因此我们决定学习如何仅为用户模块连接到数据库。

稍后,如果您想知道如果所有的应用都连接到数据库将如何工作,那么您将有机会检查本书的 GitHub 存储库。

为了确保我们都在运行一个类似的数据库,我们将使用 MongoDB Atlas。 Atlas 是一个提供免费 MongoDB 集群的产品,我们可以使用它来连接我们的应用。

如果你不熟悉 MongoDB,这里有一个“一句话解释”从他们的网站(https://www.mongodb.com/)。 你可以去那里学习更多的知识:

MongoDB 是一个通用的、基于文档的分布式数据库,是为现代应用开发人员和云计算时代而构建的。”

准备好了吗? 我们走吧!

创建 MongoDB 用户存储库

我们当前的UserRepository是负责将用户连接到数据库的模块。 为了使我们的应用连接到 MongoDB 实例,而不是内存中的数据库,我们希望更改这个实例。

我们将通过步骤创建新的 MongoDB 存储库,向世界公开它,并将应用的其余部分连接到它。

首先,通过重新组织 users 模块的内部文件夹结构,为新的用户存储库创建空间。

重新安排我们的用户模块

我们的用户模块是最初认为有一个单一的存储库,因此它没有一个文件夹; 它只是一个单一的repository.ts文件。 现在我们正在考虑添加更多将用户保存到数据库的方法,我们需要创建它。

还记得我们第一次谈论架构,并提到它会不断发展吗? 这就是现在的情况。

让我们重新安排 users 模块,以便它可以处理多个存储库,并添加一个 MongoDB 存储库,遵循我们之前创建的UserRepository接口:

  1. src/users中创建一个名为repository的文件夹,并将实际的src/users/repository.ts移到其中,重命名为inMemory.ts:

    └── src     ├── museums     ├── users     │   ├── adapter.ts     │   ├── controller.ts     │   ├── index.ts     │   ├── repository │ │   ├── inMemory.ts     │   ├── types.ts     │   └── util.ts

  2. 记住将模块导入固定在src/users/repository/inMemory.ts:

    import { User, UserRepository } from "../types.ts"; import { generateSalt, hashWithSalt } from "../util.ts";

  3. 为了让应用继续运行,让我们转到src/users/index.ts并导出正确的存储库:

    export { Repository } from './repository/inMemory.ts'

  4. Now, let's create our MongoDB repository. Call it mongoDb.ts and put it inside the src/users/respository folder:

    import { UserRepository } from "../types.ts"; export class Repository implements UserRepository {   storage   async create(username: string, password: string) {   }   async exists(username: string) {   }   async getByUsername(username: string) {   } }

    确保它实现了我们之前定义的UserRepository接口。

这就是所有乐趣的开始! 现在我们已经有了 MongoDB 存储库,我们将开始编写它,并将应用连接到它。

安装 MongoDB 客户端库

我们已经有了一个存储库需要实现的方法列表。 通过遵循接口,我们可以保证应用将工作,而不管实现是什么。

有一件事我们是确定的,因为我们不想重复发明轮子:我们将使用一个第三方包来处理与 MongoDB 的连接。

我们将使用deno-mongo包(https://github.com/manyuanrong/deno_mongo)。

重要提示

Deno 的 MongoDB 驱动使用了 Deno 插件 API,但仍然处于不稳定模式。 这意味着我们必须使用--unstable标志来运行应用。 由于它目前使用的 api 还不被认为是稳定的,所以还不应该在生产中使用。

让我们看一下文档中的例子,其中建立了一个 MongoDB 数据库的连接:

import { MongoClient } from
  "https://deno.land/x/mongo@v0.13.0/mod.ts";
const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");
const db = client.database("test");
const users = db.collection<UserSchema>("users");

这里,我们可以看到,我们将需要创建一个 MongoDB 客户端,并使用包含主机(可能包含主机的用户名和密码)的连接字符串将其连接到数据库。

之后,我们需要让客户端访问特定的数据库(本例中为test)。 只有这样,我们才能得到与集合交互的处理程序(本例中为users)。

首先,让我们将deno-mongo添加到依赖列表中:

  1. 转到你的src/deps.ts文件,并添加MongoClient的导出:

    export { MongoClient } from   "https://deno.land/x/mongo@v0.13.0/mod.ts";

  2. 现在,确保运行cache命令来安装模块。 我们将不得不使用--unstable标志运行它,因为我们安装的插件也需要不稳定的 api:

    $ deno cache --lock=lock.json --lock-write --unstable src/deps.ts

这样,我们就用刚刚安装的包更新了deps.ts文件!

让我们继续,实际使用这个包来开发我们的存储库。

开发 MongoDB 存储库

在我们从文档中获得的示例中,我们学习了如何连接到数据库,并为我们想要的用户集合创建处理程序。 我们知道存储库需要访问这个处理程序,这样它才能与集合交互。

同样,我们可以在存储库中直接创建 MongoDB 客户端,但这将使我们无法在不尝试连接 MongoDB 的情况下测试存储库。

由于我们希望尽可能多地将依赖注入到模块中,我们将通过构造函数将 MongoDB 客户端传递到我们的存储库中,这与我们在代码的其他部分所做的非常相似。

让我们回到 MongoDB 存储库,按照以下步骤来做:

  1. Create the constructor method inside the MongoDB repository.

    确保它接收到一个具有Database类型的storage属性的对象,该对象由deno-mongo包导出:

    import { User, UserRepository } from "../types.ts"; import { Database, Collection } from '../../deps.ts'; interface RepositoryDependencies {   storage: Database, } export class Repository implements UserRepository {   storage: Collection<User>   constructor({ storage }: RepositoryDependencies) {     this.storage = storage.collection<User>('users');   } …

    注意,我们需要一个数据库,然后调用它的collection方法来访问用户的集合。 一旦我们这样做了,我们必须将它设置为我们的storage类属性。 方法和类型都需要传入一个泛型。 这应该表示该集合中存在的对象类型。 在我们的例子中,它是User类型。

  2. 现在,我们必须进入src/deps.ts文件,并从deno-mongo:

    export { MongoClient, Collection, Database } from   "https://deno.land/x/mongo@v0.13.0/mod.ts";

    中导出DatabaseCollection类型。

现在,只需开发满足UserRepository接口的方法。

这些方法将非常类似于我们为内存数据库开发的那些方法,不同的是我们现在与 MongoDB 集合交互,而不是以前使用的 JavaScript Map。

现在,我们只需要实现一些方法,这些方法将创建、验证用户的存在性,并通过用户名获取它。 这些方法可以在插件文档中找到,并且非常接近 MongoDB 的原生 API。

这是最后一节课的内容

import { CreateUser, User, UserRepository } from
 "../types.ts";
import { Collection, Database } from "../../deps.ts";
export class Repository implements UserRepository {
  storage: Collection<User>
  constructor({ storage }: RepositoryDependencies) {
    this.storage = storage.collection<User>("users");
  }
  async create(user: CreateUser) {
    const userWithCreatedAt = { ...user, createdAt: new Date() }
    this.storage.insertOne({ ...user })
    return userWithCreatedAt;
  }
  async exists(username: string) {
    return Boolean(await this.storage.count({ username }));
  }
  async getByUsername(username: string) {
    const user = await this.storage.findOne({ username });
    if (!user) {
      throw new Error("User not found");
    }
    return user;
  }
}  

我们强调了使用deno-mongo插件访问数据库的方法。 请注意,其逻辑与我们之前所做的非常相似。 我们将日期创建添加到create方法,然后从 mongo 调用create方法。 在exists方法中,我们调用count方法,并将其转换为boolean方法。 对于getByUsername方法,我们使用 mongo 库中的findOne方法,发送用户名。

如果你对如何使用这些 api 有任何疑问,请查看 deno-mongo 的文档(https://github.com/manyuanrong/deno_mongo)。

连接 MongoDB

现在,为了暴露我们创建的 MongoDB 存储库,我们需要进入src/users/index.ts并将其暴露为Repository(删除突出显示的行):

export { Repository } from "./repository/mongoDb.ts";
export { Repository } from "./repository/inMemory.ts";

现在,我们的编辑器和 typescript 编译器会抱怨我们没有在src/index.ts上实例化UserRepository时将正确的依赖项发送到UserRepository中,这是真的。 所以,让我们去那里解决它。

在我们将数据库客户端发送到UserRepository之前,需要实例化它。 通过查看deno-mongo的文档,我们可以阅读以下示例:

const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");

我们没有连接到本地主机,因此稍后需要更改连接 URI。

让我们按照文档中的示例来编写连接到 MongoDB 实例的代码。 遵循以下步骤:

  1. src/deps.ts文件中添加MongoClient导出后,在src/index.ts:

    import { MongoClient } from "./deps.ts";

    中导入MongoClient 2. 然后,拨打connectWithUri:

    const client = new MongoClient(); client.connectWithUri("mongodb://localhost:27017");

  2. 在此之后,通过在客户端调用database方法获取数据库处理程序:

    const db = client.database("getting-started-with-deno");

这应该是所有我们需要连接到 MongoDB。 唯一缺少的是将数据库处理程序发送到UserRepository的代码。 所以,让我们添加这个:

const client = new MongoClient();
client.connectWithUri("mongodb://localhost:27017");
const db = client.database("getting-started-with-deno");
...
const userRepository = new UserRepository({ storage: db });

应该没有可见的警告,我们现在应该能够运行我们的应用了!

但是,我们仍然没有要连接的数据库。 我们接下来再来看这个。

连接 MongoDB 集群

现在,我们需要连接到一个真实的 MongoDB 实例。 在这里,我们将使用一个名为 Atlas 的服务。 Atlas 是 MongoDB 的一项服务,提供云 MongoDB 数据库。 它们的自由层相当慷慨,在我们的应用中工作得很好。 在那里创建一个帐户。 完成这些之后,我们就可以创建 MongoDB 集群了。

重要提示

如果您有任何其他 MongoDB 实例,本地或远程的,您可以跳过下一段,直接将数据库 URI 插入到代码中,从而使用它。

以下链接包含创建集群所需的所有指令:https://docs.atlas.mongodb.com/tutorial/create-new-cluster/

一旦创建了集群,我们还需要创建一个具有访问权限的用户。 登录https://docs.atlas.mongodb.com/tutorial/connect-to-your-cluster/index.html#connect-to-your-atlas-cluster,学习如何获取连接字符串。

它看起来应该如下所示:

mongodb+srv://<username>:<password>@clustername.mongodb.net/
  test?retryWrites=true&w=majority&useNewUrlParser=
    true&useUnifiedTopology=true

现在我们有了连接字符串,我们只需要将它传递给我们之前在src/index.ts中创建的代码:

const client = new MongoClient();
client.connectWithUri("mongodb+srv://<username>:<password>
  @clustername.mongodb.net/test?retryWrites=true&w=
    majority&useNewUrlParser=true&useUnifiedTopology=true");
const db = client.database("getting-started-with-deno");

这应该就是让应用运行所需要的全部内容。 让我们这样做!

请记住,由于我们使用插件 API 连接到 MongoDB,它仍然不稳定,以下权限需要与--unstable标志:

$ deno run --allow-net --allow-write --allow-read --allow-plugin --allow-env --unstable src/index.ts
Application running at http://localhost:8080

现在,要测试我们的UserRepository是否工作并连接到数据库,让我们尝试注册和登录,看看它是否工作:

  1. 执行一个POST请求到/api/users/register来注册我们的用户:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/users/register {"user":{"username":"asantos00","createdAt":"2020-11-01T23:21:58.442Z"}}

  2. 现在,为了确保连接到永久存储,我们可以在尝试登录之前停止应用并再次运行它:

    $ deno run --allow-net --allow-write --allow-read --allow-plugin --allow-env --unstable src/index.ts Application running at http://localhost:8080

  3. 现在,让我们用刚刚创建的用户登录:

    $ curl -X POST -d '{"username": "asantos00", "password": "testpw1" }' -H 'Content-Type: application/json' http://localhost:8080/api/login {"user":{"username":"asantos006"},"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtdXNl dW1zIiwiZXhwIjoxNjA0MjczMDQ1LCJ1c2VyIjoiYXNhbnRvczAwNi J9.elY48It-DHse5sSszCAWuE2PzNkKiPsMIvif4v5klY1URq0togK 84wsbSskGAfe5UQsJScr4_0yxqnrxEG8viw"}

我们有我们的回应! 我们设法将以前连接到内存数据库的应用连接到一个真正的 MongoDB 数据库。 如果您使用 MongoDB,您可以通过进入Collections菜单查看在 Atlas 界面上创建的用户。

您是否注意到,我们不需要修改任何业务或 web 逻辑就可以更改持久性机制? 这证明了我们最初创建的层和抽象现在得到了回报,它允许应用不同部分之间的分离。

这样,我们就完成了本章,并将用户迁移到真实的数据库中。 我们可以对其他模块做同样的事情,但这基本上是相同的事情,不会增加你的学习经验。 我想挑战你写其他模块的逻辑,以便它可以连接到 MongoDB。

如果你想跳过这一步,但又想知道它是什么样子的,那么可以看看这本书的 GitHub 存储库。

小结

本章基本上从逻辑上总结了我们的应用。 我们将在第 8 章测试-单元和集成中添加测试和我们缺少的单一功能——评估博物馆的能力。 然而,大部分已经完成了。 在当前状态下,我们有一个应用,它的域被划分为模块,这些模块可以自己使用,彼此不依赖。 我们相信我们实现了一些代码中易于导航和可扩展的东西。

这就结束了不断地重新工作和优化架构、管理依赖关系和调整逻辑以确保代码尽可能地解耦,并在未来尽可能地容易更改的过程。 在完成所有这些工作的同时,我们设法创建了一个具有几个特性的应用,试图同时绕过行业标准。

我们从学习中间件功能开始这一章,这是我们之前使用过的功能,尽管我们还没有学习过。 我们了解了它们是如何工作的,以及如何利用它们在应用和路由中添加逻辑。 为了更具体一点,我们进入了一些具体的示例,并在应用中实现了其中的一些示例。 在这里,我们添加了一些常见功能,比如基本日志记录和请求定时。

然后,我们继续完成我们的认证之旅。 在前一章中添加用户和注册后,我们开始实现身份验证功能。 我们依赖一个外部包来管理我们的 JWT 令牌,稍后我们将其用于我们的授权机制。 在向用户提供令牌之后,我们必须确保令牌是有效的,只有这样用户才能访问应用。 我们在博物馆路径中添加了一条经过身份验证的路径,以确保只有经过身份验证的用户才能访问该路径。 再一次,中间件被用来检查令牌的有效性并在错误情况下回答请求。

在本章的最后,我们又向应用添加了一个特性:到真实数据库的连接。 在此之前,我们所有的应用模块都依赖于内存中的数据库。 在这里,我们将其中一个模块users移动到 MongoDB 实例。 为此,我们利用前面创建的层将业务逻辑与持久性和交付机制分离开来。 在这里,我们创建并实现了我们称为 MongoDB 的存储库,以确保应用在使用真正的持久性机制的情况下平稳运行。 我们在这个例子中使用了 MongoDB Atlas。

在下一章中,我们将在我们的 web 应用中添加更多的东西,即管理代码外的秘密和配置的能力,这是一个众所周知的最佳实践。 我们还将探讨 Deno 在浏览器中运行代码的可能性。 下一章将结束这本书的这一部分。 也就是说,构建应用的特性。 我们走吧!