Pre-save хуки в Mongoose используются для выполнения логики перед сохранением документа в базу данных. Один из наиболее распространённых сценариев — автоматическое хеширование пароля перед записью пользователя. Для этого часто применяется библиотека bcrypt.js, обеспечивающая криптографическое хеширование с солью и контролируемой стоимостью вычислений.
Для работы потребуется установить Mongoose и bcrypt.js:
npm install mongoose bcryptjs
Важный момент: именно bcryptjs используется в среде Node.js без необходимости компиляции нативных модулей, в отличие от оригинального bcrypt.
Импорт в коде:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
Структура модели обычно включает поле password, которое хранит только хешированное значение:
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
}
});
На уровне базы данных недопустимо хранить исходный пароль в открытом виде. Любая логика должна обеспечивать преобразование до сохранения.
Mongoose позволяет подключать middleware, выполняемый перед сохранением документа. Это ключевой механизм для автоматического хеширования:
userSchema.pre('save', async function (next) {
next();
});
Контекст this внутри middleware указывает на текущий
документ. Именно он используется для проверки и модификации данных.
Критически важно не перехешировать уже хешированный пароль при каждом
сохранении документа. Для этого используется метод
isModified:
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}
});
Это предотвращает повторное хеширование при обновлении других полей, например email или имени пользователя.
Основной процесс включает генерацию соли и создание хеша:
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(this.password, salt);
this.password = hashedPassword;
next();
});
Значение 10 в genSalt(10) определяет
стоимость вычислений (salt rounds). Чем выше значение, тем безопаснее
хеш, но медленнее операция.
bcrypt.js позволяет объединить генерацию соли и хеширование в одну операцию:
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) {
return next();
}
this.password = await bcrypt.hash(this.password, 10);
next();
});
В этом случае соль генерируется автоматически на основе указанного количества раундов.
Middleware должен корректно обрабатывать ошибки, иначе возможны зависания сохранения документа:
userSchema.pre('save', async function (next) {
try {
if (!this.isModified('password')) {
return next();
}
this.password = await bcrypt.hash(this.password, 10);
next();
} catch (err) {
next(err);
}
});
Любая ошибка должна передаваться через next(err), чтобы
Mongoose мог корректно прервать операцию сохранения.
Хеширование в pre-save хуке решает только часть задачи. Для проверки
пароля используется bcrypt.compare:
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
Этот метод добавляется как instance method модели и позволяет удобно проверять введённый пароль.
Pre-save хуки не срабатывают при использовании
findOneAndUpdate, updateOne и аналогичных
методов. Это ключевая проблема архитектуры.
await User.findOneAndUpdate(
{ _id: id },
{ password: 'newPassword' }
);
В этом случае хеширование не произойдёт автоматически.
Для обеспечения безопасности есть два подхода:
const user = await User.findById(id);
user.password = 'newPassword';
await user.save();
Этот вариант гарантирует срабатывание pre-save middleware.
Можно добавить pre hook для update операций:
userSchema.pre('findOneAndUpdate', async function (next) {
const update = this.getUpdate();
if (update.password) {
update.password = await bcrypt.hash(update.password, 10);
}
next();
});
Однако этот подход требует аккуратности, так как структура update
может быть вложенной ($set, $unset и
т.д.).
При сложных схемах обновлений важно учитывать, что
isModified работает только в контексте документа, но не
query middleware. Это различие часто приводит к ошибкам в
продакшн-системах.
Значение salt rounds напрямую влияет на производительность:
Выбор зависит от архитектуры системы и требований к безопасности.
Часто встречаются следующие проблемы:
Для повторного использования логики хеширования можно выделить плагин:
function passwordPlugin(schema) {
schema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
}
userSchema.plugin(passwordPlugin);
Это упрощает поддержку при наличии нескольких моделей с паролями.
При одновременных изменениях документа возможны гонки состояния.
Использование save() с оптимистичной блокировкой Mongoose
(версии документа __v) снижает риск перезаписи
неконсистентных данных.
При использовании TypeScript важно расширить интерфейс документа:
import { Document } from 'mongoose';
interface IUser extends Document {
email: string;
password: string;
comparePassword(candidate: string): Promise<boolean>;
}
Это позволяет безопасно работать с методами модели и middleware без потери типизации.
При возврате пользователя через API необходимо исключать пароль:
userSchema.methods.toJSON = function () {
const obj = this.toObject();
delete obj.password;
return obj;
};
Это не связано напрямую с bcrypt, но критично для корректной архитектуры безопасности данных.
После хеширования и проверки пароля результат обычно используется для генерации токена:
const isMatch = await user.comparePassword(password);
if (isMatch) {
const token = jwt.sign({ id: user._id }, 'secret');
}
bcrypt.js в этой цепочке обеспечивает только безопасную верификацию, не участвуя в генерации токенов.