Mongoose pre(save)钩子干扰save():邮箱验证报错原因排查
问题描述
我在开发电商项目的认证逻辑,用Mongoose结合validator校验userSchema字段,同时通过pre(save)钩子在持久化前哈希用户密码。
设置邮箱验证接口时,仅更新userSchema里的token和isVerified字段,完全没碰password相关字段,但调用接口时一直报错:
User validation failed: passwordConfirm: Please confirm your password
最后用findOneAndUpdate()绕开了问题,但想知道报错的根本原因。
相关代码
UserSchema 文件
const mongoose = require('mongoose'); const validator = require('validator'); const bcrypt = require('bcrypt'); const userSchema = new mongoose.Schema({ name: { type: String, required: [true,'Please tell us your name'] }, email: { type: String, unique: true, lowercase: true, validate: [validator.isEmail, 'Please provide a valid email'] }, phone: { type: String, }, password: { type: String, required: [true, 'Please provide a password'], minlength: 8, select: false }, passwordConfirm: { type: String, required: [true, 'Please confirm your password'], validate: { // This only works on CREATE and SAVE!!! validator: function(el) { return el === this.password; }, message: 'Passwords are not the same!' } }, isVerified: { type: Boolean, default: false }, token: String }); userSchema.pre('save', async function(next) { if(!this.isModified('password')) return next(); // Hash the password this.password = await bcrypt.hash(this.password, 10); next(); // Delete PasswordConfirm field this.passwordConfirm = undefined; }); // Instance methods // Available on all documents in this collection userSchema.methods.confirmPassword = async function (enteredPassword, dbPassword) { return await bcrypt.compare(enteredPassword, dbPassword); }; const User = mongoose.model('User', userSchema); module.exports = User;
邮箱验证文件
exports.confirmEmail = async (req, res) => { const token = req.query.token; console.log('This is the token: ', token); try { const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET); console.log('This is Decoded: ', decoded); const user = await User.findOne({ email: decoded.payload.email }); console.log('This is the user: ', user); // Checking if the token is still valid if(decoded.exp * 1000 < Date.now() || !user){ console.log('Token is expired'); res.status(401).send('Token has expired'); return; }; user.token = undefined; user.isVerified = true; await user.save(); console.log('Email verified'); return res.status(200).json({ status: 'success', message: 'Email verified' }) } catch (error) { console.log(error.message); res.status(500).json({ status:'failed', message: error.message }) } }
根本原因
问题出在passwordConfirm的必填校验和pre(save)钩子的执行顺序上:
userSchema中passwordConfirm的required约束是全局生效的——每次调用save()时都会校验该字段是否存在,不管你有没有修改它。- 用户创建完成后,
passwordConfirm字段并不会存入数据库:因为你在pre(save)钩子中把它设为undefined,但这里的执行顺序错了——你先调用next()进入校验环节,之后才设置this.passwordConfirm = undefined,导致创建时这个字段就没被持久化。 - 当你从数据库查询出用户文档时,
passwordConfirm字段根本不存在,此时调用user.save(),Mongoose会触发必填校验,发现缺少该字段,直接抛出错误。
为什么
findOneAndUpdate()能规避? Mongoose的findOneAndUpdate()默认不会触发Schema的校验规则,也不会执行pre(save)钩子(除非手动设置runValidators: true和context: 'query'),所以不会触发passwordConfirm的必填校验,自然不会报错。
优化方案
如果想继续使用save()方法,可做以下调整:
1. 修正pre(save)钩子的执行顺序
先删除passwordConfirm再调用next(),确保校验环节执行时该字段已被处理:
userSchema.pre('save', async function(next) { if(!this.isModified('password')) return next(); // Hash the password this.password = await bcrypt.hash(this.password, 10); // 先删除字段,再进入下一个环节 this.passwordConfirm = undefined; next(); });
2. 让passwordConfirm的必填约束仅在创建用户时生效
修改字段定义,通过this.isNew判断是否为新文档,只在创建时要求必填:
passwordConfirm: { type: String, required: function() { // 仅当创建新用户时才校验必填 return this.isNew; }, validate: { validator: function(el) { return el === this.password; }, message: 'Passwords are not the same!' } },
这样后续更新用户(比如邮箱验证)时,就不会触发passwordConfirm的必填校验了。
内容的提问来源于stack exchange,提问作者Gregzone
相关产品推荐
相关产品推荐

