03、express + mysql搭建nodejs服务端项目流程(三)——使用joi、crypto-js和jwt等技术完善注册登录接口

107 阅读7分钟

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

image.png

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);

image.png

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文件,把全局处理错误文件导入进来,并使用它:

image.png

使用apifox进行接口测试验证结果:

image.png

二、集成 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)

image.png
测试注册结果:

image.png

三、使用jwt相关技术完成登录接口

相关包及作用

包名作用安装命令
jsonwebtokenJWT 生成与验证npm install jsonwebtoken
express-jwtExpress 中间件(自动验证 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-jwtunless 方法过滤路由:

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信息:

image.png

4、在app.js中应用jwt中间件并编写获取用户信息接口验证jwt功能

image.png
image.png
在控制器中添加获取用户信息的业务处理方法

    /**
     * 根据携带的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工具验证接口测试结果: image.png