返回

Node.js bcrypt.compare 崩溃?Express 登录验证避坑指南

mysql

Node.js + Express + bcrypt 登录验证:为啥我的 bcrypt.compare 总是崩?

刚开始用 Express 搭登录页面?踩坑是难免的,别灰心。不少人都在这儿摔过跟头:数据库连上了,明文密码也能查出来,可一换成 bcrypt 哈希密码,用 bcrypt.compare 一比对,程序立马崩溃,提示 "app crashed - waiting for file changes before starting"。

听起来是不是很耳熟?来看看下面这段出问题的代码:

// ... (前面的 express, mysql, cors, bcryptjs 引入和 app, db 设置省略) ...

app.post('/login', (req, res) => {
  const sql = 'SELECT password FROM auth_table WHERE email = ?'

  db.query(sql, req.body.email, (err, data) => {
    if (err) return res.json("Error."); // 数据库查询出错
    if (data.length > 0) { // 找到了对应的邮箱
      // 问题就出在这里 👇
      bcrypt.compare(req.body.password, res.json(data), function (err, result) {
        if (err) throw err; // 致命错误!应用会崩
        if (result) { // 密码匹配成功
          return res.json("Login Successful.");
        }
        // 注意:这里缺少了密码不匹配的情况处理
      })
    } else { // 邮箱没找到
      return res.json("Login Failed.")
    }
  })
})

// ... (app.listen 省略) ...

注释也标出来了,问题大概率就在 bcrypt.compare 那一块。为啥呢?咱们来捋一捋。

问题在哪儿?

代码崩溃,通常不是空穴来风。仔细看 bcrypt.compare 的调用,就能发现几个关键问题:

  1. bcrypt.compare 的第二个参数给错了!
    这是最核心的错误。bcrypt.compare 的函数签名通常是这样的:bcrypt.compare(plainPassword, hash, callback)。它需要两个字符串:一个是用户输入的明文密码 (plainPassword),另一个是从数据库里查出来的哈希密码 (hash)。

    可代码里写的是啥?res.json(data)res.json() 这个方法是 Express 用来向客户端发送 JSON 响应 的,它会结束当前的请求-响应周期。把它放在这儿,等于直接把数据库查出来的原始数据(通常是个数组,里面包含对象,类似 [{ password: 'hashed_password_string' }])作为 JSON 发回给前端了,根本没把哈希密码字符串传给 bcrypt.comparebcrypt.compare 拿到一个不符合预期的东西,自然就出错了。

    正确的做法应该是从 data 里把那个哈希密码字符串提取出来。因为 db.query 返回的 data 是一个数组,即使只查到一条记录,也得通过索引访问,比如 data[0],然后再取出 password 字段的值:data[0].password

  2. bcrypt.compare 回调里的错误处理方式太“刚”了!
    看看这行:if (err) throw err;。在 Express 的路由处理器里面直接 throw err 是个坏主意。这会导致 Node.js 进程因为未捕获的异常而崩溃退出,也就是你看到的 "app crashed"。除非你专门配置了全局错误处理中间件,否则别这么干。更稳妥的方式是捕获错误,然后给客户端返回一个服务器错误状态码和信息,例如 res.status(500).json({ message: '服务器内部错误' });

  3. 密码不匹配时没处理!
    bcrypt.compare 的回调函数里,result 这个布尔值表示密码是否匹配。代码里只处理了 resulttrue (匹配成功) 的情况:if (result) { return res.json("Login Successful."); }。那如果 resultfalse (密码不匹配) 呢?代码没写 else 分支,这意味着请求会在这里“卡住”,不会给客户端任何响应,最终可能导致超时。

结合这几点,就不难理解为啥应用会崩溃,或者行为不符合预期了。

解决方案

知道了问题所在,改起来就容易多了。下面提供几种解决方案,从最直接的修复到更推荐的实践。

方案一:核心修正 - 正确传递参数和处理结果

这是最直接的修改方法,解决上面分析出的核心问题。

原理与作用:

  • 从数据库查询结果 data 中正确提取哈希密码字符串 data[0].password
  • 将提取到的哈希字符串作为第二个参数传递给 bcrypt.compare
  • 修改 bcrypt.compare 回调中的错误处理,不再 throw err,而是返回 500 错误响应。
  • 添加 else 分支来处理密码不匹配 (result === false) 的情况,返回表示登录失败的响应。

代码示例:

// ... (前面的 express, mysql, cors, bcryptjs 引入和 app, db 设置省略) ...

app.post('/login', (req, res) => {
  const sql = 'SELECT password FROM auth_table WHERE email = ?';
  const userEmail = req.body.email;
  const plainPassword = req.body.password; // 用户输入的明文密码

  // 输入校验是个好习惯,这里先省略了,实际项目建议加上
  if (!userEmail || !plainPassword) {
    return res.status(400).json({ message: '邮箱和密码不能为空' });
  }

  db.query(sql, userEmail, (err, data) => {
    if (err) {
      console.error('数据库查询出错:', err); // 在服务器端记录详细错误
      return res.status(500).json({ message: '服务器内部错误,请稍后再试' });
    }

    if (data.length > 0) {
      const hashedPassword = data[0].password; // ⭐ 正确提取哈希密码

      bcrypt.compare(plainPassword, hashedPassword, function (compareErr, result) {
        if (compareErr) {
          console.error('bcrypt 比较出错:', compareErr); // 记录 bcrypt 错误
          // ⭐ 不再 throw err,返回 500 错误
          return res.status(500).json({ message: '密码校验时出错,请稍后再试' });
        }

        if (result) {
          // 密码匹配成功
          return res.json({ message: '登录成功' }); // 返回更结构化的 JSON
        } else {
          // 密码不匹配
          // ⭐ 增加处理密码不匹配的情况
          return res.status(401).json({ message: '邮箱或密码不正确' }); // 使用 401 未授权状态码
        }
      });
    } else {
      // 邮箱未找到
      // 为了安全,通常建议返回与密码错误时相同的模糊提示
      return res.status(401).json({ message: '邮箱或密码不正确' });
    }
  });
});

// ... (app.listen 省略) ...

安全建议:

  • 统一错误提示 :注意看,现在无论是“邮箱未找到”还是“密码不匹配”,都返回了相同的 "邮箱或密码不正确" 信息和 401 状态码。这是个好实践,可以防止攻击者通过不同的错误信息来判断是用户名错了还是密码错了(用户枚举攻击)。
  • 记录详细错误 :在服务器端使用 console.error 记录具体的数据库错误或 bcrypt 错误,但不要把这些敏感细节暴露给客户端。
  • 输入校验 :在处理之前,务必检查 req.body.emailreq.body.password 是否存在且格式基本正确。

方案二:进阶 - 使用 Async/Await 提升可读性

回调函数(Callback)嵌套多了,代码会变得难以阅读和维护,俗称“回调地狱”。async/await 是处理异步操作的更现代、更优雅的方式。

原理与作用:

  • async 用在函数声明前,表示这个函数内部可以包含 await 表达式。
  • await 关键字用在 Promise 前面,它会暂停 async 函数的执行,等待 Promise 被解决(resolved)或拒绝(rejected),然后恢复执行,并返回 Promise 的解决值。
  • 我们可以将 db.querybcrypt.compare 包装成返回 Promise 的形式(或者使用支持 Promise 的库,如 mysql2/promisebcryptjs 本身可能就支持 Promise)。
  • 使用 try...catch 结构来统一处理异步操作中可能出现的错误,代码逻辑更清晰。

代码示例:

首先,你需要一个支持 Promise 的数据库驱动,比如 mysql2/promise,或者手动将 db.query 包装成 Promise。这里假设你使用了 mysql2/promise 或者类似的方法。bcryptjscompare 方法原生并不直接返回 Promise,但可以简单包装一下,或者直接使用 bcrypt 库(注意不是 bcryptjs),它自带 Promise 支持。不过,我们也可以保持回调风格,或者用 util.promisify。这里为了演示 async/await 的结构,假设 bcrypt.compare 能被 await (可以通过 promisify 实现,或者 bcrypt 库原生支持):

const util = require('util');
const express = require("express");
// 推荐使用 mysql2/promise
// const mysql = require('mysql2/promise');
const mysql = require("mysql"); // 继续用老库的话,需要 promisify
const cors = require("cors");
const bcrypt = require('bcryptjs'); // bcryptjs 保持回调,需要 promisify

const app = express();
app.use(express.json());
app.use(cors());

const dbConfig = {
  host: "localhost",
  user: "useraccount",
  password: "useraccount",
  database: "users",
};

// 如果用 mysql2/promise 可以直接 createPool 或 createConnection
// const db = mysql.createPool(dbConfig);

// 如果继续用 mysql,需要手动 promisify
const connection = mysql.createConnection(dbConfig);
const query = util.promisify(connection.query).bind(connection);
const compare = util.promisify(bcrypt.compare).bind(bcrypt);


app.post('/login', async (req, res) => { // ⭐ 函数声明前加 async
  const sql = 'SELECT password FROM auth_table WHERE email = ?';
  const { email, password: plainPassword } = req.body; // 解构赋值更简洁

  if (!email || !plainPassword) {
    return res.status(400).json({ message: '邮箱和密码不能为空' });
  }

  try { // ⭐ 使用 try...catch 包裹异步操作
    // ⭐ 使用 await 等待数据库查询结果
    const data = await query(sql, email);

    if (data.length > 0) {
      const hashedPassword = data[0].password;

      // ⭐ 使用 await 等待 bcrypt 比较结果
      const result = await compare(plainPassword, hashedPassword);

      if (result) {
        // 密码匹配成功
        return res.json({ message: '登录成功' });
      } else {
        // 密码不匹配
        return res.status(401).json({ message: '邮箱或密码不正确' });
      }
    } else {
      // 邮箱未找到
      return res.status(401).json({ message: '邮箱或密码不正确' });
    }
  } catch (err) { // ⭐ 统一捕获所有 await 过程中的错误
    console.error('登录处理出错:', err); // 记录错误
    // 根据错误类型可以细化返回信息,但通常内部错误都返回 500
    if (err.code) { // 比如数据库连接错误等
      return res.status(500).json({ message: '数据库操作失败,请稍后再试' });
    } else if (err instanceof Error && err.message.includes('bcrypt')) { // 简单的判断
       return res.status(500).json({ message: '密码校验服务出错,请稍后再试' });
    }
    // 其他未知错误
    return res.status(500).json({ message: '服务器发生未知错误' });
  }
});

// ... (启动服务器逻辑) ...
connection.connect(err => { // 如果用 connection 需要先连接
  if (err) {
    console.error('数据库连接失败:', err);
    process.exit(1); // 连接失败则退出
  }
  console.log('数据库连接成功');
  app.listen(8081, () => {
    console.log("服务监听在 8081 端口...");
  });
});

进阶使用技巧:

  • 数据库连接池: 对于生产环境的应用,使用 mysql.createPoolmysql2.createPoolcreateConnection 更好,连接池可以更有效地管理数据库连接,提高性能和稳定性。
  • 细化错误处理: try...catch 可以捕获所有类型的错误。你可以根据 err 的类型或属性(比如 err.code for MySQL errors)来提供更具体的日志记录或后台报警,但对外的错误信息仍应保持通用。
  • Promise 封装: 如果你不想引入 mysql2/promise,可以自己用 new Promise() 包装 db.query 回调,或者使用 Node.js 内置的 util.promisify

通过上面这些修改,不仅解决了程序崩溃的问题,还让代码更健壮、更安全、也更容易维护。处理用户认证这种敏感操作,细节决定成败!