一次由"双重加密"引发的 Node.js 登录失败血案
1. 问题描述
在一个基于 Node.js, Express 和 MongoDB 的用户认证系统中,我遇到了一个棘手的登录问题。尽管我确信在 Postman 中使用了正确的用户名(admin)和密码(admin123),但登录接口 (/api/auth/login) 始终返回 400 Bad Request,并附带错误信息:
json
{
"code": 400,
"message": "用户名或密码错误"
}
截图如下: 
在排除了服务器未启动、端口错误、路由404等基本配置问题后,我确信问题出在登录逻辑的内部,而不是应用的基础架构上。
2. 问题分析与原因
为了弄清楚密码验证环节到底发生了什么,我采取了最直接也最有效的调试手段:在核心代码中添加日志。
我定位到了 models/User.js 文件中用于比较密码的实例方法 comparePassword,并在其中加入了详细的 console.log,以便观察输入密码和数据库中存储的哈希值。
javascript
// models/User.js
userSchema.methods.comparePassword = async function(candidatePassword) {
try {
console.log('--- 开始密码验证 ---');
console.log('输入的密码 (Candidate Password):', candidatePassword);
console.log('数据库中的哈希密码 (Stored Hash):', this.password);
const isMatch = await bcrypt.compare(candidatePassword, this.password);
console.log('密码是否匹配 (isMatch):', isMatch);
console.log('--- 密码验证结束 ---');
return isMatch;
} catch (error) {
// ...
}
};
重启服务并再次尝试登录后,我在终端看到了决定性的日志输出:
bash
--- 开始密码验证 ---
输入的密码 (Candidate Password): admin123
数据库中的哈希密码 (Stored Hash): $2a$10$xtYl0jQPCJukil5IEWS6reAJEBfQL9l.52TPhoj1BBCBmj9GFSQyi
密码是否匹配 (isMatch): false
--- 密码验证结束 ---
日志清晰地表明 isMatch 的结果是 false ,密码比对失败!
这立刻让我警觉起来:为什么用同一个原始密码,bcrypt 却认为它们不匹配?答案只有一个:数据库中存储的密码哈希是错误的。通过追溯代码,我发现了问题的根源------密码被加密了两次。
- 第一次加密: 在数据初始化脚本 scripts/initData.js 中,我为了创建管理员账号,手动对原始密码进行了哈希处理。
javascript
// scripts/initData.js
const hashedPassword = await bcrypt.hash('admin123', 10);
await User.create({
username: 'admin',
password: hashedPassword, // <- 这里传入了已加密的密码
// ...
});
- 第二次加密: 在用户模型 models/User.js 中,存在一个 Mongoose 的 pre('save') 中间件。这个中间件的职责是在任何 User 文档保存到数据库之前,自动检查 password 字段是否被修改,如果被修改,就对其进行哈希处理。
js
// models/User.js
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, salt); // <- 自动加密
next();
});
当 initData.js 脚本调用 User.create() 时,整个流程变成了:
- 脚本将 'admin123' 加密成 哈希A。
- User.create() 接收到 哈希A 作为密码
- 在数据即将存入数据库前,pre('save') 钩子被触发。
- 钩子检测到 password 字段(也就是哈希A)是"新"的,于是它把这个哈希A本身又当成一个普通字符串,进行了第二次哈希,生成了哈希B。
- 最终,存入数据库的是这个被错误加密了两次的哈希B
这就是导致登录时密码比对永远失败的根本原因。
3. 解决思路
问题的症结在于加密操作的职责不清。Mongoose 的 pre('save') 钩子是处理此类逻辑的理想场所,它能确保无论是通过哪个业务流程创建或更新用户,密码加密的逻辑都是统一且唯一的。
因此,解决思路非常明确:
移除初始化脚本中的手动加密步骤,将密码加密的职责完全交给 User 模型自己。
4. 解决方案
我修改了 scripts/initData.js 文件,不再对密码进行预处理,而是直接将明文密码传递给 User.create() 方法。
js
// scripts/initData.js
const adminPassword = 'admin123';
// 不再需要手动加密,交给 User 模型的 pre('save') 钩子处理
// const hashedPassword = await bcrypt.hash(adminPassword, 10);
await User.create({
username: 'admin',
email: 'admin@example.com',
// 直接提供原始密码
password: adminPassword,
roles: [adminRole._id],
status: 1
});
在代码修正后,执行了最后也是最关键的一步:
- 重新运行数据初始化脚本:npm run init-data,用正确加密的密码覆盖数据库中的旧数据。
- 重启服务:npm start。
- 再次登录,成功获取到了 token,问题圆满解决。
json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY4NTY1MTE0MDk1YjM1YWExMGUwNjkwNyIsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3NTA0ODczMjYsImV4cCI6MTc1MTA5MjEyNn0.avkF5iOAgp4o45dFHSijtu4tgy8J4snO3TwnzXUAbr4"
}
}
这次排错经历是一个经典的教训:在多层抽象(模型、服务、脚本)中,确保单一职责原则,避免功能重叠,是防止此类诡异 Bug 的关键。