# koa2 框架操作 MongoDB 数据库,项目实践基础准备

TIP

从本节开始正式学习 koa2 框架操作 MongoDB 数据库,为项目实践开发做好前期的基础技术准备工作。

  • koa2 项目结构搭建,项目目录结构优化
  • Koa2 集成 Mongoose 操作 MongoDB 数据库,模块化,拆分中间件
  • 完成用户注册、验证、密码加密,项目结构分层
  • 完成用户登录,用户 Token 认证,项目综合实践

# 一、koa2 项目结构搭建

TIP

  • 以评论系统为例,完善部分模块的增删查改操作
  • 基于 koa2 编写
  • 定义 Schema 和 Model
  • 完善数据库操作

# 1、初始化项目

TIP

新建项目文件夹 comment-system ,并使用 VSCode 打开项目

# 1.1、npm 初始化

TIP

生成 package.json 文件,用来记录项目的依赖

npm init -y

# 1.2、初始化 Git

TIP

生成 .git 隐藏文件夹,即 : Git 本地仓库

git init

# 1.3、新建 .gitignore 文件

TIP

在项目根目录中,新建 .gitignore 文件,用于过滤不需要同步到 Git 的文件

# 1.4、创建 README.md 文件

TIP

在项目根目录中,创建 README.md 文件,用于编写项目说明文档

# 1.5、新建远程仓库

TIP

使用 Gitee 建立远程仓库,用于存储项目源代码

image-20240319231932426

创建完成后,复制远程仓库地址

image-20240319232605913

# 1.6、添加远程仓库提交地址

TIP

在 VSCode 命令行终端中运行命令,添加远程仓库的提交地址

git remote add origin https://gitee.com/aicodingedu/comment-system.git

# 2、搭建 koa 项目

TIP

安装 koa2 框架,编写 Hello World ,测试并启动项目

# 2.1、安装 koa 框架

# -S 或 --save 生产依赖
npm i koa -S

# 2.2、编写 Hello World

TIP

创建 src/main.js 入口文件

// 导入koa包
const Koa = require("koa");
// 实例化app
const app = new Koa();
// 编写中间件,ctx(context)上下文
app.use((ctx) => {
  ctx.body = "Hello World !";
});

// 启动服务,监听 3000 端口
app.listen(3000, () => {
  console.log("程序启动在 3000 端口啦 !http://localhost:3000");
});

# 2.3、测试启动项目

TIP

node 启动,浏览器访问 http://localhost:3000/

image-20240319234529437

注:

.gitignore 文件中,添加 node_modules 文件夹名称,将其过滤不提交至 Git 及 远程仓库

# 3、项目基础优化

TIP

添加自动重启服务,配置环境变量读取配置文件信息,优化端口号配置

# 3.1、添加自动重启服务

TIP

安装 nodemon,使用 nodemon 启动 node 服务。会帮助我们实时监听 JS 文件的改变而自动重启服务

# -D 开发依赖
npm i nodemon -D

使用 nodemon 启动

# 直接使用 nodemon 有时会提示找不到
nodemon .\src\main.js

# 以上找不到,可使用 npx nodemon 的方式,即:会在当前的 node_modules 中去查找 nodemon,如果有会直接使用,如没有就会使用全局的,如还没有就会去安装 nodemon
npx nodemon .\src\main.js

image-20240320192417143

或 在 package.json 中,定义启动运行脚本

{
  "scripts": {
    // 定义 nodemon 的启动运行脚本
    "dev": "nodemon ./src/main.js"
  }
}

使用自定义脚本启动

npm run dev

image-20240320192453761

在浏览器地址栏中输入 http://localhost:3000/

image-20240320134120532

在 postman 中测试

image-20240320143051305

# 3.2、配置环境变量

安装 dotenv 包来读取 .env 文件中的环境变量

# -S 或 --save 生产依赖
npm i dotenv --save

在项目根目录中,创建 .env 文件

APP_PORT=8000

注:

在 NodeJS 代码中,使用 dotenv 包来读取 .env 文件中的环境变量值,然后可以使用 process.env 对象来访问它们

创建 src/config/config.default.js 配置文件,用于读取环境变量的信息

// 导入dotenv包 ,将环境变量从文件加载到进程中
const dotenv = require("dotenv");
// 读取配置信息
dotenv.config();

// console.log('从 .env 配置文件中读取的值:', process.env.APP_PORT)
// process 代表进程 ,env 代表环境变量

module.exports = process.env;

改写 src/main.js 文件

// 导入koa包
const Koa = require("koa");
// 导入环境变量中设置的端口号
const { APP_PORT } = require("./config/config.default");

// 实例化app
const app = new Koa();
// 编写中间件,ctx(context)上下文
app.use((ctx) => {
  ctx.body = "Hello World !";
});

// 启动服务,监听 APP_PORT 端口
app.listen(APP_PORT, () => {
  console.log(`程序启动在 ${APP_PORT} 端口啦 !http://localhost:${APP_PORT}`);
});

启动运行

npm run dev

image-20240320192652389

# 4、添加路由

TIP

路由:根据不同的 URL,调用对应的处理函数

实现路由的步骤,按照 npm 官方 或 GitHub 官网文档为主

  • ①、安装 koa-router
  • ②、导入 koa-router
  • ③、 实例化对象
  • ④、编写路由
  • ⑤、注册中间件

# 4.1、安装 koa-router

# -S 或 --save 生产依赖
npm i koa-router -S

# 4.2、编写路由

创建 src/router 目录,编写 user.route.js

// 导入 koa-router
const Router = require("koa-router");

// 实例化 router 路由对象
// 给路由设置一个统一的前缀
const router = new Router({ prefix: "/users" });

// 编写路由规则
router.get("/", (ctx, next) => {
  ctx.body = "hello users";
});

// 导出 Router 对象,这样其他模块就可以引入并使用这个路由对象了
module.exports = router;

# 4.3、改写 main.js 入口

src/main.js 入口文件中导入用户路由模块

// 导入koa包
const Koa = require("koa");
// 导入环境变量中设置的端口号
const { APP_PORT } = require("./config/config.default");

// 导入用户路由模块
const userRouter = require("./router/user.route");

// 实例化 app(创建 Koa 应用实例)
const app = new Koa();

// 加载路由(使用 app.use() 方法将 userRouter 的路由添加到 Koa 应用中)
app.use(userRouter.routes());

// 启动服务,监听 APP_PORT 端口
app.listen(APP_PORT, () => {
  console.log(`程序启动在 ${APP_PORT} 端口啦 !http://localhost:${APP_PORT}`);
});

启动运行

npm run dev

image-20240321163047710

在浏览器地址栏中输入 http://localhost:8000/users/

image-20240321163303933

# 5、目录结构优化

TIP

main.js 作为项目的启动文件,跟 koa 等相关的代码都是跟业务相关的,与项目启动无关,可以单独抽取出来。

将服务 和 业务分开,也是区分是否有经验的很重要的点

# 5.1、将 http 服务 和 app 业务拆分

创建 src/app/index.js 用于 app 业务模块(与业务相关的代码单独拆分出来)

// 导入koa包
const Koa = require("koa");
// 导入用户路由模块
const userRouter = require("./router/user.route");

// 实例化 app(创建 Koa 应用实例)
const app = new Koa();

// 加载路由(使用 app.use() 方法将 userRouter 的路由添加到 Koa 应用中)
app.use(userRouter.routes());

// 导出 app 对象
module.exports = app;

src/main.js 入口文件中导入 app 模块(去除与业务相关的代码,以模块的形式导入)

// 导入环境变量中设置的端口号
const { APP_PORT } = require("./config/config.default");

// 导入 app 模块
const app = require("./app");

// 启动服务,监听 ${APP_PORT} 端口
app.listen(APP_PORT, () => {
  console.log(`程序启动在 ${APP_PORT} 端口啦 !http://localhost:${APP_PORT}`);
});

启动运行

npm run dev

# 5.2、将路由和控制器拆分

TIP

  • 路由:解析 URL,根据不同的 URL 分发给控制器对于的方法
  • 控制器:处理不同的业务

创建控制器 src/controller/user.controller.js

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    ctx.body = "用户注册成功 !";
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

改写 src/router/user.route.js

// 导入 koa-router
const Router = require("koa-router");
// 从 user.controller 中导入模块
// 并从该模块中提取 register 和 login 这两个方法或属性,然后将它们分别赋值给当前文件中的 register 和 login 常量。
const { register, login } = require("../controller/user.controller");

// 实例化 router 路由对象
// 给路由设置一个统一的前缀
const router = new Router({ prefix: "/users" });

// 用户注册路由
router.post("/register", register);

// 用户登录路由
router.post("/login", login);

// 导出 Router 对象,这样其他模块就可以引入并使用这个路由对象了
module.exports = router;

启动运行

npm run dev

在 postman 中测试以上两个路由(POST 请求)

image-20240321192653872

image-20240321192858036

# 6、解析 body

TIP

koa 原生是不支持 body 的参数解析,会借助社区提供的中间件来实现。官方推荐如下

image-20240217193233208

# 6.1、安装 koa-body

npm i koa-body -S

# 6.2、改写 app,注册中间件

改写 /src/app/index.js

// 导入 koa 包
const Koa = require("koa");
// 导入 koa-body
const { koaBody } = require("koa-body");

// 导入用户路由模块
const userRouter = require("../router/user.route");

// 实例化 app(创建 Koa 应用实例)
const app = new Koa();

// 注册 koa-body 中间件(挂载到 ctx.request.body 上)
// 注:注册 KoaBody 必须在所有中间件之前
app.use(koaBody());
// 注册路由中间件
// 加载路由(使用 app.use() 方法将 userRouter 的路由添加到 Koa 应用中)
app.use(userRouter.routes());

// 导出 app 对象
module.exports = app;

# 6.3、拆分 service 层

TIP

service 层:主要是做数据库处理

创建 src/service/user.service.js

/**
 * 用户信息 Service
 * @author arry老师
 * @version V1.0
 */
class UserService {
  async createUser(username, password) {
    // 写入数据库
    return "写入数据库成功";
  }
}

// 导出 UserService 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserService();

# 6.4、解析请求数据

改写 src/controller/user.controller.js

const { createUser } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // console.log(ctx.request.body)
    const { username, password } = ctx.request.body;
    // 2、操作数据库
    const res = await createUser(username, password);
    // console.log(res)
    // 3、返回结果
    ctx.body = ctx.request.body;
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

启动运行

npm run dev

image-20240321234742460

# 二、Koa2 集成 Mongoose 操作 MongoDB 数据库

TIP

Mongoose 是一个对象文档模型(ODM)库,用于 Node.js 中操作 MongoDB 数据库。

  • 连接 MongoDB 数据库
  • 模型定义与数据建模:使用模式(Schema)来定义数据库中文档的结构,类似于传统数据库中的表。通过定义模型,你可以描述数据结构和数据之间的关系,确保数据库中存储的文档满足特定的格式规范。
  • 数据验证:内置的验证器,自动对文档中的数据进行验证,确保数据的一致性和有效性。
  • 数据处理:Mongoose 提供了一组强大的 API,可以用来对数据库中的数据进行增删改查操作
  • 中间件:允许你使用中间件来扩展模型的功能

官方文档:https://mongoosejs.com/docs/guide.html (opens new window)

# 1、安装 Mongoose

npm i mongoose --save

# 2、改写 env 环境变量配置文件

MONGODB_HOST = 127.0.0.1
MONGODB_PORT = 27017
MONGODB_USER = ''
MONGODB_PWD = ''
MONGODB_DB = comment-system

注:

  • MONGODB_HOST MongoDB 数据库服务器主机地址(IP 或 域名)
  • MONGODB_PORT MongoDB 数据库服务器监听的端口号
  • MONGODB_USER 连接 MongoDB 数据库的用户名
  • MONGODB_PWD 连接 MongoDB 数据库的密码
  • MONGODB_DB 要连接的 MongoDB 数据库的名称

出于安全考虑,不应该在代码库或版本控制系统中硬编码这些敏感信息(如用户名和密码)。使用环境变量是一个更好的做法,因为它允许你在不同的环境中(如开发、测试和生产)使用不同的值,而无需修改代码。

同时,确保这些环境变量在生产环境中是安全地存储和管理的。

# 3、连接 MongoDB 数据库

TIP

使用 Mongoose 连接到 MongoDB 数据库

新建 src/db/db.js 连接数据库

// 导入 env 环境变量
const {
  MONGODB_HOST,
  MONGODB_PORT,
  MONGODB_USER,
  MONGODB_PWD,
  MONGODB_DB,
} = require("../config/config.default");
// 使用 Mongoose 连接数据库 MongoDB 的服务端
const mongoose = require("mongoose");

// 适应环境变量参数
// 开始连接数据库
mongoose
  .connect(`mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${MONGODB_DB}`)
  .then(() => {
    console.log("数据库连接成功 !");
  })
  .catch((err) => {
    console.error("连接数据库失败 !", err);
  });

// 导出 mongoose(commonJS 语法)
module.exports = mongoose;

在命令行终端,测试启动连接数据库

node .\src\db\db.js

image-20240322145849219

# 4、确保 .env 文件不被提交到版本控制

TIP

通常,不希望将 .env 文件提交到版本控制(如 Git),因为这可能会泄露敏感信息。可以在 .gitignore 文件中添加 .env 来确保它不会被提交。

# .gitignore
.env

# 5、创建 User 模型(Schema)

TIP

需要定义一个 Mongoose 模型(Schema),它基于 MongoDB 的集合和文档结构

创建 src/model/user.model.js

// 数据模型(规范数据格式)
const { Schema } = require("mongoose");
// 导入数据库连接对象
const mongoose = require("../db/db");

// 定义 Schema
const UserSchema = new Schema(
  {
    username: {
      type: String,
      required: true, // 必填
      unique: true, // 唯一,不重复
    },
    password: String,
    age: Number,
    city: String,
    // 性别
    gender: {
      type: Number,
      // 默认值
      default: 0, // 0 - 保密,1 - 男,2 - 女
    },
    phone: {
      type: String,
      required: false,
      validate: {
        validator: function (v) {
          // 这里使用正则表达式来验证手机号格式
          // 假设你的手机号格式是11位数字
          return /^\d{11}$/.test(v);
        },
        message: "手机号格式不正确,请输入11位数字",
      },
    },
    email: {
      type: String,
      required: false,
      validate: {
        validator: function (v) {
          // 这里使用正则表达式来验证邮箱格式
          return /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(v);
        },
        message: "邮箱格式不正确",
      },
    },
    // 角色
    role: {
      type: Number,
      enum: [0, 1], // 枚举类型,限制 role字段只能为 0 - 普通用户(默认) 或 1 - 管理员
      default: 0, // 默认值为 0,即普通用户
    },
  },
  {
    timestamps: true, // 时间戳,自动添加文档的创建时间和更新时间
  }
);

// 创建模型
const User = mongoose.model("User", UserSchema);

// console.log(User)

// 导出 User 对象
module.exports = User;

node 命令启动运行,创建数据库 和 集合

node .\src\model\user.model.js

# 6、创建用户入库

TIP

所有数据库的操作都在 Service 层完成,Service 调用 Model 完成数据库操作

# 6.1、改写用户注册 Service 层

改写 src/service/user.service.js

// 导入 User 模型
const User = require("../model/user.model");

/**
 * 用户信息 Service
 * @author arry老师
 * @version V1.0
 */
class UserService {
  // 用户注册
  async createUser(username, password) {
    // 创建用户
    // User.create({
    //     // 文档字段(在ES6中,属性名和参数名相同时,可以简写)
    //     username: username,
    //     password: password
    // })

    const res = await User.create({ username, password });
    console.log(res);
  }
}

module.exports = new UserService();

启动运行项目

npm run dev

通过 Postman 请求注册用户信息

image-20240321234742460

用户注册成功后查看 VSCode 终端输出

image-20240322160831699

# 6.2、改写用户注册 Controller 层

改写 src/controller/user.controller.js

const { createUser } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // console.log(ctx.request.body)
    const { username, password } = ctx.request.body;
    // 2、操作数据库
    const res = await createUser(username, password);
    // console.log(res)
    // 3、返回结果(从 res 中读取)
    ctx.body = {
      status: 0,
      message: "用户注册成功",
      data: {
        id: res._id,
        username: res.username,
      },
    };
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

通过 Postman 测试,返回注册成功信息

image-20240322170330604

# 7、错误处理

TIP

在控制器中,对不同的错误进行处理,返回不同的错误提示信息,提高代码质量

  • 加强程序的鲁棒性(健壮性)
  • 合法性(非空判断等)
  • 合理性(去重判断等)

# 7.1、制造错误

制造错误 1: 注册不填写 username,报 500 服务器内部错误

image-20240322191533497

制造错误 2: 注册数据库中已有用户名,同样报 500 服务器内部错误

image-20240322191953854

注:

我们发现以上两种不同错误,返回的状态码 500 和 报文都是相同的,至于到底是什么问题导致的,只能靠猜了,显然是不合理的。

因此,我们需要单独对错误进行处理,增强程序的鲁棒性

# 7.2、添加错误处理

改写 src/controller/user.controller.js,添加用户注册时 合法性 与 合理性的验证

const { createUser } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // console.log(ctx.request.body)
    const { username, password } = ctx.request.body;

    // 合法性
    if (!username || !password) {
      console.log("用户名 或 密码为空", ctx.request.body);
      ctx.status = 400;
      ctx.body = {
        code: "10001",
        message: "用户名 或 密码为空",
        data: "",
      };
      return;
    }

    // 合理性(用户名是否存在)
    // if (该用户名已存在) {
    //     return
    // }

    // 根据特定参数(用户名)查询用户信息是否存在

    // 2、操作数据库
    const res = await createUser(username, password);
    // console.log(res)
    // 3、返回结果(从 res 中读取)
    ctx.body = {
      status: 0,
      message: "用户注册成功",
      data: {
        id: res._id,
        username: res.username,
      },
    };
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

image-20240322234244376

改写 service ,在 src/service/user.service.js 中封装函数(根据特定参数查询用户信息是否存在)

// 导入 User 模型
const User = require("../model/user.model");

/**
 * 用户信息 Service
 * @author arry老师
 * @version V1.0
 */
class UserService {
  // 用户注册
  async createUser(username, password) {
    // 创建用户
    // User.create({
    //     // 文档字段(在ES6中,属性名和参数名相同时,可以简写)
    //     username: username,
    //     password: password
    // })

    const res = await User.create({ username, password });
    // console.log(res)
    return res;
  }

  /**
   * 根据特定参数查询用户信息
   * @param { Object } { id, username, role } 对象中有 3 个参数可选(id 用户id,username 用户名,role 用户角色)
   * @returns 根据参数查询到的用户信息
   */
  async getUserInfo({ id, username, role }) {
    const whereOpt = {};
    // 短路运算:如果id不为空(id存在)时,则将 id 拷贝(assign)到 whereOpt对象中
    id && Object.assign(whereOpt, { id });
    username && Object.assign(whereOpt, { username });
    role && Object.assign(whereOpt, { role });

    // 返回第一个匹配的文档数据(按条件查询)
    return await User.findOne(whereOpt);
  }
}

module.exports = new UserService();

src/controller/user.controller.js 中,添加 合理性(用户名是否存在)

const { createUser, getUserInfo } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // console.log(ctx.request.body)
    const { username, password } = ctx.request.body;

    // 合法性
    if (!username || !password) {
      console.log("用户名 或 密码为空", ctx.request.body);
      ctx.status = 400;
      ctx.body = {
        code: "10001",
        message: "用户名 或 密码为空",
        data: "",
      };
      return;
    }

    // 合理性(用户名是否存在)
    // if (该用户名已存在) {
    //     return
    // }

    // 根据特定参数(用户名)查询用户信息是否存在
    if (getUserInfo({ username })) {
      // 409 由于和被请求的资源的当前状态之间存在冲突,请求无法完成
      ctx.status = 409;
      ctx.body = {
        code: 10002,
        message: "该用户已存在",
        data: "",
      };
      return;
    }

    // 2、操作数据库
    const res = await createUser(username, password);
    // console.log(res)
    // 3、返回结果(从 res 中读取)
    ctx.body = {
      status: 0,
      message: "用户注册成功",
      data: {
        id: res._id,
        username: res.username,
      },
    };
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

image-20240322234423589

# 8、拆分中间件,统一处理错误

TIP

为了使代码的逻辑更加清晰, 我们可以拆分一个中间件层, 封装多个中间件函数

# 8.1、拆分用户模块,验证中间件

创建 src/middleware/user.middleware.js,完成注册用户的合法性与合理性验证

const { getUserInfo } = require("../service/user.service");

// 用户名密码的合法性验证
const userValidator = async (ctx, next) => {
  const { username, password } = ctx.request.body;

  // 合法性
  if (!username || !password) {
    console.error("用户名或密码为空", ctx.request.body);
    ctx.status = 400;
    ctx.body = {
      code: "10001",
      message: "用户名或密码为空",
      data: "",
    };
    return;
  }
  await next();
};

// 用户唯一性验证
const userUniqueVerify = async (ctx, next) => {
  const { username } = ctx.request.body;
  if (await getUserInfo({ username })) {
    // 409 由于和被请求的资源的当前状态之间存在冲突,请求无法完成
    ctx.status = 409;
    ctx.body = {
      code: 10002,
      message: "该用户已存在",
      data: "",
    };
    return;
  }
  await next();
};

module.exports = {
  userValidator,
  userUniqueVerify,
};

改写,src/controller/user.controller.js 去掉 controller 中的验证信息,抽取到 src/middleware/user.middleware.js

const { createUser, getUserInfo } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // console.log(ctx.request.body)
    const { username, password } = ctx.request.body;

    // // 合法性
    // if (!username || !password) {
    //     console.log('用户名 或 密码为空', ctx.request.body)
    //     ctx.status = 400
    //     ctx.body = {
    //         code: '10001',
    //         message: '用户名 或 密码为空',
    //         data: ''
    //     }
    //     return
    // }

    // // 合理性(用户名是否存在)
    // // if (该用户名已存在) {
    // //     return
    // // }

    // // 根据特定参数(用户名)查询用户信息是否存在
    // if (getUserInfo({ username })) {
    //     // 409 由于和被请求的资源的当前状态之间存在冲突,请求无法完成
    //     ctx.status = 409
    //     ctx.body = {
    //         code: 10002,
    //         message: '该用户已存在',
    //         data: '',
    //     }
    //     return
    // }

    // 2、操作数据库
    const res = await createUser(username, password);
    // console.log(res)
    // 3、返回结果(从 res 中读取)
    ctx.body = {
      status: 0,
      message: "用户注册成功",
      data: {
        id: res._id,
        username: res.username,
      },
    };
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

改写 src/router/user.route.js,在用户注册路由中添加验证中间件

// 导入 koa-router
const Router = require("koa-router");

// 导入 userValidator 中间件(用户验证)
const {
  userValidator,
  userUniqueVerify,
} = require("../middleware/user.middleware");

// 从 user.controller 中导入模块
// 并从该模块中提取 register 和 login 这两个方法或属性,然后将它们分别赋值给当前文件中的 register 和 login 常量。
const { register, login } = require("../controller/user.controller");

// 实例化 router 路由对象
// 给路由设置一个统一的前缀
const router = new Router({ prefix: "/users" });

// 用户注册路由(添加验证中间件)
router.post("/register", userValidator, userUniqueVerify, register);

// 用户登录路由
router.post("/login", login);

// 导出 Router 对象,这样其他模块就可以引入并使用这个路由对象了
module.exports = router;

测试运行

npm run dev

# 8.2、统一错误处理

TIP

统一将所有的错误信息和服务端状态码单独抽离出来,提高代码的可阅读性和健壮性

创建 src/constant/err.type.js 定义统一返回错误信息

module.exports = {
  userFormateError: {
    code: "10001",
    message: "用户名或密码为空",
    data: "",
  },
  userAlreadyExists: {
    code: "10002",
    message: "用户已存在",
    data: "",
  },
};

改写 src/middleware/user.middleware.js ,统一返回错误信息

const { getUserInfo } = require("../service/user.service");

// 导入统一返回错误
const { userFormateError, userAlreadyExists } = require("../constant/err.type");

// 用户名密码的合法性验证
const userValidator = async (ctx, next) => {
  const { username, password } = ctx.request.body;

  // 合法性
  if (!username || !password) {
    console.error("用户名或密码为空", ctx.request.body);
    // ctx.status = 400
    // ctx.body = {
    //     code: '10001',
    //     message: '用户名或密码为空',
    //     data: '',
    // }

    // 统一提交错误管理
    ctx.app.emit("error", { userFormateError }, ctx);

    return;
  }
  await next();
};

// 用户唯一性验证
const userUniqueVerify = async (ctx, next) => {
  const { username } = ctx.request.body;
  if (await getUserInfo({ username })) {
    // // 409 由于和被请求的资源的当前状态之间存在冲突,请求无法完成
    // ctx.status = 409
    // ctx.body = {
    //     code: 10002,
    //     message: '该用户已存在',
    //     data: '',
    // }

    // 统一提交错误管理
    ctx.app.emit("error", { userAlreadyExists }, ctx);

    return;
  }
  await next();
};

module.exports = {
  userValidator,
  userUniqueVerify,
};

创建 src/app/errHandler.js 定义统一响应状态码

// 定义统一响应状态码
module.exports = (err, ctx) => {
  let status = 500;
  switch (err.code) {
    case "10001":
      status = 400;
      break;
    case "10002":
      status = 409;
      break;
    default:
      status = 500;
  }
  ctx.status = status;
  ctx.body = err;
};

改写 src/app/index.js 统一错误处理,添加 error 事件侦听器

const Koa = require("koa");
const { koaBody } = require("koa-body");
// 导入统一响应状态码
const errHandler = require("./errHandler");

const userRouter = require("../router/user.route");

const app = new Koa();

app.use(koaBody());
app.use(userRouter.routes());

// 统一错误处理,添加error事件侦听器
app.on("error", errHandler);

module.exports = app;

启动运行,测试

npm run dev

# 8.3、优化用户注册插入数据对象

TIP

目前在用户注册时,只能插入 usernamepassword ,如果需要增加 user.model.js 中其他字段,并不能插入数据库的文档中

改写 src/controller/user.controller.js

const { createUser, getUserInfo } = require("../service/user.service");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    // const { username, password } = ctx.request.body
    // -----------------------------------------------
    // 不再解构,直接获取完整的对象
    const user = ctx.request.body;

    // 2、操作数据库
    // const res = await createUser(username, password)
    // ------------------------------------------------
    // 直接传入 user 完整对象
    const res = await createUser(user);
    // console.log(res)
    // 3、返回结果(从 res 中读取)
    ctx.body = {
      status: 0,
      message: "用户注册成功",
      data: {
        id: res._id,
        username: res.username,
      },
    };
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

改写 service ,在 src/service/user.service.js

// 导入 User 模型
const User = require("../model/user.model");

/**
 * 用户信息 Service
 * @author arry老师
 * @version V1.0
 */
class UserService {
  // 用户注册
  // --------------------------------------
  // async createUser(username, password) {

  /**
   * 注册用户
   * @param { Object } user 用户对象
   * @returns 注册成功后的用户信息
   */
  async createUser(user) {
    // 创建用户
    // ----------------------------------
    // const res = await User.create({ username, password })
    return await User.create(user);
  }

  /**
   * 根据特定参数查询用户信息
   * @param { Object } { id, username, role } 对象中有 3 个参数可选(id 用户id,username 用户名,role 用户角色)
   * @returns 根据参数查询到的用户信息
   */
  async getUserInfo({ id, username, role }) {
    const whereOpt = {};
    // 短路运算:如果id不为空(id存在)时,则将 id 拷贝(assign)到 whereOpt对象中
    id && Object.assign(whereOpt, { id });
    username && Object.assign(whereOpt, { username });
    role && Object.assign(whereOpt, { role });

    // 返回第一个匹配的文档数据(按条件查询)
    return await User.findOne(whereOpt);
  }
}

module.exports = new UserService();

# 8.4、错误处理异常捕获

改写 src/controller/user.controller.js 用户注册,添加异常捕获

const { createUser, getUserInfo } = require("../service/user.service");
// 导入统一返回错误信息
const { userRegisterError } = require("../constant/err.type");

/**
 * 用户 Controller
 * @author arry老师
 * @version V1.0
 */
class UserController {
  // 用户注册
  async register(ctx, next) {
    // 1、获取数据
    const user = ctx.request.body;
    // 2、操作数据库
    try {
      const res = await createUser(user);
      // console.log(res)
      // 3、返回结果(从 res 中读取)
      ctx.body = {
        status: 0,
        message: "用户注册成功",
        data: {
          id: res._id,
          username: res.username,
        },
      };
    } catch (err) {
      console.error("用户注册错误", err);
      ctx.app.emit("error", userRegisterError, ctx);
    }
  }

  // 用户登录
  async login(ctx, next) {
    ctx.body = "用户登录成功 !";
  }
}

// 导出 UserController 实例(其他模块可以通过 require 导入这个实例,并调用其方法)
module.exports = new UserController();

改写 src/constant/err.type.js 添加用户注册错误信息

module.exports = {
  userFormateError: {
    code: "10001",
    message: "用户名或密码为空",
    data: "",
  },
  userAlreadyExists: {
    code: "10002",
    message: "用户已存在",
    data: "",
  },
  // 新增用户注册错误信息
  userRegisterError: {
    code: "10003",
    message: "用户注册错误",
    data: "",
  },
};

改写 src/middleware/user.middleware.js 获取用户信息添加异常捕获

const { getUserInfo } = require("../service/user.service");

// 导入统一返回错误
// 导入 userRegisterError 错误码
const {
  userFormateError,
  userAlreadyExists,
  userRegisterError,
} = require("../constant/err.type");

// 用户名密码的合法性验证
const userValidator = async (ctx, next) => {
  const { username, password } = ctx.request.body;

  // 合法性
  if (!username || !password) {
    console.error("用户名或密码为空", ctx.request.body);
    // 统一提交错误管理
    ctx.app.emit("error", { userFormateError }, ctx);
    return;
  }
  await next();
};

// 用户唯一性验证
const userUniqueVerify = async (ctx, next) => {
  const { username } = ctx.request.body;

  // if (await getUserInfo({ username })) {
  //     // 统一提交错误管理
  //     ctx.app.emit('error', { userAlreadyExists }, ctx)
  //     return
  // }

  // 添加异常捕获
  try {
    const res = await getUserInfo({ username });
    if (res) {
      console.error("该用户名已经存在", { username });
      // 统一提交错误管理
      ctx.app.emit("error", { userAlreadyExists }, ctx);
      return;
    }
  } catch (err) {
    console.error("获取用户信息错误", err);
    // 统一提交错误管理
    ctx.app.emit("error", userRegisterError, ctx);
    return;
  }
  await next();
};

module.exports = {
  userValidator,
  userUniqueVerify,
};

启动测试

npm run dev

# 9、用户注册密码加密

TIP

将密码保存到数据库之前,对密码进行加密处理

在 Mongoose 中,对于用户注册时密码的加密,通常会使用bcryptjs库,这是一个 Node.js 环境下用于哈希密码的库。为了在用户注册时自动对密码进行加密,可以使用 Mongoose 的中间件功能。

官方文档:https://www.npmjs.com/package/bcryptjs (opens new window)

# 9.1、安装 bcryptjs

npm i bcryptjs -S

# 9.2、编写加密中间件

TIP

同样,将加密也独立成一个单独的中间件,目的是为了代码逻辑的解耦,每一个中间件最好都是单一职责原则,未来项目升级都可以随时可替换

src/middleware/user.middleware.js 中 新增 加密中间件

// 导入 bcryptjs 加密
const bcrypt = require("bcryptjs");

// 用户密码加密(使用 bcryptjs 加密)
const bcryptPassword = async (ctx, next) => {
  const { password } = ctx.request.body;

  // 生成盐(10次加盐)
  var salt = bcrypt.genSaltSync(10);
  // hash 保存的是 密文
  var hash = bcrypt.hashSync(password, salt);
  // 覆盖 body 中的 password
  ctx.request.body.password = hash;

  await next();
};

module.exports = {
  userValidator,
  userUniqueVerify,
  bcryptPassword,
};

# 9.3、在 user.route 中使用加密中间件

改写 src/router/user.route.js ,新增加密中间件

// 导入 koa-router
const Router = require("koa-router");

// 导入 bcryptPassword 加密中间件
const {
  userValidator,
  userUniqueVerify,
  bcryptPassword,
} = require("../middleware/user.middleware");
const { register, login } = require("../controller/user.controller");

// 实例化 router 路由对象
// 给路由设置一个统一的前缀
const router = new Router({ prefix: "/users" });

// 用户注册路由(添加验证中间件)
// 添加加密 bcryptPassword 中间件
router.post(
  "/register",
  userValidator,
  userUniqueVerify,
  bcryptPassword,
  register
);

// 用户登录路由
router.post("/login", login);

// 导出 Router 对象,这样其他模块就可以引入并使用这个路由对象了
module.exports = router;

启动运行测试

npm run dev

# 9.4、测试用户注册,密码加密

postman 模拟用户注册

image-20240324010920682

在 数据库中查看注册成功后的密码(已加密)

image-20240324011156321

# 三、用户登录验证

TIP

完成用户登录基础信息验证,步骤:

  • 验证用户名和密码的格式是否正确(web 端)
  • 验证用户是否存在
  • 验证密码是否匹配

# 1、创建用户登录验证中间件

改写 src/middleware/user.middleware.js,新增用户登录验证中间件

// 导入 bcryptjs 加密
const bcrypt = require("bcryptjs");
const { getUserInfo } = require("../service/user.service");

// 导入统一返回错误
const {
  userFormateError,
  userAlreadyExists,
  userRegisterError,
  userDoesNotExist,
  invalidPassword,
  userLoginError,
} = require("../constant/err.type");

// ----------- 省略部分代码 ... ------------

// 验证用户登录,中间件
const verifyLogin = async (ctx, next) => {
  const { username, password } = ctx.request.body;
  try {
    // 1、判断用户是否存在(不存在:报错)
    const res = await getUserInfo({ username });
    if (!res) {
      console.error("用户名不存在", username);
      // 统一提交错误管理
      ctx.app.emit("error", userDoesNotExist, ctx);
      return;
    }
    // 2、密码是否匹配(不匹配:报错)
    // bcrypt.compareSync(用户输入密码, 数据库中的密码) 进行比对
    if (!bcrypt.compareSync(password, res.password)) {
      console.error("无效的密码", password);
      // 统一提交错误管理
      ctx.app.emit("error", invalidPassword, ctx);
      return;
    }
  } catch (err) {
    console.error("获取用户信息错误", err);
    ctx.app.emit("error", userLoginError, ctx);
    return;
  }

  await next();
};

module.exports = {
  userValidator,
  userUniqueVerify,
  bcryptPassword,
  verifyLogin,
};

# 2、新增错误类型,统一错误处理

src/constant/err.type.js 中,新增登录相关错误类型

// 定义统一返回错误信息
module.exports = {
  // 省略部分 ...

  userDoesNotExist: {
    code: "10004",
    message: "用户不存在",
    data: "",
  },
  userLoginError: {
    code: "10005",
    message: "用户登录失败",
    data: "",
  },
  invalidPassword: {
    code: "10006",
    message: "无效的密码",
    data: "",
  },
};

# 3、改写用户登录路由

src/router/user.route.js 中,改写用户登录路由

// 导入自定义中间件
const {
  userValidator,
  userUniqueVerify,
  bcryptPassword,
  verifyLogin,
} = require("../middleware/user.middleware");

// 用户登录路由
router.post("/login", userValidator, verifyLogin, login);

# 4、Postman 测试用户登录

当用户名不存在时

image-20240325151552541

密码错误

image-20240325151740227

登录失败(在验证登录中间件 verifyLogin 中制造一个错误,即可测试)

image-20240325152026543

注:

在实现用户登录前需要对相关用户信息进行验证,都放到对应的中间件中来执行。真正验证通过后,最后再执行 login (登录)

在用户登录时,先要记录用户登录的状态,完成用户信息的认证(用户确认用户身份)。

# 四、用户 Token 认证

TIP

登录成功后,给用户颁发一个令牌 token ,用户在以后的每一次请求中携带这个令牌。

颁发令牌(Token)的主要目的是为了实现安全的用户认证和授权。Token 在此过程中扮演了关键的角色,它的主要作用包括:

  • ①、减轻服务器压力:通过引入 Token,服务器可以减少对数据库的频繁查询,因为一旦用户通过验证并获得了 Token,服务器就可以在后续的请求中直接验证这个 Token,而无需每次都去查询数据库。这有助于提升服务器的性能和健壮性。
  • ②、实现无状态认证:Token 是一种无状态的认证机制,这意味着服务器不需要保存用户的会话信息。每次请求时,客户端只需提供有效的 Token,服务器即可验证用户的身份。这种无状态的特性使得系统更加灵活和可扩展。
  • ③、增强安全性:Token 通常包含一些加密信息,如用户的身份标识、有效期等,这些信息经过加密处理,可以提高传输过程的安全性。同时,Token 的有效期通常有限,过期后需要重新获取,这进一步降低了安全风险。

因此,我们知道 JWT 是一个 Token(令牌),用于 web 中数据的安全传输。

# 1、Token 登录验证步骤

TIP

  • ①、用户输入账号密码点击登录
  • ②、后台收到账号密码,验证是否合法用户
  • ③、后台验证是合法用户,生成一个 Token返回给用户
  • ④、用户收到该 Token 并将其保存在每次请求的请求头中
  • ⑤、后台每次收到请求都去查询请求头中是否含有正确的 Token,只有 Token 验证通过才会返回请求的资源

image-20240331233853664

注:

这种基于 Token 的认证方式相比较于基于传统的 cookie 和 session 方式更加节约资源,并且对移动端和分布式系统支持更加友好,其优点有:

  • 支持跨域访问:cookie 是不支持跨域的,而 Token 可以放在请求头中传输
  • 无状态:Token 自身包含了用户登录的信息,无需在服务器端存储 session
  • 移动端支持更好:当客户端不是浏览器时,cookie 不被支持,采用 Token 无疑更好
  • 无需考虑 CRSF:不使用 cookie,也就无需考虑 CRSF 的防御

而 JWT 就是上述 Token 的一种具体实现方式,其 本质就是一个字符串,是将用户信息存储到 JSON 中然后经过编码得到的字符串

# 2、JWT 的结构

TIP

JWT(JSON Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为 JSON 对象在各方之间安全地传输信息。这些信息可以被验证和信任,因为它们是数字签名的。JWT 通常用于身份验证和授权。

JWT 由三部分组成,分别是

  • Header(头部):头部包含了两部分,token 类型和采用的加密算法
  • Payload(有效载荷):包含了有关用户或其他实体(如设备)的声明(即:token 中到底包含了哪些具体的信息)
  • Signature(签名):是 JWT 的最后一部分,用于验证 JWT 的完整性和发送者的身份(对 Header 和 Payload 进行 Base64 的加密最后生成了一个 签名),该 签名 可以验证 token 令牌的有效性 和 安全性
  • 用点 . 将三部分隔开便是 JWT 的结构,形如xxxxx.yyyyyy.zzzzz的字符串

详细可阅读 JWT 官方文档 (opens new window)

image-20240325233303762

# 2.1、Header(头部)

TIP

  • 含义:Header 描述了 JWT 的元数据,包含了签名算法的类型(如 HMAC SHA256 或 RSA)以及 JWT 的类型(通常是 JWT)
  • 结构:Header 是一个 JSON 对象,它被 Base64Url 编码形成 JWT 的第一部分。
{
  "alg": "HS256",
  "typ": "JWT"
}

注:

该示例中,alg 属性表示用于签名的算法(这里是 HMAC SHA256),而 typ 属性表示 token 的类型(这里是 JWT)。

# 2.2、Payload(有效载荷)

TIP

  • 含义:Payload 包含了有关用户或其他实体(如设备)的声明。这些声明可以是用户的 ID、用户名、角色等,以及其他任何自定义数据。请注意,Payload 不是加密的,只是 Base64 编码,因此不应包含敏感信息。
  • 结构:Payload 同样是一个 JSON 对象,被 Base64Url 编码形成 JWT 的第二部分。

payload 是 JWT 的主体部分,保存实体(通常是用户)信息,每一个字段就是一个 claim(声明),JWT 为我们提供了一些默认字段

iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID 用于标识该 JWT

我们也可以自定义私有字段,如用来保存用户信息

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

注:

该示例中,sub 是用户的 ID,name 是用户名,而 admin 是一个布尔值,表示用户是否具有管理员权限。

# 2.3、Signature(签名)

TIP

  • 含义:Signature 是 JWT 的最后一部分,用于验证 JWT 的完整性和发送者的身份。Signature 是使用 Header 中指定的算法以及一个密钥(secret)对 Header 和 Payload 进行签名得到的。
  • 结构:Signature 是通过 Base64Url 编码的 Header 和 Payload,以及密钥(secret)一起使用指定的签名算法(如 HMAC SHA256 或 RSA)计算得出的。

签名部分是对上面两部分数据签名,需要使用 base64 编码后的 header 和 payload 数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用 header 中指定的签名算法(默认情况下为 HMAC SHA256)根据以下公式生成签名

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  // 你的 256 位秘钥
  your - 256 - bit - secret
);

注:

在 JWT 的使用中,密钥(secret)的安全性至关重要。如果密钥被泄露,攻击者可以伪造有效的 JWT。因此,应妥善保管密钥,并在必要时定期更换。

JWT 的三个部分共同构成了一个紧凑的、自包含的令牌,可以在各方之间安全地传输信息。

# 2.4、注意事项

TIP

  • ①、Header 和 Payload 只是简单的利用 Base64 编码,是可逆的,因此不要在 Payload 中存储敏感信息
  • ②、Signature 使用的是不可逆的加密算法,无法解码出原文,它的作用是校验 Token 有没有被篡改。该算法需要我们自己指定一个密钥,这个密钥存储在服务端,不能泄露
  • ③、尽量避免在 JWT 中存储大量信息,因为一些服务器接收的 HTTP 请求头最大不超过 8KB

# 3、颁发 token

安装 jwt(jsonwebtoken)

npm i jsonwebtoken -S

# 4、在控制器中,改写 login 方法

src/controller/user.controller.js 中,在用户登录成功后,给用户颁发 token 签名,作为唯一身份认证

// 导入 jsonwebtoken
const jwt = require('jsonwebtoken')
// 导入 jwt 秘钥
const { JWT_SECRET } = require('../config/config.default')


    // 用户登录
    async login(ctx, next) {
        const { username } = ctx.request.body

        // 1、获取用户信息 (在token的payload中,记录 id,username,role ...)
        try {
            const userInfo = await getUserInfo({ username })
            // console.log(userInfo)
            // { password, ...res } 将返回结果的 json 对象中 password 剔除掉,剩余的键值对 保存在 res 新的对象中 (ES6 中的剩余参数)
            const { _doc: { password, ...res } } = userInfo;
            // console.log("踢出 password 剩余的部分:", res)

            ctx.body = {
                code: 0,
                message: '用户登录成功',
                data: {
                    // 用户登录成功后,给用户颁发 token 签名,作为唯一身份认证。在后续的接口调用过程中,都需要携带上这样的令牌,在服务端对令牌的有效性进行校验,校验通过才能执行下面的操作
                    token: jwt.sign(res, JWT_SECRET, { expiresIn: '1d' }),
                },
            }
        } catch (err) {
            console.error('用户登录失败', err)
        }
    }

注:

解读以上生成 token 签名

  • jwt.sign(...):调用一个名为jwt的模块或对象中的sign方法。这个方法用于生成 JWT
  • res:这是jwt.sign方法的第一个参数,代表你想要包含在 JWT 有效载荷(payload)中的数据。这通常是一个 JavaScript 对象,其中包含有关用户或其他实体的信息。
  • JWT_SECRET:这是jwt.sign方法的第二个参数,用于签名 JWT 的密钥(secret)。这个密钥应该保密,只有授权方才能知道。当 JWT 被接收并需要验证时,也会使用这个密钥来确认 JWT 的完整性和发送者的身份。
  • { expiresIn: '1d' }:这是jwt.sign方法的第三个参数,一个选项对象。expiresIn 指定 JWT 的过期时间。'1d'表示这个 JWT 的有效期是 1 天。过期后,这个 JWT 将不再有效,需要重新生成。

这段代码的功能是:使用JWT_SECRET密钥和给定的有效载荷res生成一个 JWT,并设置其有效期为 1 天。生成的 JWT 可以用于在各种场景中进行身份验证和授权,例如 API 访问、单点登录等。

# 5、定义私钥

.env 文件中定义

JWT_SECRET = icoding

注:

为了确保 JWT 的安全性,可以按以下方式来做

  • 妥善保管JWT_SECRET密钥,不要将其泄露给未经授权的人员。
  • 定期更换密钥,以减少密钥被破解的风险。
  • 确保 JWT 的传输是安全的,例如使用 HTTPS 来避免中间人攻击。

# 6、postman 测试 token

TIP

用户登录成功后,颁发 token

image-20240326204134457

# 7、用户认证(验证 token)

TIP

前面我们学习了登录的流程,登录成功后给用户颁发 token,该 token 的作用是用户身份的象征。

在后边如果该用户需要访问其它的资源 或 接口的话,需要携带 token,再去做进一步的判断时,需要对 token 进行验证。如果 token 验证成功可以从 token 中提取用户信息(id、用户名 ..... 等)

# 7.1、验证场景:修改用户密码

TIP

  • 首先要先登录 ->
  • 登录后,系统颁发 token ->
  • 在发送修改密码请求时(就会携带 token)->
  • 验证 token

修改密码发送请求时,是如何携带 token 的呢,首先我们在 postman 中模拟创建一个 “修改用户密码” 的请求接口。修改请求方式有两种(RESTful API 风格)

  • PUT 请求:将所有的信息一次性全部修改
  • PATCH 请求:补丁的方式,即:只修改一部分的内容(如:只传递 password 就修改该字段,其他的字段不会被修改)

很显然,以上 “修改用户密码” 的场景中,只需要修改密码,用 PATCH 请求最为合适

image-20240329153653875

# 7.2、定义修改密码路由

src/router/user.route.js 中定义修改用户密码的路由

// 修改用户密码
router.patch("/", (ctx, next) => {
  ctx.body = "修改密码成功";
});

启动运行

npm run dev

在 postman 中发送修改密码请求

image-20240329155925286

注:

点击 send 发送请求,即可成功拿到响应。接下来要实现修改密码,就需要携带认证信息

  • 首先要先登录(在 postman 中,用户登录接口发送请求) ->
  • 登录后,返回系统颁发的 token ->
  • 在发送 “修改密码接口” 请求时,需要携带该 token

# 7.3、认证:验证 token ,并获取用户信息

TIP

认证 token 是写在中间件里边的,认证很多接口都会用到,这样可以提高代码复用。

在 JWT 认证中,最标准的做法是把 Token 放在请求头里边,并且前边会有 Bearer 开头。格式为 Bearer 空格 + token 信息

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyIkX18iOnsiYWN0aXZlUGF0aHMiOnsicGF0aHMiOnsidXNlcm5hbWUiOiJpbml0IiwiZ2VuZGVyIjoiaW5pdCIsInJvbGUiOiJpbml0IiwiX2lkIjoiaW5pdCIsInBhc3N3b3JkIjoiaW5pdCIsInBob25lIjoiaW5pdCIsImVtYWlsIjoiaW5pdCIsImNyZWF0ZWRBdCI6ImluaXQiLCJ1cGRhdGVkQXQiOiJpbml0IiwiX192IjoiaW5pdCJ9LCJzdGF0ZXMiOnsicmVxdWlyZSI6e30sImRlZmF1bHQiOnt9LCJpbml0Ijp7Il9pZCI6dHJ1ZSwidXNlcm5hbWUiOnRydWUsInBhc3N3b3JkIjp0cnVlLCJnZW5kZXIiOnRydWUsInBob25lIjp0cnVlLCJlbWFpbCI6dHJ1ZSwicm9sZSI6dHJ1ZSwiY3JlYXRlZEF0Ijp0cnVlLCJ1cGRhdGVkQXQiOnRydWUsIl9fdiI6dHJ1ZX19fSwic2tpcElkIjp0cnVlfSwiJGlzTmV3IjpmYWxzZSwiX2RvYyI6eyJfaWQiOiI2NWZmMGJjNzNjZTJmMjNhZmM3Mzk0OTYiLCJ1c2VybmFtZSI6ImFycnnogIHluIgiLCJwYXNzd29yZCI6IiQyYSQxMCRrTHYzM2N0NTdOMTk3blBiT3FETElPZ2ZhV0JWaGpUeGdqbEFxZWhBWHYwR2tTUHg3OWtGMiIsImdlbmRlciI6MCwicGhvbmUiOiIxMzIxMjM0NTUyMSIsImVtYWlsIjoiMTIzQGliYy5jb20iLCJyb2xlIjowLCJjcmVhdGVkQXQiOiIyMDI0LTAzLTIzVDE3OjA1OjExLjQ4OFoiLCJ1cGRhdGVkQXQiOiIyMDI0LTAzLTIzVDE3OjA1OjExLjQ4OFoiLCJfX3YiOjB9LCJpYXQiOjE3MTE0NTg5NjksImV4cCI6MTcxMTU0NTM2OX0.zD8xgf-m9gUhGd_0ZnlUqQyCRM4aDSxAceOMvcA179Q

注:

进行接口测试时,添加断言时必不可少的。断言就是判断响应内容与预期返回是否一致

# 7.4、Postman 配置全局共享 token

TIP

登录接口中配置 Postman ,设置 token 为全局共享 token(省得每次使用 token 过程中单独配置)

在用户登录接口中,请求成功后 获取、解析、存储 token

image-20240330001935421

// pm 代表 postman
pm.test("Successful POST request", function () {
  // 获取响应体中的 json
  const jsonData = pm.response.json();
  // 解析 token
  const token = jsonData.data.token;
  // 将 token 存储在集合变量中,其中存储 token 值的变量名称也是 token
  pm.collectionVariables.set("token", token);
});

为整个项目配置全局 token ,全局共享 token(省得每次使用 token 过程中单独配置)

image-20240330002909340

配置 token 变量名称,和以上 集合中存储 token 的变量名一致

image-20240330004033447

再次请求用户登录接口,为 token 变量重新赋值,再次进入 Variable 中,即可查看到 token 的值

GIF-2024-3-30-0-48-01

请求登录接口后,也可在 Test Results 中查看 token 的值是否设置成功

image-20240330005636238

通过 "修改用户密码" 接口,验证 token 配置:Authorization 默认配置即可

image-20240330012317721

在 “修改用户密码” 接口的请求头中,会自动携带 token,用于 token 验证(是否当前用户登录)

image-20240330012620705

# 7.5、获取请求头中的 token

src/router/user.route.js "修改用户密码" 路由中,获取请求头的中的 token 值

// 修改用户密码
router.patch("/", (ctx, next) => {
  // 从请求头中获取 authorization 授权令牌(token),解析 token 值
  const { authorization } = ctx.request.header;
  // 从 authorization 字符串中移除 "Bearer " 前缀,以获取实际的令牌(token)
  // 在很多 OAuth 2.0 授权流程中,令牌通常是以 "Bearer " 前缀开始的
  const token = authorization.replace("Bearer ", "");
  // 将提取出的令牌打印到控制台
  console.log(token);

  ctx.body = "修改密码成功";
});

注:

以上代码的主要目的是从 HTTP 请求的头部获取一个带有"Bearer "前缀的授权令牌,并移除该前缀以获取实际的令牌值,然后将令牌值打印到控制台。

启动运行

npm run dev

在 postman 中,发送 “修改密码” 接口请求

image-20240330143929326

在 VSCode 控制台中查看获取到的 token 值

image-20240330144208953

注:

以上代码中已从请求头中获取到了 token 的值,然后就需要对 token 进行验证。

这部分的代码都是在做统一的用户鉴权,最好是将其抽离到独立的中间件里。在很多情况下都需要先去验证用户有没有登录。抽离出中间件就是最优解

# 8、定义 auth 中间件

TIP

进行 token 验证(是否当前用户登录)

新建 src/middleware/auth.middleware.js 中间件

const jwt = require("jsonwebtoken");

const { JWT_SECRET } = require("../config/config.default");
const { tokenExpiredError, invalidToken } = require("../constant/err.type");

const auth = async (ctx, next) => {
  // 获取请求头 authorization ,解析 token 值
  const { authorization } = ctx.request.header;
  // 从 authorization 字符串中移除 "Bearer " 前缀,以获取实际的令牌(token)
  const token = authorization.replace("Bearer ", "");
  // console.log(token)

  try {
    // 调用 jwt.verify 函数来验证一个 JWT 令牌,并将验证后的用户信息存储到上下文对象 ctx 的 state 属性中
    // token:这是之前从 HTTP 请求头部提取并处理过的 JWT 令牌
    // JWT_SECRET:这是一个密钥,用于验证 JWT 令牌的签名。这个密钥应该是保密的,并且只有授权的服务端才应该知道它。
    // user 中包含了 payload 的信息(id、username、role ......)
    const user = jwt.verify(token, JWT_SECRET);
    // jwt.verify 函数会返回一个对象,这个对象通常包含了在创建令牌时添加到载荷(payload)中的用户信息
    // 该中间件将验证后的用户数据直接返回给浏览器
    ctx.state.user = user;
  } catch (err) {
    switch (err.name) {
      // jsonwebtoken 库抛出的一个特定错误,表示尝试验证的 JWT令牌已经过期
      case "TokenExpiredError":
        console.error("token已过期", err);
        // 统一提交错误管理
        return ctx.app.emit("error", tokenExpiredError, ctx);
      // JsonWebTokenError 是一个更广泛的错误类型,它涵盖了与 JWT 相关的各种错误情况
      case "JsonWebTokenError":
        console.error("无效的token", err);
        // 统一提交错误管理
        return ctx.app.emit("error", invalidToken, ctx);
    }
  }

  await next();
};

module.exports = {
  auth,
};

注:

JsonWebTokenError (opens new window) 是一个更广泛的错误类型,它涵盖了与 JWT 相关的各种错误情况。这包括但不限于:

  • 令牌格式不正确(例如,不是有效的 Base64 编码)
  • 令牌签名验证失败(可能是因为使用了错误的密钥或令牌在传输过程中被篡改)
  • 令牌中的某些声明缺失或无效

jwt.verify函数遇到上述任何问题时,它都会抛出JsonWebTokenError。与TokenExpiredError不同,JsonWebTokenError不特指令牌过期,而是涵盖了更广泛的验证失败情况。

处理JsonWebTokenError通常需要检查错误的详细信息来确定具体的问题所在,并据此采取相应的措施。例如,如果是因为令牌格式不正确,你可能需要向用户显示一个错误消息,要求他们提供有效的令牌。如果是因为签名验证失败,这可能表明存在更严重的安全问题,需要进一步的调查和处理。

总的来说,TokenExpiredErrorJsonWebTokenError都是jsonwebtoken库用于处理 JWT 验证过程中可能遇到的问题的特定错误类型。通过捕获和处理这些错误,你可以更优雅地处理与 JWT 相关的各种异常情况。

改写 src/constant/err.type.js 添加 token 验证相关错误信息

  tokenExpiredError: {
    code: '10101',
    message: 'token已过期',
    data: '',
  },
  invalidToken: {
    code: '10102',
    message: '无效的token',
    data: '',
  },

改写 src/router/user.route.js 路由

// 导入 jwt 验证 token 中间件
const { auth } = require("../middleware/auth.middleware");

// 修改用户密码
// 修改密码前,添加 auth 中间件,进行 token 身份认证
router.patch("/", auth, (ctx, next) => {
  // 打印输出 在创建令牌时添加到载荷(payload)中的用户信息(通过 auth 中间件验证后的用户数据)
  console.log(ctx.state.user);
  ctx.body = "修改密码成功";
});

# 9、postman 测试 token 报错信息

TIP

在 postman 中,测试 token 报错信息

①、故意修改错 token 的值,用来测试无效的 token

image-20240330161614323

token 无效

image-20240330162624051

②、将 登录成功后,颁发 token 时,把 { expiresIn: '1d' } 改为 10 秒

src/controller/user.controller.js 用户登录方法中,修改 token 过期时间

data: {
    // 将 { expiresIn: '1d' } 改为  { expiresIn: 10 }
    token: jwt.sign(res, JWT_SECRET, { expiresIn: '10' }),
},

注:

jsonwebtoken 库中,expiresIn 选项的值用于指定 JWT 的过期时间,其值可以是一个数字或一个字符串。数字表示秒数,而字符串可以包含数字和以下单位之一:

  • 's':秒
  • 'm':分钟
  • 'h':小时
  • 'd':天

所以,expiresIn 的值可以是以下一些例子:

数字:

  • 60:60 秒后过期
  • 3600:3600 秒(即 1 小时)后过期

字符串:

  • '10s':10 秒后过期
  • '1m':1 分钟后过期
  • '2h':2 小时后过期
  • '1d':1 天后过期

token 已过期

image-20240330192437649

VSCode 控制台也会报错

image-20240330192844654

注:

完成测试后,再将 token 的过期时间修改过来,方便后续使用

src/controller/user.controller.js 用户登录方法中,修改 token 过期时间

data: {
    // 将 { expiresIn: '10' } 改为  { expiresIn: '1d' }
    token: jwt.sign(res, JWT_SECRET, { expiresIn: '1d' }),
},

# 10、定义修改密码 Controller

src/controller/user.controller.js 添加修改密码的方法

const { createUser, getUserInfo, updateById } = require('../service/user.service')
// 导入统一返回错误信息
const { userRegisterError, changePasswordError } = require('../constant/err.type')

// 修改密码
async changePassword(ctx, next) {
    // 1、获取数据
    // 获取 auth 中间件中将验证后的用户数据,其中的 _id
    const _id = ctx.state.user._id
    // 用户密码
    const password = ctx.request.body.password

    try {
        // 2、操作数据库
        if (await updateById({ _id, password })) {
            // a.b 模拟修改密码失败返回错误
            ctx.body = {
                code: 0,
                message: '修改密码成功',
                data: '',
            }
        }
    } catch (err) {
        console.error('修改密码失败', err)
        ctx.app.emit('error', changePasswordError, ctx)
    }
    // 3、返回结果
}

改写 src/constant/err.type.js ,新增 changePasswordError 统一错误处理

  changePasswordError: {
    code: '10007',
    message: '修改密码失败',
    data: '',
  },

# 11、创建修改密码 Service

TIP

定义操作数据库修改密码的方法

src/service/user.service.js 中新增修改密码的方法

/**
 * 根据用户 id 修改(username, password, role) 通用方法
 * @param { Object } { _id, username, password, role }
 * @returns 返回修改后的最新用户信息
 */
async updateById({ _id, username, password, role }) {
    // 修改的条件
    const whereOpt = { _id }
    // 修改的内容
    const newUser = {}

    username && Object.assign(newUser, { username })
    password && Object.assign(newUser, { password })
    role && Object.assign(newUser, { role })

    // const res = await User.update(newUser, { where: whereOpt })
    const res = await User.updateOne(
        whereOpt,
        { $set: newUser }
    )
    console.log(res)
    // 返回值为代码改动记录数
    return res.modifiedCount > 0 ? true : false
}

# 12、改写用户路由

TIP

src/router/user.route.js 中,添加 bcryptPassword(密码加密中间件)、changePassword(修改密码 Controller)完成用户密码修改

// 导入修改用户密码 controller 方法
const {
  register,
  login,
  changePassword,
} = require("../controller/user.controller");

// 修改用户密码路由
// auth:token 验证中间件
// bcryptPassword:密码加密中间件
// changePassword: 修改密码 Controller
router.patch("/", auth, bcryptPassword, changePassword);

启动运行

npm run dev

image-20240330234157079

再次使用原密码 “123” 登录测试

image-20240330234409846

# 13、在 postman 中配置开发环境,测试请求

TIP

在 postman 中配置环境变量,新建 comment-system-dev 作为开发环境的环境变量, baseURL 为对应的 url 地址。生成环境同理 !

image-20240331221719333

用户注册,引用开发环境

image-20240331224421217

用户登录,引用开发环境

image-20240331223520214

修改用户密码,引用开发环境

image-20240331224130807

# 14、生产环境配置

TIP

在 postman 中 配置生产环境的 “环境变量” 与 开发环境类似,一般情况命名为 comment-system-pro,同时 配置对应的 URL 地址即可。

image-20240331225442544

上次更新时间: 4/13/2024, 7:26:20 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X