用户再次登录时出现 402 (Payment Required) 错误
结论
问题不在 logout,也不在 bcrypt.compareSync(),而是 pre("save") 每次保存用户时都会重新哈希 password:
if (this.password) {
this.password = bcrypt.hashSync(this.password, salt)
}
创建用户时,明文密码会被正确哈希一次。创建文章后,代码又调用了 user.save()。虽然这次只修改了 posts,中间件仍会对数据库中已有的密码哈希再次执行哈希。数据库最终保存的是“哈希值的哈希值”,原始密码当然无法通过 confirmPassword() 验证。
这也解释了为什么第一次登录正常,而用户文档再次保存后,后续登录就会提示 "incorrect password"。
修复密码重复哈希
只在密码字段新增或发生修改时执行哈希:
userSchema.pre("save", function (next) {
if (!this.isModified("password")) {
return next()
}
try {
const salt = bcrypt.genSaltSync(10)
this.password = bcrypt.hashSync(this.password, salt)
next()
} catch (err) {
next(err)
}
})
这里的:
this.isModified("password")
用于判断本次保存是否修改了密码。创建用户或修改密码时,它会返回 true;只修改 posts 等其他字段时,则返回 false。
confirmPassword 可以继续使用同步写法:
userSchema.methods.confirmPassword = function (password) {
return bcrypt.compareSync(password, this.password)
}
也可以改为异步版本,避免同步加密操作阻塞事件循环:
userSchema.methods.confirmPassword = function (password) {
return bcrypt.compare(password, this.password)
}
推荐的完整 Model 写法
const mongoose = require("mongoose")
const bcrypt = require("bcrypt")
const { Schema } = mongoose
const userSchema = new Schema(
{
username: {
type: String,
required: true
},
email: {
type: String,
required: true
},
password: {
type: String,
required: true
},
posts: [
{
type: Schema.Types.ObjectId,
ref: "Post"
}
]
},
{ timestamps: true }
)
userSchema.pre("save", async function () {
if (!this.isModified("password")) {
return
}
this.password = await bcrypt.hash(this.password, 10)
})
userSchema.methods.confirmPassword = function (password) {
return bcrypt.compare(password, this.password)
}
module.exports = mongoose.model("User", userSchema)
原代码中 email 的配置还有一个拼写错误:
reuired: true
应改为:
required: true
修正登录 Controller
认证失败不应返回 402 Payment Required,因为 402 与付款有关。账号不存在或密码错误通常统一返回 401 Unauthorized,这样也能避免泄露某个邮箱是否已经注册。
loginUser: async (req, res, next) => {
try {
const { email, password } = req.body
if (!email || !password) {
return res.status(400).json({
message: "Email and password are required"
})
}
if (!validator.isEmail(email)) {
return res.status(400).json({
message: "Invalid email"
})
}
const user = await User.findOne({ email })
if (!user) {
return res.status(401).json({
error: "Invalid email or password"
})
}
const passwordMatches = await user.confirmPassword(password)
if (!passwordMatches) {
return res.status(401).json({
error: "Invalid email or password"
})
}
return res.status(200).json({
message: "Login successful",
user
})
} catch (err) {
next(err)
}
}
同一个查询不要同时使用 await 和回调:
await User.findOne({ email }, callback)
可以使用回调,也可以像上面的示例那样直接等待查询结果,但不要混用两种方式。
修正注册流程
当前注册代码先执行 User.create(req.body),之后才检查字段和密码长度。这样一来,无效数据可能已经进入创建流程。应该先验证输入,再创建用户,同时不要把整个 req.body 直接传给数据库。
registerUser: async (req, res, next) => {
try {
const { username, email, password } = req.body
if (!username || !email || !password) {
return res.status(400).json({
message: "Username, email and password are required"
})
}
if (!validator.isEmail(email)) {
return res.status(400).json({
message: "Invalid email"
})
}
if (password.length < 6) {
return res.status(400).json({
message: "Password should be at least 6 characters"
})
}
const existingUser = await User.findOne({ email })
if (existingUser) {
return res.status(409).json({
message: "Email already registered"
})
}
const createdUser = await User.create({
username,
email,
password
})
return res.status(201).json({
user: createdUser
})
} catch (err) {
next(err)
}
}
实际项目中不应将 password 哈希返回给客户端。可以在响应前移除该字段,也可以在 Schema 中为密码设置 select: false,登录查询时再显式选取密码。
创建文章时为什么会触发问题
下面的代码先修改 posts,然后保存整个用户文档:
user.posts.push(newPost._id)
user.save()
调用 user.save() 时,所有 pre("save") 中间件都会执行。原来的密码中间件没有判断 password 是否发生变化,因此对已有的密码哈希又处理了一次。
加入 isModified("password") 后,保存文章关联时就不会再修改密码。
也可以使用原子更新来维护文章关联,避免读取并重新保存整个用户文档:
await User.findByIdAndUpdate(req.user.userId, {
$push: { posts: newPost._id }
})
需要注意,findByIdAndUpdate() 默认不会执行 save 中间件。这里没有修改密码,因此可以避开无关的密码处理。
另外,下面的响应写法有误:
return res.json(200).json({ user })
应改为:
return res.status(200).json({ user })
已经损坏的密码怎么办
修复中间件只能防止以后再次出现重复哈希,无法从“哈希值的哈希值”恢复原始密码。对于已经受影响的账号,可以采取以下方式:
- 让用户重置密码;
- 在开发环境中删除测试用户,然后重新注册;
- 如果是明确知道原始密码的测试场景,重新设置密码并保存一次。
不要尝试解密 bcrypt 哈希。bcrypt 是单向哈希算法,无法还原原始密码。
备注:内容仅供参考。
