Node.js bcrypt.compare 崩溃?Express 登录验证避坑指南
2025-03-30 20:09:18
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
的调用,就能发现几个关键问题:
-
bcrypt.compare
的第二个参数给错了!
这是最核心的错误。bcrypt.compare
的函数签名通常是这样的:bcrypt.compare(plainPassword, hash, callback)
。它需要两个字符串:一个是用户输入的明文密码 (plainPassword
),另一个是从数据库里查出来的哈希密码 (hash
)。可代码里写的是啥?
res.json(data)
。res.json()
这个方法是 Express 用来向客户端发送 JSON 响应 的,它会结束当前的请求-响应周期。把它放在这儿,等于直接把数据库查出来的原始数据(通常是个数组,里面包含对象,类似[{ password: 'hashed_password_string' }]
)作为 JSON 发回给前端了,根本没把哈希密码字符串传给bcrypt.compare
!bcrypt.compare
拿到一个不符合预期的东西,自然就出错了。正确的做法应该是从
data
里把那个哈希密码字符串提取出来。因为db.query
返回的data
是一个数组,即使只查到一条记录,也得通过索引访问,比如data[0]
,然后再取出password
字段的值:data[0].password
。 -
bcrypt.compare
回调里的错误处理方式太“刚”了!
看看这行:if (err) throw err;
。在 Express 的路由处理器里面直接throw err
是个坏主意。这会导致 Node.js 进程因为未捕获的异常而崩溃退出,也就是你看到的 "app crashed"。除非你专门配置了全局错误处理中间件,否则别这么干。更稳妥的方式是捕获错误,然后给客户端返回一个服务器错误状态码和信息,例如res.status(500).json({ message: '服务器内部错误' });
。 -
密码不匹配时没处理!
bcrypt.compare
的回调函数里,result
这个布尔值表示密码是否匹配。代码里只处理了result
为true
(匹配成功) 的情况:if (result) { return res.json("Login Successful."); }
。那如果result
是false
(密码不匹配) 呢?代码没写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.email
和req.body.password
是否存在且格式基本正确。
方案二:进阶 - 使用 Async/Await 提升可读性
回调函数(Callback)嵌套多了,代码会变得难以阅读和维护,俗称“回调地狱”。async/await
是处理异步操作的更现代、更优雅的方式。
原理与作用:
async
用在函数声明前,表示这个函数内部可以包含await
表达式。await
关键字用在 Promise 前面,它会暂停async
函数的执行,等待 Promise 被解决(resolved)或拒绝(rejected),然后恢复执行,并返回 Promise 的解决值。- 我们可以将
db.query
和bcrypt.compare
包装成返回 Promise 的形式(或者使用支持 Promise 的库,如mysql2/promise
和bcryptjs
本身可能就支持 Promise)。 - 使用
try...catch
结构来统一处理异步操作中可能出现的错误,代码逻辑更清晰。
代码示例:
首先,你需要一个支持 Promise 的数据库驱动,比如 mysql2/promise
,或者手动将 db.query
包装成 Promise。这里假设你使用了 mysql2/promise
或者类似的方法。bcryptjs
的 compare
方法原生并不直接返回 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.createPool
或mysql2.createPool
比createConnection
更好,连接池可以更有效地管理数据库连接,提高性能和稳定性。 - 细化错误处理:
try...catch
可以捕获所有类型的错误。你可以根据err
的类型或属性(比如err.code
for MySQL errors)来提供更具体的日志记录或后台报警,但对外的错误信息仍应保持通用。 - Promise 封装: 如果你不想引入
mysql2/promise
,可以自己用new Promise()
包装db.query
回调,或者使用 Node.js 内置的util.promisify
。
通过上面这些修改,不仅解决了程序崩溃的问题,还让代码更健壮、更安全、也更容易维护。处理用户认证这种敏感操作,细节决定成败!