express + mysql搭建nodejs服务端项目流程系列文章链接:
01、express + mysql搭建nodejs服务端项目流程(一)——使用express-generator创建项目
02、express + mysql搭建nodejs服务端项目流程(二)——添加打印日志winston和mysql相关依赖
03、express + mysql搭建nodejs服务端项目流程(三)——使用joi、crypto-js和jwt等技术完善注册登录接口(本文)
一、集成 Joi 依赖来进行数据验证
通过下面 第 1 点 和 第 2 点 记录一下joi的基础知识点
1、Joi 安装与基础使用
1.1. 安装 Joi
npm install joi
1.2. 基本验证示例
// 基础数据验证
const Joi = require('joi');
const schema = Joi.object({
username: Joi.string().min(3).max(30).required(),
age: Joi.number().integer().min(18).max(100),
email: Joi.string().email()
});
const data = { username: 'leo', age: 25, email: 'test@example.com' };
const { error, value } = schema.validate(data);
if (error) {
console.log(error.details); // 输出错误详情
} else {
console.log('验证通过:', value);
}
2、Joi 核心验证规则速查表
2.1. 通用规则
规则类型 | 示例代码 | 说明 |
---|---|---|
必填项 | .required() | 强制字段存在 |
类型验证 | .string() .number() .boolean() | 基础类型验证 |
范围限制 | .min(5) .max(100) | 数值/字符串长度限制 |
正则验证 | .pattern(/^1[3-9]\d{9}$/) | 正则表达式匹配 |
枚举值 | .valid('admin', 'user') | 限定允许的枚举值 |
默认值 | .default('guest') | 未传值时自动填充默认值 |
2.2. 字符串专用
Joi.string()
.alphanum() // 仅允许字母数字
.trim() // 自动去除两端空格
.lowercase() // 转换为小写
.uppercase() // 转换为大写
.replace(/ /g, '_') // 替换字符
2.3. 数字专用
Joi.number()
.integer() // 必须为整数
.positive() // 正数
.precision(2) // 小数点后保留2位
2.4. 复杂结构
// 数组验证
Joi.array().items(Joi.string().valid('A', 'B', 'C'))
// 对象嵌套
Joi.object({
address: Joi.object({
city: Joi.string(),
street: Joi.string()
})
})
// 条件验证
Joi.when('role', {
is: 'admin',
then: Joi.object({ accessLevel: Joi.number().min(3) })
})
3、项目开始集成JOI
3.1. 安装 Joi
npm install joi
当前时间安装的joi版本是:17.13.3
3.2. 编写自定义中间件统一验证请求参数
在根目录下创建middlewares目录,并创建validate.js文件,编写如下代码:
const Joi = require('joi')
exports.validateJoi = (schemas, options = { myJoiStrict: false }) => {
// 自定义校验选项
// myJoiStrict 自定义属性,默认不开启严格模式,会过滤掉那些未定义的参数项
// 如果用户指定了 myJoiStrict 的值为 true,则开启严格模式,此时不会过滤掉那些未定义的参数项
if (!options.myJoiStrict) {
// allowUnknown 允许提交未定义的参数项
// stripUnknown 过滤掉那些未定义的参数项
// abortEarly 是否在第一个错误时停止验证(默认 true,设为 false 收集所有错误)
// passError 将错误传递给下一个中间件
options = { allowUnknown: true, stripUnknown: true, abortEarly: false, passError: true, ...options }
}
// 从 options 配置对象中,删除自定义的 myJoiStrict 属性
delete options.myJoiStrict
// 用户指定了什么 schemas,就应该校验什么样的数据,这里的schemas是一个对象,属性只能从['params', 'query', 'body', 'headers']选择,
// 属性值是对应的joi的schema规则 如:下面的对象是需要验证请求体,query里的参数,name和email两个的数据验证
// {
// query: Joi.object({
// name: Joi.string().required(),
// email: Joi.string().email()
// })
// }
return function (req, res, next) {
// 验证各来源数据
['params', 'query', 'body', 'headers'].forEach(key => {
// 如果当前循环的这一项 schema 没有提供,则不执行对应的校验
if (!schemas[key]) {
return
}
// req[key] 表示获取请求参数,然后针对请求参数进行验证
const { error, value } = schemas[key].validate(req[key], options)
if (error) {
// 校验失败
throw error
} else {
// 校验成功,把校验的结果重新赋值到 req 对应的 key 上
req[key] = value
}
});
// 校验通过
next()
}
};
3.3. 编写用户相关的JOI数据验证规则schema
在根目录下创建schemas目录,并创建userSchema.js文件,编写如下代码:
const Joi = require('joi')
const logger = require('../logger');
const account = Joi.string().pattern(/^1[3-9]\d{9}$/).required().messages({
'any.required': '不能缺少手机号码',
'string.empty': '手机号不能为空',
'string.pattern.base': '手机号格式错误',
});
const password = Joi.string().required().trim().min(6).max(18).pattern(/(?=.*\d)(?=.*[a-zA-Z_])|(?=.*\d)(?=.*_)|(?=.*[a-zA-Z])(?=.*_)/)
.messages({
'any.required': '不能缺少密码',
'string.empty': '密码不能为空',
'string.min': '密码长度至少6位',
'string.max': '密码长度最多18位',
'string.pattern.base': '密码需包含数字、字母、下划线中至少两种',
}).error((err) => {
// 可以通过打印出err对象,从而知道需要messages里面那个key改成对应的中文
// logger.error(JSON.stringify(err))
return err
});
// 此处Joi.ref(key:string)中的key值,是Joi.object(obj)对应的key值而不是obj的value值
const confirmPassword = Joi.string().required().valid(Joi.ref('pwd'))
.messages({
'any.required': '不能缺少确认密码',
'string.empty': '确认密码不能为空',
'string.min': '确认密码长度至少6位',
'string.max': '确认密码长度最多18位',
'string.pattern.base': '确认密码需包含数字、字母、下划线中至少两种',
'any.only': '两次密码不一致',
});
module.exports = {
// 注册相关数据验证
register: Joi.object({
account: account,
pwd: password,
confirmPwd: confirmPassword
}),
}
3.4. 在对应接口的路由上添加基于joi自定义的数据验证中间件
在routes/users.js文件下添加如下代码
const { validateJoi } = require('../middlewares/validate')
const userSchema = require('../schemas/userSchema')
/** 用户注册接口 */
router.post('/regUser',validateJoi({ body: userSchema.register }),userController.regUser);
3.5. 提取全局错误处理成为一个单独自定义中间件文件并对joi数据验证错误进行特殊处理
在middlewares目录下,创建errorHandler.js文件,编写如下代码:
// 全局错误处理
const logger = require('../logger')
module.exports = (err, req, res, next) => {
// Joi 验证错误
if (err.name === 'ValidationError') {
const details = err.details.map(d => ({
field: d.path.join('.'),
message: d.message.replace(/"/g, '')
}));
logger.error(`${req.method} ${req.originalUrl} Joi数据验证有误:` + JSON.stringify(details))
return res.status(err.status || 400).json({
code: -1,
success: false,
message: (details && details.length > 0) ? details[0].message :'参数校验失败!',
data: details
});
}
// 其他错误
const errorMsg = err instanceof Error ? err.message : err
logger.error(`${req.method} ${req.originalUrl} ` + errorMsg)
res.status(err.status || 500).json({
code: -1,
success: false,
message: errorMsg,
data: null
})
};
修改app.js文件,把全局处理错误文件导入进来,并使用它:
使用apifox进行接口测试验证结果:
二、集成 crypto-js 依赖来进行数据加密、解密操作
crypto-js
是一个流行的 JavaScript 加密库,支持多种加密算法。以下是常用方法及代码示例:
AES 加密/解密
const CryptoJS = require("crypto-js");
// 加密
const encryptAES = (plainText, secretKey) => {
const ciphertext = CryptoJS.AES.encrypt(plainText, secretKey).toString();
return ciphertext;
};
// 解密
const decryptAES = (ciphertext, secretKey) => {
const bytes = CryptoJS.AES.decrypt(ciphertext, secretKey);
return bytes.toString(CryptoJS.enc.Utf8);
};
// 使用示例
const key = "my-secret-key-123"; // 密钥需为16/24/32字节
const encrypted = encryptAES("Hello World", key);
console.log("加密结果:", encrypted);
console.log("解密结果:", decryptAES(encrypted, key));
SHA-256 哈希
const hashSHA256 = (data) => {
return CryptoJS.SHA256(data).toString(CryptoJS.enc.Hex);
};
console.log("SHA256哈希:", hashSHA256("Hello")); // 输出16进制字符串
HMAC 签名
const hmacSHA256 = (data, key) => {
return CryptoJS.HmacSHA256(data, key).toString(CryptoJS.enc.Hex);
};
console.log("HMAC签名:", hmacSHA256("Message", "secret-key"));
DES 加密/解密
// DES加密(ECB模式)
const encryptedDES = CryptoJS.DES.encrypt("Text", "passphrase").toString();
// 解密
const decryptedDES = CryptoJS.DES.decrypt(encryptedDES, "passphrase")
.toString(CryptoJS.enc.Utf8);
加盐的 PBKDF2 密钥派生
const key = CryptoJS.PBKDF2("password", "salt", {
keySize: 256/32,
iterations: 1000
}).toString();
1.1. 在本项目中,安装 crypto-js
npm install crypto-js
1.2. 用DES对密码进行加解密,编写加解密帮助文件cryptoJSUtil.js进行统一处理
在项目根目录下创建utils目录,并创建cryptoJSUtil.js文件,编写如下代码:
const CryptoJS = require('crypto-js')
const logger = require('../logger')
const cryptoJSUtil = {
/**
* 使用密钥key对message进行【DES】加密
* @param {*} message 要加密的文字
* @param {*} key 密钥
* @returns
*/
encryptByDES: (message, key) => {
const keyHex = cryptoJSUtil.getMyDESKey(key)
const encrypted = CryptoJS.DES.encrypt(message, keyHex, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
})
return encrypted.toString();
},
/**
* 使用密匙key对密文ciphertext进行【DES】解密
* @param {*} ciphertext 密文
* @param {*} key 密钥
* @returns
*/
decryptByDES: (ciphertext, key) => {
//const keyHex = CryptoJS.enc.Utf8.parse(key)
const keyHex = cryptoJSUtil.getMyDESKey(key)
const decrypted = CryptoJS.DES.decrypt({
ciphertext: CryptoJS.enc.Base64.parse(ciphertext),
}, keyHex, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
})
return decrypted.toString(CryptoJS.enc.Utf8);
},
/**
* 根据传入的key生成DES自己的密匙
* @param {*} key
* @returns
*/
getMyDESKey: (key) => {
const buffer = Buffer.from(key);
const newBuffer = Buffer.alloc(8);
for (let i = 0; i < 8; i++) {
if (i < buffer.length) {
newBuffer[i] = buffer[i];
} else {
newBuffer[i] = 0x01;
}
}
const words = [];
for (let i = 0; i < newBuffer.length; i++) {
words[i >>> 2] |= newBuffer[i] << (24 - (i % 4) * 8);
}
const wordArray = CryptoJS.lib.WordArray.create(words, newBuffer.length);
return wordArray
},
}
module.exports = cryptoJSUtil
1.3. 使用des对注册转递过来的密码进行加密处理后再保存数据中
在controllers/user.js文件中的userController对象中的regUser方法里添加加密相关代码:
const cryptoJSUtil = require('../utils/cryptoJSUtil')
let encryptPwd = cryptoJSUtil.encryptByDES(pwd,account)
测试注册结果:
三、使用jwt相关技术完成登录接口
相关包及作用
包名 | 作用 | 安装命令 |
---|---|---|
jsonwebtoken | JWT 生成与验证 | npm install jsonwebtoken |
express-jwt | Express 中间件(自动验证 JWT) | npm install express-jwt |
生成 JWT 并设置有效期
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = process.env; // 从环境变量读取密钥
// 生成JWT(有效期为1小时)
const generateToken = (userId) => {
return jwt.sign(
{ userId },
JWT_SECRET,
{ expiresIn: '1h' } // 有效期配置
);
};
设置免认证接口
使用 express-jwt
的 unless
方法过滤路由:
const { expressjwt } = require('express-jwt');
// JWT验证中间件(跳过登录、注册等路由)
const jwtAuth = expressjwt({
secret: process.env.JWT_SECRET,
algorithms: ['HS256']
}).unless({
path: [
'/api/login',
'/api/register',
/^\/public\/.*/ // 匹配/public开头的路径
]
});
解析客户端 JWT 并获取数据
在需要认证的接口中,通过中间件注入用户信息:
// 控制器中获取用户信息
const protectedAPI = (req, res) => {
// 直接从req.auth中获取解析后的JWT数据
console.log('当前用户ID:', req.auth.userId);
res.json({ data: '受保护内容' });
};
统一处理 JWT 错误
const jwtErrorHandler = (err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
return res.status(401).json({
code: 401,
message: '无效或过期的Token'
});
}
next(err); // 其他错误传递
};
1、在项目中安装jwt相关的包
npm install jsonwebtoken express-jwt
2、添加jwt相关操作的工具类authJwtUtil.js
const jwt = require('jsonwebtoken');
require('dotenv').config();
const { expressjwt } = require('express-jwt');
const authJwtUtil = {
/**
* JWT生成Token字符串(有效期为24小时) 已经包含了以'Bearer '开头
* @param {*} userId
* @returns
*/
generateToken: (userId) => {
const tokenStr = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '24h' } // 有效期配置
)
return 'Bearer ' + tokenStr
},
/**
* 生成刷新令牌(有效期7天),已经包含了以'Bearer '开头
* @param {*} userId
*/
generateRefreshToken: (userId) => {
const tokenStr = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '7d' } // 有效期配置
)
return 'Bearer ' + tokenStr
},
/**
* JWT验证中间件(跳过登录、注册、以'/public'开头的路径等路由)
*
*/
jwtAuth: expressjwt({
secret: process.env.JWT_SECRET,
algorithms: ['HS256']
}).unless({
path: [
'/users/regUser',
'/users/login',
/^\/public\/.*/ // 匹配/public开头的路径
],
// custom: function (req) {
// let noAuth = req.query.noAuth || req.body.noAuth || req.params.noAuth;
// logger.info(`当前请求是否需要验证token,noAuth = ${noAuth},true表示不需要验证token`)
// return noAuth && !('false' == noAuth)
// }
}),
}
module.exports = authJwtUtil
3、在控制器中添加登录相关的业务处理方法
/**
* 处理post请求过来的登录业务
* @param {*} req
* @param {*} res
* @param {*} next
*/
login: async (req, res, next) => {
try {
const { account, pwd } = req.body;
let dbUser = await userInfo.findUserByAccount(account)
if (dbUser && Object.keys(dbUser).length > 0) {
let dbPassword = dbUser.pwd
if (dbPassword && dbPassword.length > 0) {
let dbPwd = cryptoJSUtil.decryptByDES(dbPassword, account)
if (dbPwd != pwd) {
throw '账号或者密码有误!'
}
// 生成排除敏感字段的对象
const { pwd: _, ...safeUser } = dbUser;
res.json({
code: 200,
message: `用户${account}登录成功!`,
data: {
...safeUser,
token: authJwtUtil.generateToken(dbUser.id)
}
})
} else {
throw '账号或者密码有误!'
}
} else {
throw '账号不存在!'
}
} catch (error) {
// 通过next,传给应用级别的错误处理方法进行统一处理
next(error)
}
},
验证前端是否收到token信息:
4、在app.js中应用jwt中间件并编写获取用户信息接口验证jwt功能
在控制器中添加获取用户信息的业务处理方法
/**
* 根据携带的token信息获取该登录用户的信息
* @param {*} req
* @param {*} res
* @param {*} next
*/
getUserInfo: async (req, res, next) => {
try {
// 直接从req.auth中获取解析后的JWT数据
const loginUserId = req.auth.userId
logger.info('当前登录用户的用户ID为:userId = 【' + loginUserId + '】')
let dbUser = await userInfo.findRecordById(loginUserId)
if (dbUser && Object.keys(dbUser).length > 0) {
// 生成排除敏感字段的对象
const { pwd: _, ...safeUser } = dbUser;
res.json({
code: 200,
message: `操作成功!`,
data: safeUser
})
} else {
throw '获取用户信息有异常!'
}
} catch (error) {
// 通过next,传给应用级别的错误处理方法进行统一处理
next(error)
}
},
在全局的错误处理中间件中,添加针对jwt错误的特殊处理
// jwt 中token验证错误
if(err.name === 'UnauthorizedError'){
return res.status(err.status || 401).json({
code: -1,
success: false,
message: '无效或过期的Token',
data: null
});
}
用apifox工具验证接口测试结果: