# 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 建立远程仓库,用于存储项目源代码
创建完成后,复制远程仓库地址
# 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/
注:
在 .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
或 在 package.json
中,定义启动运行脚本
{
"scripts": {
// 定义 nodemon 的启动运行脚本
"dev": "nodemon ./src/main.js"
}
}
使用自定义脚本启动
npm run dev
在浏览器地址栏中输入 http://localhost:3000/
在 postman 中测试
# 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
# 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
在浏览器地址栏中输入 http://localhost:8000/users/
# 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 请求)
# 6、解析 body
TIP
koa 原生是不支持 body 的参数解析,会借助社区提供的中间件来实现。官方推荐如下
- koa-bodyparser (opens new window)
- koa-body (opens new window)(推荐使用,支持的类型更多)
# 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
# 二、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
# 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 请求注册用户信息
用户注册成功后查看 VSCode 终端输出
# 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 测试,返回注册成功信息
# 7、错误处理
TIP
在控制器中,对不同的错误进行处理,返回不同的错误提示信息,提高代码质量
- 加强程序的鲁棒性(健壮性)
- 合法性(非空判断等)
- 合理性(去重判断等)
# 7.1、制造错误
制造错误 1: 注册不填写 username
,报 500 服务器内部错误
制造错误 2: 注册数据库中已有用户名,同样报 500 服务器内部错误
注:
我们发现以上两种不同错误,返回的状态码 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();
改写 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();
# 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
目前在用户注册时,只能插入 username
和 password
,如果需要增加 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 模拟用户注册
在 数据库中查看注册成功后的密码(已加密)
# 三、用户登录验证
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 测试用户登录
当用户名不存在时
密码错误
登录失败(在验证登录中间件 verifyLogin 中制造一个错误,即可测试)
注:
在实现用户登录前需要对相关用户信息进行验证,都放到对应的中间件中来执行。真正验证通过后,最后再执行 login
(登录)
在用户登录时,先要记录用户登录的状态,完成用户信息的认证(用户确认用户身份)。
# 四、用户 Token 认证
TIP
登录成功后,给用户颁发一个令牌 token ,用户在以后的每一次请求中携带这个令牌。
颁发令牌(Token)的主要目的是为了实现安全的用户认证和授权。Token 在此过程中扮演了关键的角色,它的主要作用包括:
- ①、减轻服务器压力:通过引入 Token,服务器可以减少对数据库的频繁查询,因为一旦用户通过验证并获得了 Token,服务器就可以在后续的请求中直接验证这个 Token,而无需每次都去查询数据库。这有助于提升服务器的性能和健壮性。
- ②、实现无状态认证:Token 是一种无状态的认证机制,这意味着服务器不需要保存用户的会话信息。每次请求时,客户端只需提供有效的 Token,服务器即可验证用户的身份。这种无状态的特性使得系统更加灵活和可扩展。
- ③、增强安全性:Token 通常包含一些加密信息,如用户的身份标识、有效期等,这些信息经过加密处理,可以提高传输过程的安全性。同时,Token 的有效期通常有限,过期后需要重新获取,这进一步降低了安全风险。
因此,我们知道 JWT 是一个 Token(令牌),用于 web 中数据的安全传输。
# 1、Token 登录验证步骤
TIP
- ①、用户输入账号密码点击登录
- ②、后台收到账号密码,验证是否合法用户
- ③、后台验证是合法用户,生成一个
Token
返回给用户 - ④、用户收到该 Token 并将其保存在每次请求的请求头中
- ⑤、后台每次收到请求都去查询请求头中是否含有正确的 Token,只有 Token 验证通过才会返回请求的资源
注:
这种基于 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
的字符串
# 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
方法。这个方法用于生成 JWTres
:这是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
# 7、用户认证(验证 token)
TIP
前面我们学习了登录的流程,登录成功后给用户颁发 token,该 token 的作用是用户身份的象征。
在后边如果该用户需要访问其它的资源 或 接口的话,需要携带 token,再去做进一步的判断时,需要对 token 进行验证。如果 token 验证成功可以从 token 中提取用户信息(id、用户名 ..... 等)
# 7.1、验证场景:修改用户密码
TIP
- 首先要先登录 ->
- 登录后,系统颁发 token ->
- 在发送修改密码请求时(就会携带 token)->
- 验证 token
修改密码发送请求时,是如何携带 token 的呢,首先我们在 postman 中模拟创建一个 “修改用户密码” 的请求接口。修改请求方式有两种(RESTful API 风格)
- PUT 请求:将所有的信息一次性全部修改
- PATCH 请求:补丁的方式,即:只修改一部分的内容(如:只传递 password 就修改该字段,其他的字段不会被修改)
很显然,以上 “修改用户密码” 的场景中,只需要修改密码,用 PATCH 请求最为合适
# 7.2、定义修改密码路由
在 src/router/user.route.js
中定义修改用户密码的路由
// 修改用户密码
router.patch("/", (ctx, next) => {
ctx.body = "修改密码成功";
});
启动运行
npm run dev
在 postman 中发送修改密码请求
注:
点击 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
// 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 过程中单独配置)
配置 token 变量名称,和以上 集合中存储 token 的变量名一致
再次请求用户登录接口,为 token 变量重新赋值,再次进入 Variable
中,即可查看到 token 的值
请求登录接口后,也可在 Test Results
中查看 token 的值是否设置成功
通过 "修改用户密码" 接口,验证 token 配置:Authorization 默认配置即可
在 “修改用户密码” 接口的请求头中,会自动携带 token,用于 token 验证(是否当前用户登录)
# 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 中,发送 “修改密码” 接口请求
在 VSCode 控制台中查看获取到的 token 值
注:
以上代码中已从请求头中获取到了 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
通常需要检查错误的详细信息来确定具体的问题所在,并据此采取相应的措施。例如,如果是因为令牌格式不正确,你可能需要向用户显示一个错误消息,要求他们提供有效的令牌。如果是因为签名验证失败,这可能表明存在更严重的安全问题,需要进一步的调查和处理。
总的来说,TokenExpiredError
和JsonWebTokenError
都是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
token 无效
②、将 登录成功后,颁发 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 已过期
VSCode 控制台也会报错
注:
完成测试后,再将 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
再次使用原密码 “123” 登录测试
# 13、在 postman 中配置开发环境,测试请求
TIP
在 postman 中配置环境变量,新建 comment-system-dev
作为开发环境的环境变量, baseURL 为对应的 url 地址。生成环境同理 !
用户注册,引用开发环境
用户登录,引用开发环境
修改用户密码,引用开发环境
# 14、生产环境配置
TIP
在 postman 中 配置生产环境的 “环境变量” 与 开发环境类似,一般情况命名为 comment-system-pro
,同时 配置对应的 URL 地址即可。
大厂最新技术学习分享群
微信扫一扫进群,获取资料
X