创建后端认证

在第十章 “添加 Vuex Store” 和第十一章 “编写路由中间件和服务器中间件” 的前面练习中,我们为后端身份验证使用了虚拟用户,特别是在 /chapter-11/nuxt-universal/route-middleware/per-route/ 中用于单个路由中间件,例如:

// server/modules/public/user/_routes/login.js
router.post('/login', async (ctx, next) => {
  let request = ctx.request.body || {}
  if (request.username === 'demo' && request.password === 'demo') {
    let payload = { id: 1, name: 'Alexandre', username: 'demo' }
    let token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: 1 * 60 })
    //...
  }
})

但是在本章中,我们将使用包含一些用户数据的数据库进行身份验证。此外,在第九章 “添加服务器端数据库” 中,我们使用了 MongoDB 作为我们的数据库服务器。但这一次,为了多样性,让我们尝试一个不同的数据库系统——MySQL。那么,让我们开始吧。

使用 MySQL 作为服务器数据库

请确保你的本地机器上已安装 MySQL 服务器。在撰写本书时,最新的 MySQL 版本是 5.7。根据你使用的操作系统,你可以在 https://dev.mysql.com/doc/mysql-installation-excerpt/5.7/en/installing.html 找到适用于你系统的具体指南。如果你使用的是 Linux,你可以在 https://dev.mysql.com/doc/mysql-installation-excerpt/5.7/en/linux-installation.html 找到适用于你的 Linux 发行版的安装指南。如果你使用的是 Linux Ubuntu 并使用 APT 存储库,你可以遵循 https://www.google.com/search?q=https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/apt-repo-fresh-install 上的指南。

或者,你可以安装 MariaDB 服务器而不是 MySQL 服务器,以便在你的项目中使用关系数据库管理系统 (DBMS)。同样,根据你使用的操作系统,你可以在 https://mariadb.com/downloads/ 找到适用于你系统的具体指南。如果你使用的是 Linux,你可以在 https://www.google.com/search?q=https://downloads.mariadb.org/mariadb/repositories/ 找到适用于你的特定 Linux 发行版的指南。如果你使用的是 Linux Ubuntu 19.10,你可以遵循 https://www.google.com/search?q=https://downloads.mariadb.org/mariadb/repositories/%23distro%3DUbuntudistro_release%3Deoan—​ubuntu_eoanmirror%3Dbmeversion%3D10.4 上的指南。

无论你选择哪个,拥有一个管理工具来从浏览器管理你的 MySQL 数据库都很方便。你可以使用 phpMyAdminAdminer (https://www.google.com/search?q=https://www.adminer.org/latest.php);两者都需要你的机器上安装 PHP。如果你是 PHP 的新手,你可以使用第十六章 “为 Nuxt 创建一个与框架无关的 PHP API” 中的安装指南。本书更推荐使用 Adminer。你可以在 https://www.phpmyadmin.net/downloads/ 下载该程序。如果你想使用 phpMyAdmin,请访问 https://www.phpmyadmin.net/ 以了解更多信息。一旦你拥有了管理工具,请按照以下步骤设置我们将在本章中使用的数据库:

  1. 使用 Adminer 创建一个数据库,例如 “nuxt-auth”。

  2. 在该数据库中插入以下表和示例数据:

    DROP TABLE IF EXISTS users;
    CREATE TABLE users (
      id int(11) NOT NULL AUTO_INCREMENT,
      name varchar(255) NOT NULL,
      email varchar(255) NOT NULL,
      username varchar(255) NOT NULL,
      password varchar(255) NOT NULL,
      created_on datetime NOT NULL,
      last_on datetime NOT NULL,
      PRIMARY KEY (id),
      UNIQUE KEY email (email),
      UNIQUE KEY username (username)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    INSERT INTO users (id, name, email, username, password, created_on,
    last_on) VALUES
    (1, 'Alexandre', 'demo@gmail.com', 'demo',
    '$2a$10$pyMYtPfIvE.PAboF3cIx9.IsyW73voMIRxFINohzgeV0I2BxwnrEu',
    '2019-06-17 00:00:00', '2019-01-21 23:32:58');

上述示例数据中的用户密码是 123123,并使用 bcrypt 加密为 $2a$10$pyMYtPfIvE.PAboF3cIx9.IsyW73voMIRxFINohzgeV0I2BxwnrEu。我们将在服务器端安装并使用 bcryptjs Node.js 模块来哈希和验证此密码。但是在深入了解 bcryptjs 之前,让我们先看看我们将在下一节中创建的应用的结构。

你可以在我们的 GitHub 仓库的 /chapter-12/ 中找到我们导出的数据库副本 nuxt-auth.sql

组织跨域应用目录

我们一直在为单个域名构建 Nuxt 应用。自第八章 “添加服务器端框架” 以来,我们的服务器端 API 就与 Nuxt 紧密耦合在一起,在该章中,我们使用 Koa 作为服务器端框架和 API,用于处理和提供 Nuxt 应用的数据。如果你回顾一下 GitHub 仓库中的 /chapter-8/nuxt-universal/koa-nuxt/,你应该记得我们将服务器端程序和文件保存在 /server/ 目录下。我们还将包/模块依赖项保存在一个 package.json 文件中,并安装在同一个 /node_modules/ 目录下。当我们的应用变得更大时,将两个框架(NuxtKoa)的模块依赖项混合在同一个 package.json 文件中最终可能会令人困惑。它也可能使调试过程更加困难。因此,将我们由 NuxtKoa(或任何其他服务器端框架,如 Express)组成的单个应用分离成两个独立的应用程序可能更有利于可伸缩性和维护。现在,是时候构建一个跨域的 Nuxt 应用了。我们将重用和重构第八章 “添加服务器端框架” 中的 Nuxt 应用。让我们将我们的 Nuxt 应用称为前端应用,将 Koa 应用称为后端应用。我们将在这两个应用中分别添加新的模块。

后端应用将进行后端身份验证,而前端应用将单独进行前端身份验证,但它们最终将作为一个整体运行。为了使这个学习和重构过程对你来说更容易,我们将仅使用 JWT 进行身份验证。因此,让我们按照以下步骤创建我们的新工作目录:

  1. 创建一个项目目录,并使用你喜欢的任何名称命名它,并在其中创建两个子目录。一个名为 frontend,另一个名为 backend,如下所示:

    <项目名称>
    ├── frontend
    └── backend
  2. 使用脚手架工具 create-nuxt-app/frontend/ 目录下安装 Nuxt 应用,这样你就可以得到你已经熟悉的 Nuxt 目录,如下所示:

    frontend
    ├── package.json
    ├── nuxt.config.js
    ├── store
    │   ├── index.js
    │   └── ...
    └── pages
        ├── index.vue
        └── ...
  3. /backend/ 目录下创建一个 package.json 文件、一个 backpack.config.js 文件、一个 /static/ 文件夹和一个 /src/ 文件夹,然后在 /src/ 文件夹中创建其他文件和子文件夹(我们将在接下来的章节中更详细地介绍它们),如下所示:

    backend
    ├── package.json
    ├── backpack.config.js
    ├── assets
    │   └── ...
    ├── static
    │   └── ...
    └── src
        ├── index.js
        ├── ...
        ├── modules
        │   └── ...
        └── core
            └── ...

backend 目录是我们 API 所在的位置,可以使用 ExpressKoa 构建。我们将继续使用你已经熟悉的 Koa。我们将在该目录中安装服务器端依赖项,例如 mysqlbcryptjsjsonwebtoken,这样它们就不会与 Nuxt 应用的前端模块混淆。

正如你所看到的,在这个新的结构中,我们成功地将我们的 APINuxt 应用完全分离和解耦。这对于调试和开发都有好处。从技术上讲,我们现在将一次开发和测试一个应用。在一个环境中开发两个应用可能会令人困惑,并且当应用变得更大时(正如我们前面提到的),协作可能会很困难。

在研究如何在服务器端使用 JWT 之前,让我们首先在下一节中更深入地了解如何在 /src/ 目录中组织 API 路由和模块。

创建 API 公共/私有路由及其模块

请注意,不强制要求遵循本书中建议的目录结构。关于如何使用 Koa 组织我们的应用程序,没有武断的或官方的规则。Koa 社区贡献了一些骨架、样板和框架,你可以通过访问 https://github.com/koajs/koa/wiki 来查看它们。现在,让我们更仔细地看看 /src/ 目录下的目录结构,我们将在其中开发我们的 API 源代码,请按照以下步骤操作:

  1. /src/ 目录下创建以下文件夹和空的 .js 文件:

    └── src
        ├── index.js
        ├── middlewares.js
        ├── routes-private.js
        ├── routes-public.js
        ├── config
        │   └── index.js
        ├── core
        │   └── database
        ├── middlewares
        │   ├── authenticate.js
        │   ├── errorHandler.js
        │   └── ...
        └── modules
            └── ...

    /src/ 目录下,/middlewares/ 目录用于存放所有中间件,例如 authenticate.js,我们希望使用 Kaoapp.use 方法注册它,而 /modules/ 目录用于存放所有 API 端点的分组,例如 homeuserlogin

  2. 创建两个主要目录 privatepublic,并在每个目录下创建子目录,如下所示:

    └── modules
        ├── private
        │   └── home
        └── public
            ├── home
            ├── user
            └── login

    /public/ 目录用于无需 JWT 即可公开访问的模块,例如登录路由,而 /private/ 目录用于需要 JWT 保护的模块。正如你所看到的,我们将 API 路由分成两个主要组,因此 /private/ 组将在 routes-private.js 中处理,而 /public/ 组将在 routes-public.js 中处理。我们有 /config/ 目录用于存放所有配置文件,以及 /core/ 目录用于存放可以在整个应用程序中共享和使用的抽象程序或模块,例如你将在本章后面发现的 mysql 连接池。因此,从上面的目录树中,我们将在我们的 API 中使用这些公共模块:homeuserlogin 和一个私有模块:home

  3. 在每个模块中,例如在 user 模块中,创建一个 /_routes/ 目录来配置属于此特定模块(或组)的所有路由(或端点):

    └── user
        ├── index.js
        └── _routes
            ├── index.js
            └── fetch-user.js

    在这个 user 模块中,/user/index.js 文件用于组装和分组该模块的所有路由到模块路由中,例如:

    // src/modules/public/user/index.js
    import Router from 'koa-router'
    import fetchUsers from './_routes'
    import fetchUser from './_routes/fetch-user'
    const router = new Router({
      prefix: '/users'
    })
    const routes = [fetchUsers, fetchUser]
    for (var route of routes) {
      router.use(route.routes(), route.allowedMethods())
    }
    export default router

    设置为 prefix 键的 /users 值是此 user 模块的模块路由。在每个导入的子路由中,我们开发我们的代码,例如登录路由的代码。

  4. 在每个模块的每个 .js 文件中,例如在 user 模块中,添加以下基本代码结构,以便在后面的阶段构建我们的代码:

    // src/modules/public/user/_routes/index.js
    import Router from 'koa-router'
    import pool from 'core/database/mysql'
    const router = new Router()
    router.get('/', async (ctx, next) => {
      // 代码写在这里....
    })
    export default router
  5. 让我们创建 home 模块,它将返回一个包含 “Hello World!” 消息的响应,如下所示:

    // src/modules/public/home/_routes/index.js
    import Router from 'koa-router'
    const router = new Router()
    router.get('/', async (ctx, next) => {
      ctx.type = 'json'
      ctx.body = {
        message: 'Hello World!'
      }
    })
    export default router
  6. home 模块只有一个路由,但我们仍然需要在该模块的 index.js 文件中组装这个路由,以便我们的代码与其他模块保持一致,如下所示:

    // src/modules/public/home/index.js
    import Router from 'koa-router'
    import index from './_routes'
    const router = new Router() // 没有前缀
    const routes = [index]
    for (var route of routes) {
      router.use(route.routes(), route.allowedMethods())
    }
    export default router

    请注意,此 home 模块没有添加前缀,因此我们可以直接在 localhost:4000/public 访问其唯一的路由。

  7. /src/ 目录下创建 routes-public.js 文件,并从 /modules/ 目录下的公共模块导入所有公共路由,如下所示:

    // src/routes-public.js
    import Router from 'koa-router'
    import home from './modules/public/home'
    import user from './modules/public/user'
    import login from './modules/public/login'
    const router = new Router({ prefix: '/public' })
    const modules = [home, user, login]
    for (var module of modules) {
      router.use(module.routes(), module.allowedMethods())
    }
    export default router

    正如你所看到的,我们导入了我们在前面步骤中刚刚创建的 home 模块。我们将在接下来的章节中创建 userlogin 模块。导入这些模块后,我们应该将它们的路由注册到路由器,然后导出该路由器。请注意,这些路由添加了一个前缀 /public。另请注意,每个路由都通过普通的 JavaScript for 循环函数进行循环并注册到路由器。

  8. /src/ 目录下创建 routes-private.js 文件,并从 /modules/ 目录下的私有模块导入所有私有路由,如下所示:

    // src/routes-private.js
    import Router from 'koa-router'
    import home from './modules/private/home'
    import authenticate from './middlewares/authenticate'
    const router = new Router({ prefix: '/private' })
    const modules = [home]
    for (var module of modules) {
      router.use(authenticate, module.routes(),
        module.allowedMethods())
    }
    export default router

    在这个文件中,你可以看到我们将在接下来的章节中仅创建一个私有的 home 模块。此外,authenticate 中间件被导入到这个文件中并添加到私有路由,以便保护私有模块。之后,我们应该使用路由器导出私有路由,并为其添加 /private 前缀。我们将在接下来的章节中创建这个 authenticate 中间件。现在,让我们使用 Backpack 配置我们的模块文件路径,并安装我们的 API 必需的基本 Node.js 模块。

  9. 通过 Backpack 配置文件将以下额外的文件路径(./src./src/core./src/modules)添加到 webpack 配置中:

    // backpack.config.js
    module.exports = {
      webpack: (config, options, webpack) => {
        config.resolve.modules = ['./src', './src/core',
          './src/modules']
        return config
      }
    }

    有了这些额外的文件路径,我们可以简单地使用 import pool from 'core/database/mysql' 导入我们的模块,而不是使用以下代码:

    import pool from '../../../../core/database/mysql'

    有关使用 webpack 中的 modules 选项解析模块的更多信息,请访问 https://www.google.com/search?q=https://webpack.js.org/configuration/resolve/%23resolvemodules。

  10. 现在我们应该在我们的项目中安装 Backpack,以及开发这个后端应用所需其他基本和必要的 Node.js 模块:

    $ npm i backpack-core
    $ npm i cross-env
    $ npm i koa
    $ npm i koa-bodyparser
    $ npm i koa-favicon
    $ npm i koa-router
    $ npm i koa-static

    你应该熟悉这些模块,因为你在第八章 “添加服务器端框架”(你可以在我们的 GitHub 仓库中的 /chapter-8/nuxt-universal/koa-nuxt/ 中回顾一下)、第十章 “添加 Vuex Store”(在 /chapter-10/nuxt-universal/nuxtServerInit/ 中)和第十一章 “编写路由中间件和服务器中间件”(在 /chapter-11/nuxt-universal/route-middleware/per-route/ 中)中学习并安装过它们。

  11. /backend/ 目录的 package.json 文件中添加以下运行脚本:

    // package.json
    {
      "scripts": {
        "dev": "backpack",
        "build": "backpack build",
        "start": "cross-env NODE_ENV=production node build/main.js"
      }
    }

    因此,“dev” 运行脚本用于开发我们的 API,“build” 运行脚本用于在完成时构建我们的 API,“start” 脚本用于在构建后提供 API 服务。

  12. 将以下服务器配置添加到 /config/ 目录下的 index.js 文件中:

    // src/config/index.js
    export default {
      server: {
        port: 4000
      },
    }

    这个配置文件只有一个非常简单的配置,即服务器,配置为在 4000 端口运行。

  13. 导入你刚刚安装的以下模块,并将它们注册为 /src/ 目录下的 middlewares.js 文件中的中间件,如下所示:

    // src/middlewares.js
    import serve from 'koa-static'
    import favicon from 'koa-favicon'
    import bodyParser from 'koa-bodyparser'
    export default (app) => {
      app.use(serve('assets'))
      app.use(favicon('static/favicon.ico'))
      app.use(bodyParser())
    }
  14. /middlewares/ 目录中创建一个处理 HTTP 响应并返回 200 HTTP 状态码的中间件:

    // src/middlewares/okOutput.js
    export default async (ctx, next) => {
      await next()
      if (ctx.status === 200) {
        ctx.body = {
          status: 200,
          data: ctx.body
        }
      }
    }

    如果响应成功,我们将得到以下 JSON 输出:

    {"status":200,"data":{"message":"Hello World!"}}
  15. 创建一个处理 HTTP 错误状态(如 400404500)的中间件:

    // src/middlewares/errorHandler.js
    export default async (ctx, next) => {
      try {
        await next()
      } catch (err) {
        ctx.status = err.status || 500
        ctx.type = 'json'
        ctx.body = {
          status: ctx.status,
          message: err.message
        }
        ctx.app.emit('error', err, ctx)
      }
    }

    对于 400 错误响应,你将得到以下 JSON 响应:

    {"status":400,"message":"username param is required."}
  16. 创建一个专门处理 HTTP 404 响应的中间件,通过抛出 “Not found” 消息:

    // src/middlewares/notFound.js
    export default async (ctx, next) => {
      await next()
      if (ctx.status === 404) {
        ctx.throw(404, 'Not found')
      }
    }

    对于未知路由,我们将得到以下 JSON 输出:

    {"status":404,"message":"Not found"}
  17. 将这三个中间件导入 middlewares.js,并将它们注册到 Koa 实例,就像其他中间件一样:

    // src/middlewares.js
    import serve from 'koa-static'
    import favicon from 'koa-favicon'
    import bodyParser from 'koa-bodyparser'
    import errorHandler from './middlewares/errorHandler'
    import notFound from './middlewares/notFound'
    import okOutput from './middlewares/okOutput'
    export default (app) => {
      app.use(errorHandler)
      app.use(notFound)
      app.use(okOutput)
      app.use(serve('assets'))
      app.use(favicon('static/favicon.ico'))
      app.use(bodyParser())
    }

    请注意我们如何按顺序排列这些中间件——即使 errorHandler 中间件首先注册,如果 HTTP 响应中存在错误,它也是在 Koa 的上游级联中最后重新执行的中间件。如果 HTTP 响应状态为 200,上游级联将在 okOutput 中间件处停止。另请注意,这些中间件必须在 staticfaviconbodyparser 中间件之后注册,这些中间件必须在下游级联中首先被调用和公开服务。

  18. routes-public.jsroutes-private.js 导入公共和私有路由,并在前面的中间件之后注册它们,如下所示:

    // Import custom local middlewares.
    import routesPublic from './routes-public';
    import routesPrivate from './routes-private';
    
    export default (app) => {
      app.use(routesPublic.routes(), routesPublic.allowedMethods());
      app.use(routesPrivate.routes(), routesPrivate.allowedMethods());
    };
  19. 导入 Koa,来自 middlewares.js 文件的所有中间件,以及 /config/ 目录下的 index.js 文件中的服务器配置,实例化一个 Koa 实例并将其传递给 middlewares.js 文件,然后使用此 Koa 实例启动服务器:

    // index.js
    import Koa from 'koa';
    import config from './config';
    import middlewares from './middlewares';
    
    const app = new Koa();
    const host = process.env.HOST || '127.0.0.1';
    const port = process.env.PORT || config.server.port;
    
    middlewares(app);
    
    app.listen(port, host);
  20. 使用 npm run dev 运行此 API,你应该在浏览器中看到应用运行在 localhost:4000 上。当你访问 localhost:4000 时,你应该在浏览器上看到以下输出:

    {"status":404,"message":"Not found"}

    这是因为 / 上不再设置任何路由——我们已经为所有路由添加了 /public/private 前缀。但是,如果你导航到 localhost:4000/public,你将得到以下 JSON 输出:

    {"status":200,"data":{"message":"Hello World!"}}

    这是我们前面步骤中刚刚创建的 home 模块的响应。此外,你应该看到你的 faviconassetslocalhost:4000 上正确地提供服务——如果你已将它们放置在 /static//assets/ 目录中,例如:

    localhost:4000/sample-asset.jpg
    localhost:4000/favicon.ico

你可以在 localhost:4000 上看到这两个目录中的文件。这是因为在 Koa 的下游级联发生时,staticfavicon 中间件被安装并注册为首先执行在中间件栈中。

做得好!现在你已经准备好了新的工作目录,并且一个基本的 API 正在运行,就像第八章 “添加服务器端框架” 中一样。接下来,你需要在 /backend/ 目录中安装其他服务器端依赖项,并开始向公共的 userlogin 模块以及私有的 home 模块中的路由添加代码。让我们从下一节中的 bcryptjs 开始。

你可以在我们的 GitHub 仓库的 /chapter-12/nuxt-universal/cross-domain/jwt/axiosmodule/backend/ 中找到具有上述结构的示例应用。

使用 bcryptjs 模块(Node.js)

正如我们之前提到的,bcryptjs 用于哈希和验证密码。请查看以下简化步骤,以获取有关如何在我们的应用中使用此模块的更多建议:

  1. 通过 npm 安装 bcryptjs 模块:

    $ npm i bcryptjs
  2. 通过使用请求体(request)中从客户端发送的密码添加盐(salt)来哈希密码,例如,在 user 模块中创建新用户期间:

    // src/modules/public/user/_routes/create-user.js
    import bcrypt from 'bcryptjs'
    const saltRounds = 10
    const salt = bcrypt.genSaltSync(saltRounds)
    const hashed = bcrypt.hashSync(request.password, salt)

    请注意,为了加快本章的身份验证课程,我们跳过了创建新用户的过程。但是在更完整的 CRUD 中,你可以使用此步骤来哈希用户提供的密码。

  3. 通过比较从客户端发送的密码(request)与数据库中存储的密码来验证密码,例如,在 login 模块中的登录身份验证过程中,如下所示:

    // src/modules/public/login/_routes/local.js
    import bcrypt from 'bcryptjs'
    const isMatched = bcrypt.compareSync(request.password,
    user.password)
    if (isMatched === false) { ... }

    请注意,你可以在我们的 GitHub 仓库中的 /chapter-12/nuxt-universal/cross-domain/jwt/axiosmodule/backend/src/modules/public/login/_routes/local.js 中找到此步骤在我们的后端应用中的应用方式。

我们将在下一节向你展示如何使用 bcryptjs 来验证来自客户端的传入密码。但是在哈希和验证来自客户端的密码之前,首先,我们需要连接到我们的 MySQL 数据库,以便确定是插入新用户还是查询现有用户。为此,我们的应用需要下一个 Node.js 模块:mysql - 一个 MySQL 客户端。因此,让我们继续下一节,看看如何安装和使用它。

如果你想了解更多关于此模块的信息以及一些异步示例,请访问 https://www.google.com/search?q=https://github.com/dcodeIO/bcrypt.js。

使用 mysql 模块(Node.js)

我们在上一节中安装了 MySQL 服务器。现在我们需要一个 MySQL 客户端,我们可以连接到 MySQL 服务器并从我们的服务器端程序执行 SQL 查询。mysql 是标准的 MySQL Node.js 模块,它实现了 MySQL 协议,因此无论你使用的是 MySQL 服务器还是 MariaDB 服务器,我们都可以使用这个模块来处理 MySQL 连接和 SQL 查询。那么,让我们按照以下步骤开始:

  1. 通过 npm 安装 mysql 模块:

    $ npm i mysql
  2. 使用你的 MySQL 连接详细信息在 /src/ 目录的子目录中创建一个 mysql.js 文件,并在其中创建 MySQL 连接实例,如下所示:

    // src/core/database/mysql.js
    import util from 'util'
    import mysql from 'mysql'
    const pool = mysql.createPool({
      connectionLimit: 10,
      host : 'localhost',
      user : '<用户名>',
      password : '<密码>',
      database : '<数据库>'
    })
    pool.getConnection((err, connection) => {
      if (error) {
        // 处理错误 ...
      }
      // 如果没有错误,将连接释放回连接池。
      if (connection) {
        connection.release()
      }
      return
    })
    pool.query = util.promisify(pool.query)
    export default pool

    让我们通过以下注释来理解我们刚刚创建的代码:

    • mysql 不支持 async/await,因此我们使用 Node.jsutil.promisify 工具包装了 MySQLpool.querypool.querymysql 模块中处理 SQL 查询的函数,它通过回调函数返回结果,例如:

      connection.query('SELECT ...', function (error, results, fields) {
        if (error) {
          throw error
        }
        // 做一些事情 ...
      })

      通过 util.promisify 工具,我们消除了回调函数,现在我们可以使用 async/await,如下所示:

      let result = null
      try {
        result = await pool.query('SELECT ...')
      } catch (error) {
        // 处理错误 ...
      }
    • pool.query 是这三个函数 pool.getConnection、connection.queryconnection.release 的快捷方式函数,我们应该一起使用它们在 mysql 模块的连接池中执行 SQL 查询。通过使用 pool.query,当你完成操作后,连接会自动释放回连接池。这是 pool.query 函数的基本底层结构:

      import mysql from 'mysql'
      const pool = mysql.createPool(...)
      pool.getConnection(function(error, connection) {
        if (error) { throw error }
        connection.query('SELECT ...', function (error, results,
        fields) {
          connection.release()
          if (error) { throw error }
        })
      })
    • 在这个 mysql 模块中,我们没有使用 mysql.createConnection 逐一创建和管理 MySQL 连接(这可能是一项开销很大的操作),而是使用 mysql.createPool 进行连接池管理。连接池是可重用数据库连接的缓存,用于减少每次想要连接数据库时建立新连接的成本。有关连接池的更多信息,请访问 https://www.google.com/search?q=https://github.com/mysqljs/mysql%23pooling-connections。

  3. 因此,我们已将 MySQL 连接抽象到 /core/ 目录中的上述文件中。现在我们可以像下面这样在用户(user)模块中使用它来获取用户列表:

    // backend/src/modules/public/user/_routes/index.js
    import Router from 'koa-router';
    import pool from 'core/database/mysql';
    
    const router = new Router();
    
    router.get('/', async (ctx, next) => {
      try {
        var users = await pool.query('SELECT `id`, `name`, `created_on` FROM `users`');
      } catch (err) {
        // Handle the error appropriately, e.g., log it and set an error response
        console.error('Error fetching users:', err);
        ctx.status = 500;
        ctx.body = { error: 'Failed to fetch users' };
        return;
      }
      ctx.type = 'json';
      ctx.body = users;
    });
    
    export default router;

    你可以看到,我们使用了在前一节中介绍的相同的代码结构,通过 MySQL 连接池将请求发送到 MySQL 服务器。在我们发送的查询中,我们告诉 MySQL 服务器在结果中只返回 users 表的 idnamecreated_on 字段。

  4. 如果你访问 localhost:4000/public/users 这个用户路由,你应该在屏幕上看到以下输出:

{"status":200,"data":[{"id":1,"name":"Alexandre","created_on":"2019-06-16T22:00:00.000Z"}]}

现在我们有了用于连接 MySQL 服务器和数据库的 mysql 模块,以及用于哈希和验证客户端密码的 bcryptjs 模块,因此我们可以重构和改进我们在前一章中粗略创建的登录代码。让我们在下一节中了解如何操作。

如果你想了解更多关于 mysql 模块的信息,请访问 https://github.com/mysqljs/mysql。

重构服务器端的登录代码

我们在前面的章节中收集了所有必要的要素,一旦创建了 MySQL 连接池,我们就可以按照以下步骤重构和改进我们在第十章 “添加 Vuex Store” 和第十一章 “编写路由中间件和服务器中间件” 中的登录代码:

  1. 导入所有依赖项,例如 koa-routerjsonwebtokenbcryptjs 和登录路由的 MySQL 连接池,如下所示:

    // src/modules/public/login/_routes/local.js
    import Router from 'koa-router'
    import jwt from 'jsonwebtoken'
    import bcrypt from 'bcryptjs'
    import pool from 'core/database/mysql'
    import config from 'config'
    const router = new Router()
    router.post('/login', async (ctx, next) => {
      let request = ctx.request.body || {}
      //...
    })
    export default router

    我们在这里导入了 config 文件,用于存储我们 API 的配置选项,其中包含 MySQL 数据库连接详细信息、服务器和静态目录的选项,以及我们稍后签署令牌所需的 JWT 密钥。

  2. 验证登录路由的 post 方法中的用户输入,以确保它们已定义且不为空:

    if (request.username === undefined) {
      ctx.throw(400, 'username 参数是必需的。')
    }
    if (request.password === undefined) {
      ctx.throw(400, 'password 参数是必需的。')
    }
    if (request.username === '') {
      ctx.throw(400, 'username 是必需的。')
    }
    if (request.password === '') {
      ctx.throw(400, 'password 是必需的。')
    }
  3. 当用户名和密码通过验证后,将它们赋值给变量,以便查询数据库:

    let username = request.username
    let password = request.password
    let users = []
    try {
      users = await pool.query('SELECT * FROM users WHERE username = ?', [username])
    } catch(err) {
      ctx.throw(400, err.sqlMessage)
    }
    if (users.length === 0) {
      ctx.throw(404, '未找到用户')
    }
  4. 如果 MySQL 查询有结果,则使用 bcryptjs 比较存储的密码和用户输入的密码:

    let user = users[0]
    let match = false
    try {
      match = await bcrypt.compare(password, user.password)
    } catch(err) {
      ctx.throw(401, err)
    }
    if (match === false) {
      ctx.throw(401, '密码无效')
    }
  5. 如果用户通过了之前的所有步骤和验证,则签署一个 JWT 并将其发送给客户端:

    let payload = { name: user.name, email: user.email }
    let token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: 1 * 60 })
    ctx.body = {
      user: payload,
      message: '登录成功',
      token: token
    }
  6. 使用 npm run dev 运行 API,并在你的终端上使用 curl 手动测试之前的路由,如下所示:

    $ curl -X POST -d "username=demo&password=123123" -H "Content-Type: application/x-www-form-urlencoded" http://localhost:4000/public/login/local

    如果登录成功,你将得到以下结果:

    {"status":200,"data":{"user":{"name":"Alexandre","email":"demo@gmail.com"},"message":"logged in ok","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiQWxleGFuZHJlIiwiZW1haWwiOiJkZW1vQGdtYWlsLmNvbSIsImlhdCI6MTY4MjgwMzYwNSwiZXhwIjoxNjgyODAzNjY1fQ.someRandomToken"}}

当然,每次成功签署后,你在上述响应中都会得到不同的令牌。现在你已经成功地重构和改进了登录代码。接下来,我们将在下一节中了解如何验证客户端发送回请求标头的上述令牌。请继续阅读!

在服务器端验证传入的令牌

我们已经成功地签署了一个令牌,并在凭据与数据库中存储的凭据匹配时将其返回给客户端。但这只是一半的故事。每次客户端使用该令牌发出请求时,我们都应该验证该令牌,以访问所有受服务器端中间件保护的受保护路由。

因此,让我们按照以下步骤创建中间件和受保护的路由:

  1. /src/ 目录下的 /middlewares/ 目录中创建一个中间件文件,代码如下:

    // src/middlewares/authenticate.js
    import jwt from 'jsonwebtoken'
    import config from 'config'
    export default async (ctx, next) => {
      if (!ctx.headers.authorization) {
        ctx.throw(401, '受保护的资源,请使用 Authorization 标头获取访问权限')
      }
      const token = ctx.headers.authorization.split(' ')[1]
      try {
        ctx.state.jwtPayload = jwt.verify(token, config.JWT_SECRET)
      } catch (err) {
        // 处理错误。
        ctx.throw(401, '无效的令牌')
      }
      await next()
    }

    if (!ctx.headers.authorization) 条件用于确保客户端已在请求标头中包含令牌。由于授权信息以 Bearer: [token] 的格式出现,其中包含一个空格,我们按空格拆分该值,并在 trycatch 块中仅获取 [token] 进行验证。如果令牌有效,那么我们使用 await next() 将请求传递到下一个路由。

  2. 导入此中间件并将其注入到我们想要使用 JWT 保护的路由组中:

    // src/routes-private.js
    import Router from 'koa-router'
    import home from './modules/private/home'
    import authenticate from './middlewares/authenticate'
    const router = new Router({ prefix: '/private' })
    const modules = [home]
    for (var module of modules) {
      router.use(authenticate, module.routes(),
        module.allowedMethods())
    }
    export default router

在这个 API 中,我们想要保护所有位于 /private 路由下的路由。因此,我们将在此文件中导入任何我们想要保护的路由,例如上面的 /home 路由。因此,当你使用 /private/home 请求此路由时,你必须在请求标头中包含令牌才能访问此路由。

就是这样。你已经成功地在服务器端创建并验证了 JWT。接下来,我们将在下一节中了解如何在客户端使用 Nuxt 完成 JWT 身份验证。让我们开始吧!