Skip to content

Latest commit

 

History

History
1125 lines (784 loc) · 44.6 KB

File metadata and controls

1125 lines (784 loc) · 44.6 KB

三、使用 MongoDB、Express 和 Node 构建后端

在大多数 web 应用的开发过程中,有一些常见任务、基本特性和实现代码在整个过程中重复出现。本书中开发的 MERN 应用也是如此。考虑到这些相似性,我们将首先为框架 MERN 应用奠定基础,该应用可以轻松修改和扩展以实现各种 MERN 应用。

在本章中,我们将介绍以下主题,并使用 Node、Express 和 MongoDB 从 MERN 骨架的后端实现开始:

  • MERN 应用中的用户 CRUD 和 auth
  • 使用 Express 服务器处理 HTTP 请求
  • 对用户模型使用 Mongoose 模式
  • 用于用户 CRUD 和 auth 的 API
  • 使用 JWT 对受保护的路由进行身份验证
  • 运行后端代码和检查 API

框架应用概述

框架应用将封装基本功能和大多数 MERN 应用重复的工作流。我们将基本上构建一个基本但功能完整的 MERN web 应用框架,用户为create、update、delete(CRUD)和auth诱骗——认证来源(认证)功能,还将展示如何为使用此堆栈构建的通用 web 应用开发、组织和运行代码。目的是使框架尽可能简单,以便易于扩展,并可作为开发不同 MERN 应用的基础应用。

特征分解

在框架应用中,我们将添加以下具有用户 CRUD 和身份验证功能实现的用例:

  • 注册:用户可以使用电子邮件地址创建新帐户进行注册
  • 用户列表:任何访问者都可以看到所有注册用户的列表
  • 认证:注册用户可以登录和注销
  • 受保护用户档案:只有注册用户才能在登录后查看个人用户详细信息
  • 授权用户编辑和删除:只有注册和认证的用户才能编辑或删除自己的用户帐户详细信息

本章的重点–后端

在本章中,我们将重点介绍使用 Node、Express 和 MongoDB 为框架应用构建一个工作后端。完成的后端将是一个独立的服务器端应用,可以处理 HTTP 请求以创建用户、列出所有用户以及查看、更新或删除数据库中的用户,同时考虑用户身份验证和授权。

用户模型

用户模型将定义要存储在 MongoDB 数据库中的用户详细信息,并处理与用户相关的业务逻辑,如密码加密和用户数据验证。此骨架版本的用户模型将是基本的,支持以下属性:

| 字段名 | | 说明 | | name | 一串 | 存储用户名所需的字段 | | email | 一串 | 存储用户电子邮件和识别每个帐户所需的唯一字段(每个唯一电子邮件只允许一个帐户) | | password | 一串 | 身份验证的必填字段,数据库将存储加密的密码,而不是出于安全目的存储的实际字符串 | | created | 日期 | 创建新用户帐户时自动生成的时间戳 | | updated | 日期 | 更新现有用户详细信息时自动生成的时间戳 |

用户 CRUD 的 API 端点

为了在用户数据库上启用和处理用户 CRUD 操作,后端将实现并公开前端可以在视图中使用的 API 端点,如下所示:

| 操作 | API 路线 | HTTP 方式 | | 创建用户 | /api/users | POST | | 列出所有用户 | /api/users | GET | | 获取用户 | /api/users/:userId | GET | | 更新用户 | /api/users/:userId | PUT | | 删除用户 | /api/users/:userId | DELETE | | 用户登录 | /auth/signin | POST | | 用户注销(可选) | /auth/signout | GET |

其中一些用户 CRUD 操作将具有受保护的访问权限,这将要求请求的客户端进行身份验证、授权或两者兼有。最后两条路由用于身份验证,允许用户登录和注销。

使用 JSON Web 令牌进行身份验证

为了根据框架特性限制和保护对用户 API 端点的访问,后端需要合并身份验证和授权机制。在实现 web 应用的用户身份验证时,有许多选项。最常见且经过时间测试的选项是使用会话在客户端和服务器端存储用户状态。但较新的方法是使用JSON Web 令牌JWT)作为无状态身份验证机制,不需要在服务器端存储用户状态。

这两种方法对于相关的实际用例都有优势。然而,为了使本书中的代码保持简单,并且因为它与 MERN 堆栈和我们的示例应用很好地匹配,我们将使用 JWT 进行 auth 实现。此外,本书还将在未来章节中建议安全增强选项。

JWT 的工作原理

当用户使用其凭据成功登录时,服务器端将生成一个使用密钥和唯一用户详细信息签名的 JWT。然后,该令牌返回给请求客户端,以本地保存在localStoragesessionStorage或浏览器中的 cookie 中,基本上将维护用户状态的责任移交给客户端:

对于成功登录后发出的 HTTP 请求,特别是对受保护且访问受限的 API 端点的请求,客户端必须将此令牌附加到请求。更具体地说,JSON Web Token必须作为Bearer包含在请求Authorization头中:

Authorization: Bearer <JSON Web Token>

当服务器收到受保护 API 端点的请求时,它会检查请求的Authorization头是否有有效的 JWT,然后验证签名以识别发送方,并确保请求数据未损坏。如果令牌有效,则向请求客户端授予对关联操作或资源的访问权限,否则将返回授权错误。

在骨架应用中,当用户使用电子邮件和密码登录时,后端将生成一个带有用户 ID 和密钥的签名 JWT,该密钥仅在服务器上可用。当用户试图查看任何用户配置文件、更新其帐户详细信息或删除其用户帐户时,需要使用此令牌进行验证。

实现用户模型以存储和验证用户数据,然后将其与 API 集成以基于 auth 和 JWT 执行 CRUD 操作,将生成一个功能独立的后端。在本章的其余部分中,我们将了解如何在 MERN 堆栈和设置中实现这一点。

实现骨架后端

为了开始开发 MERN 框架的后端部分,我们将首先设置项目文件夹,安装和配置必要的 npm 模块,然后准备运行脚本以帮助开发和运行代码。然后,我们将一步一步地完成代码,以实现用户模型、API 端点和基于 JWT 的身份验证,以满足前面为面向用户的特性定义的规范。

The code discussed in this chapter, and for the complete skeleton application is available on GitHub in the repository at github.com/shamahoque/mern-skeleton. The code for just the backend is available at the same repository in the branch named mern-skeleton-backend. You can clone this code and run the application as you go through the code explanations in the rest of this chapter. 

文件夹和文件结构

以下文件夹结构仅显示与 MERN skeleton 后端相关的文件。使用这些文件,我们将生成一个运行正常的独立服务器端应用:

| mern_skeleton/
   | -- config/
      | --- config.js
   | -- server/
      | --- controllers/
         | ---- auth.controller.js
         | ---- user.controller.js
      | --- helpers/
         | ---- dbErrorHandler.js
      | --- models/
         | ---- user.model.js
      | --- routes/
         | ---- auth.routes.js
         | ---- user.routes.js
      | --- express.js
      | --- server.js
  | -- .babelrc
  | -- nodemon.json
  | -- package.json
  | -- template.js
  | -- webpack.config.server.js

这个结构将在下一章中进一步扩展,我们通过添加一个React前端来完成骨架应用。

建立项目

如果开发环境已经设置好,我们可以初始化 MERN 项目来开始开发后端。首先,我们将在项目文件夹中初始化package.json,配置并安装开发依赖项,设置代码中要使用的配置变量,并使用运行脚本更新package.json,以帮助开发和运行代码

初始化 package.json

我们需要一个package.json文件来存储项目的元信息,列出模块依赖项和版本号,并定义运行脚本。要初始化项目文件夹中的package.json文件,请从命令行转到项目文件夹并运行npm init,然后按照说明添加必要的详细信息。创建package.json后,我们可以继续进行设置和开发,并在整个代码实现过程中需要更多模块时更新文件。

开发依赖关系

为了开始开发并运行后端服务器代码,我们将按照第 2 章、**准备开发环境中所述配置并安装 Babel、Webpack 和 Nodemon,只对后端进行一些小的调整。

巴别塔

因为我们将使用 ES6 编写后端代码,所以我们将配置并安装 Babel 模块来转换 ES6。

首先,我们在.babelrc文件中为最新的 JS 功能和babel-preset-env中未涉及的一些 stage-x 功能配置了 Babel。

mern-skeleton/.babelrc

{
    "presets": [
      "env",
      "stage-2"
    ]
}

接下来,我们从命令行将 Babel 模块安装为devDependencies

npm install --save-dev babel-core babel-loader babel-preset-env babel-preset-stage-2

模块安装完成后,您会注意到package.json文件中的devDependencies列表已更新。

网页包

我们将需要 Webpack 使用 Babel 编译和捆绑服务器端代码,对于配置,我们可以使用第 2 章中讨论的webpack.config.server.js准备开发环境

从命令行运行以下命令来安装webpackwebpack-cliwebpack-node-externals模块:

npm install --save-dev webpack webpack-cli webpack-node-externals

这将安装网页包模块并更新package.json文件。

诺德蒙

为了在开发过程中更新代码时自动重新启动节点服务器,我们将使用 Nodemon 监视服务器代码的更改。我们可以使用第 2 章准备开发环境中讨论的相同安装和配置指南。

配置变量

config/config.js文件中,我们将定义一些与服务器端配置相关的变量,这些变量将在代码中使用,但不应作为最佳实践硬编码,也不应出于安全目的硬编码。

mern-skeleton/config/config.js

const config = {
  env: process.env.NODE_ENV || 'development',
  port: process.env.PORT || 3000,
  jwtSecret: process.env.JWT_SECRET || "YOUR_secret_key",
  mongoUri: process.env.MONGODB_URI ||
    process.env.MONGO_HOST ||
    'mongodb://' + (process.env.IP || 'localhost') + ':' +
    (process.env.MONGO_PORT || '27017') +
    '/mernproject'
}

export default config

定义的配置变量包括:

  • env:区分开发模式和生产模式
  • port:为服务器定义监听端口
  • jwtSecret:用于签署 JWT 的密钥
  • mongoUri:项目 MongoDB 数据库的位置

运行脚本

为了在开发后端代码时运行服务器,我们可以从package.json文件中的npm run development脚本开始。对于完整的框架应用,我们将使用第 2 章准备开发环境中定义的相同运行脚本。

mern-skeleton/package.json

"scripts": {
    "development": "nodemon"
 }

npm run development:根据nodemon.js中的配置,在项目文件夹的命令行中运行此命令基本上会启动 Nodemon。该配置指示 Nodemon 监视服务器文件的更新,并在更新时再次构建文件,然后重新启动服务器,以便更改立即可用。

准备服务器

在本节中,我们将集成 Express、Node 和 MongoDB,以便在开始实现特定于用户的功能之前运行完全配置的服务器。

配置 Express

要使用 Express,我们将首先安装 Express,然后在server/express.js文件中添加并配置它

在命令行中,运行以下命令来安装带有--save标志的express模块,从而自动更新package.json文件:

npm install express --save

安装 Express 后,我们可以将其导入到express.js文件中,根据需要进行配置,并将其提供给应用的其余部分。

mern-skeleton/server/express.js

import express from 'express'
const app = express()
  /*... configure express ... */
export default app

为了正确处理 HTTP 请求和服务响应,我们将使用以下模块配置 Express:

  • body-parser:Body 解析中间件,用于处理解析可流化请求对象的复杂性,因此我们可以通过在请求体内交换 JSON 来简化浏览器服务器通信:
    • 安装body-parser模块:npm install body-parser --save
    • 配置 Express:bodyParser.json()bodyParser.urlencoded({ extended: true })
  • cookie-parser:Cookie 解析中间件,用于解析和设置请求对象中的 Cookie: 安装cookie-parser模块:npm install cookie-parser --save
  • compression:压缩中间件,将尝试压缩通过中间件的所有请求的响应体: 安装compression模块:npm install compression --save
  • helmet:通过设置各种 HTTP 头帮助保护 Express 应用安全的中间件功能集合: 安装helmet模块:npm install helmet --save
  • cors:启用CORS跨源资源共享)的中间件: 安装cors模块:npm install cors --save

在安装了前面的模块之后,我们可以更新express.js来导入这些模块并配置 Express app,然后再导出它以用于服务器代码的其余部分。

更新后的mern-skeleton/server/express.js代码如下:

import express from 'express'
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import compress from 'compression'
import cors from 'cors'
import helmet from 'helmet'

const app = express()

app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(cookieParser())
app.use(compress())
app.use(helmet())
app.use(cors())

export default app

启动服务器

通过将 Express 应用配置为接受 HTTP 请求,我们可以继续使用它来实现服务器以侦听传入的请求。

mern-skeleton/server/server.js文件中,添加以下代码来实现服务器:

import config from './../config/config'
import app from './express'

app.listen(config.port, (err) => {
  if (err) {
    console.log(err)
  }
  console.info('Server started on port %s.', config.port)
})

我们首先导入配置变量以设置服务器将侦听的端口号,然后导入配置的 Express 应用以启动服务器。

要运行此代码并继续开发,现在可以从命令行运行npm run development。如果代码没有错误,服务器应该在 Nodemon 监视代码更改的情况下开始运行。

设置 Mongoose 并连接到 MongoDB

我们将使用Mongoose模块来实现此框架中的用户模型,以及我们 MERN 应用的所有未来数据模型。在这里,我们将首先配置 Mongoose,并利用它定义与 MongoDB 数据库的连接。

首先,要安装mongoose模块,运行以下命令:

npm install mongoose --save

然后更新server.js文件导入mongoose模块,配置为使用本机 ES6 承诺,最后处理与项目 MongoDB 数据库的连接。

mern-skeleton/server/server.js

import mongoose from 'mongoose'

mongoose.Promise = global.Promise
mongoose.connect(config.mongoUri)

mongoose.connection.on('error', () => {
  throw new Error(`unable to connect to database: ${mongoUri}`)
})

如果代码正在开发中运行,保存此更新应重新启动现在与 Mongoose 和 MongoDB 集成的服务器。

Mongoose is a MongoDB object modeling tool that provides a schema-based solution to model application data. It includes built-in type casting, validation, query building, and business logic hooks. Using Mongoose with this backend stack provides a higher layer over MongoDB with more functionality including mapping object models to database documents. Thus, making it simpler and more productive to develop with a Node and MongoDB backend. To learn more about Mongoose, visit mongoosejs.com.

在根 URL 处提供 HTML 模板

现在有了一台支持节点、Express 和 MongoDB 的服务器,我们可以对其进行扩展,以响应根 URL/处的传入请求,提供 HTML 模板。

template.js文件中,添加一个 JS 函数,该函数返回一个简单的 HTML 文档,该文档将在浏览器屏幕上呈现Hello World

mern-skeleton/template.js

export default () => {
    return `<!doctype html>
      <html lang="en">
          <head>
             <meta charset="utf-8">
             <title>MERN Skeleton</title>
          </head>
          <body>
            <div id="root">Hello World</div>
          </body>
      </html>`
}

要在根 URL 处提供此模板,请更新express.js文件以导入此模板,并在响应'/'路由的 GET 请求时发送该模板。

mern-skeleton/server/express.js

import Template from './../template'
...
app.get('/', (req, res) => {
  res.status(200).send(Template())
})
...

通过此更新,在浏览器中打开根 URL 应该会显示页面上呈现的 Hello World。

如果您正在本地计算机上运行代码,则根 URL 将为http://localhost:3000/

用户模型

我们将在server/models/user.model.js文件中实现用户模型,使用 Mongoose 用必要的用户数据字段定义模式,为字段添加内置验证,并合并业务逻辑,如密码加密、身份验证和自定义验证。

我们将首先导入mongoose模块并使用它生成UserSchema

mern-skeleton/server/models/user.model.js

import mongoose from 'mongoose'

const UserSchema = new mongoose.Schema({})

mongoose.Schema()函数将模式定义对象作为参数,以生成新的 Mongoose 模式对象,该对象可用于后端代码的其余部分。

用户模式定义

生成新 Mongoose 模式所需的用户模式定义对象将声明所有用户数据字段和相关属性。

名称

name字段是String类型的必填字段。

mern-skeleton/server/models/user.model.js

name: {
   type: String,
   trim: true,
   required: 'Name is required'
 },

电子邮件

email字段是String类型的必填字段,必须匹配有效的电子邮件格式,并且必须在用户集合中为unique

mern-skeleton/server/models/user.model.js

email: {
  type: String,
  trim: true,
  unique: 'Email already exists',
  match: [/.+\@.+\..+/, 'Please fill a valid email address'],
  required: 'Email is required'
},

创建和更新时间戳

字段createdupdated是通过编程生成的Date值,用于记录正在创建和更新的用户的时间戳。

mern-skeleton/server/models/user.model.js

created: {
  type: Date,
  default: Date.now
},
updated: Date,

哈希密码和 salt

hashed_passwordsalt字段表示我们将用于身份验证的加密用户密码。

mern-skeleton/server/models/user.model.js

hashed_password: {
    type: String,
    required: "Password is required"
},
salt: String

出于安全目的,实际密码字符串不会直接存储在数据库中,而是单独处理。

身份验证密码

密码字段对于在任何应用中提供安全的用户身份验证都非常重要,它需要作为用户模型的一部分进行加密、验证和安全身份验证。

作为虚拟场

用户提供的password字符串不是直接存储在用户文档中,而是作为virtual字段处理。

mern-skeleton/server/models/user.model.js

UserSchema
  .virtual('password')
  .set(function(password) {
    this._password = password
    this.salt = this.makeSalt()
    this.hashed_password = this.encryptPassword(password)
  })
  .get(function() {
    return this._password
  })

当用户创建或更新时收到password值时,它将被加密为新的散列值,并与salt字段中的salt值一起设置为hashed_password字段。

加密和认证

用于生成表示password值的hashed_passwordsalt值的加密逻辑和 salt 生成逻辑被定义为UserSchema方法。

mern-skeleton/server/models/user.model.js

UserSchema.methods = {
  authenticate: function(plainText) {
    return this.encryptPassword(plainText) === this.hashed_password
  },
  encryptPassword: function(password) {
    if (!password) return ''
    try {
      return crypto
        .createHmac('sha1', this.salt)
        .update(password)
        .digest('hex')
    } catch (err) {
      return ''
    }
  },
  makeSalt: function() {
    return Math.round((new Date().valueOf() * Math.random())) + ''
  }
}

此外,authenticate方法也被定义为UserSchema方法,当用户提供的密码必须经过身份验证才能登录时使用。

节点中的crypto模块用于将用户提供的密码字符串加密成一个hashed_password,随机生成salt值。当用户详细信息在创建或更新时保存到数据库时,hashed_password和 salt 存储在用户文档中。为了使用前面定义的authenticate方法匹配和验证用户登录期间提供的密码字符串,需要使用hashed_passwordsalt值。

密码字段验证

要对最终用户选择的实际密码字符串添加验证约束,我们需要添加自定义验证逻辑,并将其与模式中的hashed_password字段相关联。

mern-skeleton/server/models/user.model.js

UserSchema.path('hashed_password').validate(function(v) {
  if (this._password && this._password.length < 6) {
    this.invalidate('password', 'Password must be at least 6 characters.')
  }
  if (this.isNew && !this._password) {
    this.invalidate('password', 'Password is required')
  }
}, null)

为了确保确实提供了密码值,并且在创建新用户或更新现有密码时,密码值的长度至少为六个字符,在 Mongoose 尝试存储hashed_password值之前,会添加自定义验证以检查密码值。如果验证失败,逻辑将返回相关错误消息。

一旦UserSchema被定义,并且所有与密码相关的业务逻辑都如前面所讨论的那样被添加,我们最终可以导出user.model.js文件底部的模式,以便在后端代码的其他部分使用它。

mern-skeleton/server/models/user.model.js

export default mongoose.model('User', UserSchema) 

Mongoose 错误处理

如果在将用户数据保存到数据库时违反,添加到用户架构字段的验证约束将抛出错误消息。为了处理这些验证错误和数据库在查询时可能抛出的其他错误,我们将定义一个 helper 方法来返回相关的错误消息,该错误消息可以在请求-响应周期中传播。 

我们将在server/helpers/dbErrorHandler.js文件中添加getErrorMessage助手方法。此方法将解析并返回与特定验证错误或使用 Mongoose 查询 MongoDB 时发生的其他错误相关联的错误消息。

mern-skeleton/server/helpers/dbErrorHandler.js

const getErrorMessage = (err) => {
  let message = ''
  if (err.code) {
      switch (err.code) {
          case 11000:
          case 11001:
              message = getUniqueErrorMessage(err)
              break
          default:
              message = 'Something went wrong'
      }
  } else {
      for (let errName in err.errors) {
          if (err.errors[errName].message)
          message = err.errors[errName].message
      }
  }
  return message
}

export default {getErrorMessage}

由于 Mongoose 验证程序冲突而未引发的错误将包含错误代码,在某些情况下需要以不同方式处理。例如,由于违反唯一约束而导致的错误将返回与 Mongoose 验证错误不同的错误对象。unique 选项不是验证器,而是构建 MongoDB unique 索引的方便助手,因此我们将添加另一个getUniqueErrorMessage方法来解析与 unique constraint 相关的错误对象,并构造适当的错误消息。

mern-skeleton/server/helpers/dbErrorHandler.js

const getUniqueErrorMessage = (err) => {
  let output
  try {
      let fieldName =   
      err.message.substring(err.message.lastIndexOf('.$') + 2,                                             
      err.message.lastIndexOf('_1'))
      output = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) +   
      ' already exists'
  } catch (ex) {
      output = 'Unique field already exists'
  }
  return output
}

通过使用从此帮助文件导出的getErrorMessage函数,我们将在处理针对用户 CRUD 执行的 Mongoose 操作引发的错误时添加有意义的错误消息。

用户 CRUDAPI

Express 应用公开的用户 API 端点将允许前端对根据用户模型生成的文档执行 CRUD 操作。为了实现这些工作端点,我们将编写 Express routes 和相应的控制器回调函数,这些函数应该在 HTTP 请求进入这些已声明的路由时执行。在本节中,我们将了解这些端点如何在没有任何身份验证限制的情况下工作。

用户 API 路由将使用server/routes/user.routes.js中的 Express router 进行声明,然后挂载在server/express.js中配置的 Express app 上。

mern-skeleton/server/express.js

import userRoutes from './routes/user.routes'
...
app.use('/', userRoutes)
...

用户路由

user.routes.js文件中定义的用户路由将使用express.Router()以相关 HTTP 方法声明路由路径,并分配服务器收到这些请求时应调用的相应控制器函数。

我们将通过使用以下方法使用户路线保持简单:

  • /api/users针对:
    • 使用 GET 列出用户
    • 使用 POST 创建新用户
  • /api/users/:userId针对:
    • 使用 GET 获取用户
    • 使用 PUT 更新用户
    • 使用 DELETE 删除用户

生成的user.routes.js代码如下所示(不考虑需要为受保护路由添加的身份验证)。

mern-skeleton/server/routes/user.routes.js

import express from 'express'
import userCtrl from '../controllers/user.controller'

const router = express.Router()

router.route('/api/users')
  .get(userCtrl.list)
  .post(userCtrl.create)

router.route('/api/users/:userId')
  .get(userCtrl.read)
  .put(userCtrl.update)
  .delete(userCtrl.remove)

router.param('userId', userCtrl.userByID)

export default router

用户控制器

当服务器收到路由请求时,server/controllers/user.controller.js文件将包含前面的用户路由声明中作为回调使用的控制器方法。

user.controller.js文件将具有以下结构:

import User from '../models/user.model'
import _ from 'lodash'
import errorHandler from './error.controller'

const create = (req, res, next) => {  }
const list = (req, res) => {  }
const userByID = (req, res, next, id) => {  }
const read = (req, res) => {  }
const update = (req, res, next) => {  }
const remove = (req, res, next) => {  }

export default { create, userByID, read, list, remove, update }

当出现猫鼬错误时,控制器将使用errorHandler助手以有意义的消息响应路由请求。当使用更改的值更新现有用户时,它还将使用名为lodash的模块。

lodash is a JavaScript library which provides utility functions for common programming tasks including manipulation of arrays and objects. To install lodash, run npm install lodash --save from command line. 

前面定义的每个控制器函数都与路由请求相关,并将根据每个 API 用例进行详细说明。

创建新用户

创建新用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/user.routes.js

router.route('/api/users').post(userCtrl.create)

当 Express app 在'/api/users'收到 POST 请求时,调用控制器中定义的create函数。

mern-skeleton/server/controllers/user.controller.js

const create = (req, res, next) => {
  const user = new User(req.body)
  user.save((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.status(200).json({
      message: "Successfully signed up!"
    })
  })
}

此函数使用req.body内来自前端的 POST 请求中接收到的 user JSON 对象创建一个新用户。在 Mongoose 对数据进行验证检查后,user.save尝试将新用户保存到数据库中,因此错误或成功响应返回给请求的客户端。

列出所有用户

获取所有用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/user.routes.js

router.route('/api/users').get(userCtrl.list)

当 Express app 在'/api/users'收到 GET 请求时,执行list控制器功能。

mern-skeleton/server/controllers/user.controller.js

const list = (req, res) => {
  User.find((err, users) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(users)
  }).select('name email updated created')
}

list控制器函数从数据库中查找所有用户,仅填充结果用户列表中的名称、电子邮件、创建和更新字段,然后将此用户列表作为数组中的 JSON 对象返回给请求客户端。

按 ID 加载用户以进行读取、更新或删除

用于读取、更新和删除的所有三个 API 端点都要求根据所访问用户的用户 ID 从数据库中检索用户。在响应读取、更新或删除的特定请求之前,我们将对 Express router 进行编程,使其首先执行此操作。

加载

当 Express 应用接收到与包含:userId参数的路径匹配的路由请求时,该应用将首先执行userByID控制器功能,然后再传播到特定于传入请求的next功能。

mern-skeleton/server/routes/user.routes.js

router.param('userId', userCtrl.userByID)

userByID控制器功能使用:userId参数中的值通过_id查询数据库,并加载匹配的用户详细信息。

mern-skeleton/server/controllers/user.controller.js

const userByID = (req, res, next, id) => {
  User.findById(id).exec((err, user) => {
    if (err || !user)
      return res.status('400').json({
        error: "User not found"
      })
    req.profile = user
    next()
  })
}

如果在数据库中找到匹配的用户,则用户对象将附加到profile键中的请求对象。然后,使用next()中间件将控制传播到下一个相关控制器功能。例如,如果原始请求是读取用户配置文件,userById中的next()调用将转到read控制器功能。

阅读

用于读取单个用户数据的 API 端点在以下路由中声明。

mern-skeleton/server/routes/user.routes.js

router.route('/api/users/:userId').get(userCtrl.read)

当 Express app 在'/api/users/:userId'收到 GET 请求时,执行userByID控制器功能,通过参数中的userId值加载用户,然后执行read控制器功能。

mern-skeleton/server/controllers/user.controller.js

const read = (req, res) => {
  req.profile.hashed_password = undefined
  req.profile.salt = undefined
  return res.json(req.profile)
}

read函数从req.profile中检索用户详细信息,并删除敏感信息,如hashed_passwordsalt值,然后将响应中的用户对象发送给请求客户端。

更新

要更新单个用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/user.routes.js

router.route('/api/users/:userId').put(userCtrl.update)

当 Express app 在'/api/users/:userId'收到 PUT 请求时,与read类似,它首先向用户加载:userId参数值,然后执行update控制器功能。

mern-skeleton/server/controllers/user.controller.js

const update = (req, res, next) => {
  let user = req.profile
  user = _.extend(user, req.body)
  user.updated = Date.now()
  user.save((err) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    user.hashed_password = undefined
    user.salt = undefined
    res.json(user)
  })
}

update函数从req.profile中检索用户详细信息,然后使用lodash模块扩展和合并请求正文中的更改,以更新用户数据。在将此更新的用户保存到数据库之前,updated字段将填充当前日期,以反映上次更新的时间戳。成功保存此更新后,在将响应中的用户对象发送给请求客户端之前,通过删除敏感数据(如hashed_passwordsalt)来清理更新后的用户对象。

删除

要删除用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/user.routes.js

router.route('/api/users/:userId').delete(userCtrl.remove)

当 Express app 在'/api/users/:userId'收到删除请求时,与读取和更新类似,它首先按 ID 加载用户,然后执行remove控制器功能。

mern-skeleton/server/controllers/user.controller.js

const remove = (req, res, next) => {
  let user = req.profile
  user.remove((err, deletedUser) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    deletedUser.hashed_password = undefined
    deletedUser.salt = undefined
    res.json(deletedUser)
  })
}

remove函数从req.profile中检索用户,并使用remove()查询从数据库中删除该用户。成功删除后,请求客户端将在响应中返回已删除的用户对象。

到目前为止,随着 API 端点的实现,任何客户端都可以对用户模型执行 CRUD 操作,但我们希望通过身份验证和授权限制对其中一些操作的访问。

用户身份验证和受保护的路由

为了限制对用户操作的访问,例如用户概要视图、用户更新和用户删除,我们将使用 JWT 实现登录身份验证,然后保护和授权读取、更新和删除路由。

用于登录和注销的与身份验证相关的 API 端点将在server/routes/auth.routes.js中声明,然后安装在server/express.js中的 Express app 上。

mern-skeleton/server/express.js

import authRoutes from './routes/auth.routes'
  ...
  app.use('/', authRoutes)
  ...

认证路由

auth.routes.js文件中使用express.Router()定义了这两个身份验证 API,以使用相关 HTTP 方法声明路由路径,并分配了相应的身份验证控制器函数,当收到这些路由的请求时应调用这些函数。

验证路径如下所示:

  • '/auth/signin':用电子邮件和密码对用户进行身份验证的 POST 请求
  • '/auth/signout':获取请求以清除在登录后在响应对象上设置的包含 JWT 的 cookie

生成的mern-skeleton/server/routes/auth.routes.js文件如下:

import express from 'express'
import authCtrl from '../controllers/auth.controller'

const router = express.Router()

router.route('/auth/signin')
  .post(authCtrl.signin)
router.route('/auth/signout')
  .get(authCtrl.signout)

export default router

身份验证控制器

server/controllers/auth.controller.js中的 auth controller 函数不仅将处理对登录和注销路由的请求,还将提供 JWT 和express-jwt功能,以便为受保护的用户 API 端点启用身份验证和授权。

auth.controller.js文件将具有以下结构:

import User from '../models/user.model'
import jwt from 'jsonwebtoken'
import expressJwt from 'express-jwt'
import config from './../../config/config'

const signin = (req, res) => {  }
const signout = (req, res) => {  }
const requireSignin =  
const hasAuthorization = (req, res) => {  }

export default { signin, signout, requireSignin, hasAuthorization }

下面将详细介绍四个控制器函数,以展示后端如何使用 JSON Web 令牌实现用户身份验证。

登录

要登录用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/auth.routes.js

router.route('/auth/signin').post(authCtrl.signin)

当 Express app 在'/auth/signin'收到 POST 请求时,执行signin控制器功能。

mern-skeleton/server/controllers/auth.controller.js

const signin = (req, res) => {
  User.findOne({
    "email": req.body.email
  }, (err, user) => {
    if (err || !user)
      return res.status('401').json({
        error: "User not found"
      })

    if (!user.authenticate(req.body.password)) {
      return res.status('401').send({
        error: "Email and password don't match."
      })
    }

    const token = jwt.sign({
      _id: user._id
    }, config.jwtSecret)

    res.cookie("t", token, {
      expire: new Date() + 9999
    })

    return res.json({
      token,
      user: {_id: user._id, name: user.name, email: user.email}
    })
  })
}

POST请求对象接收req.body中的电子邮件和密码。此电子邮件用于从数据库检索匹配的用户。然后,使用UserSchema中定义的密码认证方法来验证req.body中从客户端接收到的密码。

如果密码验证成功,则使用 JWT 模块生成使用密钥和用户的_id值签名的 JWT。

Install the jsonwebtoken module to make it available to this controller in the import by running npm install jsonwebtoken --save from the command line.

然后,签名的 JWT 连同用户详细信息一起返回给经过身份验证的客户端。或者,我们还可以将令牌设置为响应对象中的 cookie,以便在 cookie 是所选 JWT 存储形式的情况下,客户端可以使用该令牌。在客户端,当从服务器请求受保护的路由时,此令牌必须作为Authorization头附加。

注销

用于注销用户的 API 端点在以下路由中声明。

mern-skeleton/server/routes/auth.routes.js

router.route('/auth/signout').get(authCtrl.signout)

当 Express app 在'/auth/signout'收到 GET 请求时,执行signout控制器功能。

mern-skeleton/server/controllers/auth.controller.js

const signout = (req, res) => {
  res.clearCookie("t")
  return res.status('200').json({
    message: "signed out"
  })
}

signout函数清除包含签名 JWT 的响应 cookie。这是一个可选的端点,如果前端中根本没有使用 cookies,那么就不需要进行身份验证。使用 JWT,用户状态存储是客户端的责任,除了 cookie 之外,客户端存储还有多种选择。注销时,客户端需要删除客户端上的令牌,以确定用户不再经过身份验证。

使用快速 jwt 保护路由

为了保护对读取、更新和删除路由的访问,服务器需要检查请求的客户机是否确实是经过身份验证和授权的用户。

为了在访问受保护的路由时检查请求用户是否已登录并具有有效的 JWT,我们将使用express-jwt模块。

The express-jwt module is middleware that validates JSON Web Tokens. Run npm install express-jwt --save from the command line to install express-jwt.

需要登录

auth.controller.js中的requireSignin方法使用express-jwt来验证传入请求在Authorization头中是否有有效的 JWT。如果令牌有效,它将在'auth'密钥中向请求对象追加已验证用户的 ID,否则抛出身份验证错误。

mern-skeleton/server/controllers/auth.controller.js

const requireSignin = expressJwt({
  secret: config.jwtSecret,
  userProperty: 'auth'
})

我们可以将requireSignin添加到任何应该受到保护的路由,以防止未经验证的访问。

授权已登录用户

对于某些受保护的路由(如更新和删除),在检查身份验证的基础上,我们还希望确保请求用户只更新或删除他们自己的用户信息。为了实现这一点,auth.controller.js中定义的hasAuthorization功能在允许相应 CRUD 控制器功能继续之前,检查经过身份验证的用户是否与被更新或删除的用户相同。

mern-skeleton/server/controllers/auth.controller.js

const hasAuthorization = (req, res, next) => {
  const authorized = req.profile && req.auth && req.profile._id == 
  req.auth._id
  if (!(authorized)) {
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

验证后,req.auth对象由requireSignin中的express-jwt填充,req.profile对象由user.controller.js中的userByID函数填充。我们将在需要身份验证和授权的路由中添加hasAuthorization功能。

保护用户路由

我们将在需要通过身份验证和授权进行保护的用户路由声明中添加requireSigninhasAuthorization

更新user.routes.js中的读取、更新和删除路由,如下所示。

mern-skeleton/server/routes/user.routes.js

import authCtrl from '../controllers/auth.controller'
...
router.route('/api/users/:userId')
    .get(authCtrl.requireSignin, userCtrl.read)
    .put(authCtrl.requireSignin, authCtrl.hasAuthorization, 
     userCtrl.update)
    .delete(authCtrl.requireSignin, authCtrl.hasAuthorization, 
     userCtrl.remove)
...

读取用户信息的路由只需要身份验证,而更新和删除路由应在执行这些 CRUD 操作之前检查身份验证和授权。

express jwt 的身份验证错误处理

为了处理express-jwt在尝试验证传入请求中的 JWT 令牌时抛出的与身份验证相关的错误,我们需要在mern-skeleton/server/express.js中的 Express app 配置中添加以下错误捕获代码,该代码位于代码末尾附近,在装载路由之后和导出应用之前:

app.use((err, req, res, next) => {
  if (err.name === 'UnauthorizedError') {
    res.status(401).json({"error" : err.name + ": " + err.message})
  }
})

当由于某种原因无法验证令牌时,express-jwt抛出名为UnauthorizedError的错误。我们在这里捕获此错误,以将401状态返回给请求客户端。

通过实现用于保护路由的用户身份验证,我们已经介绍了骨架 MERN 应用工作后端的所有所需功能。在下一节中,我们将了解如何在不实现前端的情况下检查此独立后端是否按预期运行。

检查独立后端

在选择检查后端 API 的工具时,有许多选项,从命令行工具 curl(到 https://github.com/curl/curl 至高级 REST 客户端(https://chrome.google.com/webstore/detail/advanced-rest-client/hgmloofddffdnphfgcellkdfbfbjeloo -具有交互式用户界面的 Chrome 扩展应用。

要检查本章中实现的 API,首先让服务器从命令行运行,并使用以下任一工具请求路由。如果您正在本地计算机上运行代码,则根 URL 为http://localhost:3000/

使用 ARC,我们将展示检查实现的 API 端点的五个用例的预期行为。

创建新用户

首先,我们将使用/api/usersPOST 请求创建一个新用户,并在请求正文中传递名称、电子邮件和密码值。当用户在数据库中成功创建且没有任何验证错误时,我们将看到一条 200 OK 成功消息,如以下屏幕截图所示:

获取用户列表

我们可以通过获取所有用户的列表,并向/api/users发出GET请求来查看新用户是否在数据库中。响应应包含存储在数据库中的所有用户对象的数组:

正在尝试获取单个用户

接下来,我们将尝试在不首先登录的情况下访问受保护的 API。GET读取任何一个用户的请求将返回 401 未经授权,例如在下面的示例中,GET/api/users/5a1c7ead1a692aa19c3e7b33的请求将返回 401:

登录

为了能够访问受保护的路由,我们将使用在第一个示例中创建的用户的凭据登录。要登录,将在/auth/signin发送 POST 请求,请求正文中包含电子邮件和密码。成功登录后,服务器返回已签名的 JWT 和用户详细信息。我们需要此令牌来访问受保护的路由以获取单个用户:

正在成功获取单个用户

使用登录后收到的令牌,我们现在可以访问以前失败的受保护路由。向/api/users/5a1c7ead1a692aa19c3e7b33发出 GET 请求时,在承载方案的Authorization头中设置了令牌,此次成功返回用户对象:

总结

在本章中,我们使用 Node、Express 和 MongoDB 开发了一个功能齐全的独立服务器端应用,涵盖了 MERN 骨架应用的第一部分。在后端,我们实现了以下功能:

  • 存储用户数据的用户模型,用 Mongoose 实现
  • 用于执行 CRUD 操作的用户 API 端点,使用 Express 实现
  • 受保护路由的用户身份验证,使用 JWT 和express-jwt实现

我们还通过配置 Webpack 来编译 ES6 代码,并配置 Nodemon 在代码更改时重新启动服务器来设置开发流程。最后,我们使用适用于 Chrome 的高级 RESTAPI 客户端扩展应用检查了 API 的实现

我们现在准备在下一章中扩展此后端应用代码,以添加 React 前端并完成 MERN 骨架应用。