PAYATHON 2026

用户再次登录时出现 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 是单向哈希算法,无法还原原始密码。

备注:内容仅供参考。