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

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

bash 复制代码
npm install joi

1.2. 基本验证示例

javascript 复制代码
// 基础数据验证
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. 字符串专用

javascript 复制代码
Joi.string()
  .alphanum()         // 仅允许字母数字
  .trim()             // 自动去除两端空格
  .lowercase()        // 转换为小写
  .uppercase()        // 转换为大写
  .replace(/ /g, '_') // 替换字符

2.3. 数字专用

javascript 复制代码
Joi.number()
  .integer()          // 必须为整数
  .positive()         // 正数
  .precision(2)       // 小数点后保留2位

2.4. 复杂结构

javascript 复制代码
// 数组验证
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

bash 复制代码
npm install joi

当前时间安装的joi版本是:17.13.3

3.2. 编写自定义中间件统一验证请求参数

在根目录下创建middlewares目录,并创建validate.js文件,编写如下代码:

javascript 复制代码
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文件,编写如下代码:

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文件下添加如下代码

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文件,编写如下代码:

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 加密/解密

javascript 复制代码
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 哈希

javascript 复制代码
const hashSHA256 = (data) => {
  return CryptoJS.SHA256(data).toString(CryptoJS.enc.Hex);
};

console.log("SHA256哈希:", hashSHA256("Hello")); // 输出16进制字符串

HMAC 签名

javascript 复制代码
const hmacSHA256 = (data, key) => {
  return CryptoJS.HmacSHA256(data, key).toString(CryptoJS.enc.Hex);
};

console.log("HMAC签名:", hmacSHA256("Message", "secret-key"));

DES 加密/解密

javascript 复制代码
// DES加密(ECB模式)
const encryptedDES = CryptoJS.DES.encrypt("Text", "passphrase").toString();
// 解密
const decryptedDES = CryptoJS.DES.decrypt(encryptedDES, "passphrase")
  .toString(CryptoJS.enc.Utf8);

加盐的 PBKDF2 密钥派生

javascript 复制代码
const key = CryptoJS.PBKDF2("password", "salt", {
  keySize: 256/32,
  iterations: 1000
}).toString();

1.1. 在本项目中,安装 crypto-js

bash 复制代码
npm install crypto-js

1.2. 用DES对密码进行加解密,编写加解密帮助文件cryptoJSUtil.js进行统一处理

在项目根目录下创建utils目录,并创建cryptoJSUtil.js文件,编写如下代码:

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方法里添加加密相关代码:

js 复制代码
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 并设置有效期

javascript 复制代码
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = process.env; // 从环境变量读取密钥

// 生成JWT(有效期为1小时)
const generateToken = (userId) => {
  return jwt.sign(
    { userId }, 
    JWT_SECRET, 
    { expiresIn: '1h' } // 有效期配置
  );
};

设置免认证接口

使用 express-jwtunless 方法过滤路由:

javascript 复制代码
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 并获取数据

在需要认证的接口中,通过中间件注入用户信息:

javascript 复制代码
// 控制器中获取用户信息
const protectedAPI = (req, res) => {
  // 直接从req.auth中获取解析后的JWT数据
  console.log('当前用户ID:', req.auth.userId);
  res.json({ data: '受保护内容' });
};

统一处理 JWT 错误

javascript 复制代码
const jwtErrorHandler = (err, req, res, next) => {
  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({
      code: 401,
      message: '无效或过期的Token'
    });
  }
  next(err); // 其他错误传递
};

1、在项目中安装jwt相关的包

bash 复制代码
npm install jsonwebtoken express-jwt

2、添加jwt相关操作的工具类authJwtUtil.js

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、在控制器中添加登录相关的业务处理方法

js 复制代码
    /**
     * 处理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功能


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

js 复制代码
    /**
     * 根据携带的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错误的特殊处理

js 复制代码
    // jwt 中token验证错误
    if(err.name === 'UnauthorizedError'){
        return res.status(err.status || 401).json({
            code: -1,
            success: false,
            message: '无效或过期的Token',
            data: null
        });
    }

用apifox工具验证接口测试结果:

相关推荐
yuhaiqiang12 分钟前
为什么这道初中数学题击溃了所有 AI
前端·后端·面试
djk888815 分钟前
支持手机屏幕的layui后台html模板
前端·html·layui
紫_龙17 分钟前
最新版vue3+TypeScript开发入门到实战教程之watch详解
前端·javascript·typescript
默默学前端1 小时前
ES6模板语法与字符串处理详解
前端·ecmascript·es6
lxh01131 小时前
记忆函数 II 题解
前端·javascript
我不吃饼干1 小时前
TypeScript 类型体操练习笔记(三)
前端·typescript
华仔啊1 小时前
除了防抖和节流,还有哪些 JS 性能优化手段?
前端·javascript·vue.js
CHU7290351 小时前
随时随地学新知——线上网课教学小程序前端功能详解
前端·小程序
清粥油条可乐炸鸡1 小时前
motion入门教程
前端·css·react.js
这是个栗子1 小时前
【Vue3项目】电商前台项目(四)
前端·vue.js·pinia·表单校验·面包屑导航