Mongoose: pre-save хуки для автоматического хеширования

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
  }
});

На уровне базы данных недопустимо хранить исходный пароль в открытом виде. Любая логика должна обеспечивать преобразование до сохранения.

Механизм pre-save middleware

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 или имени пользователя.

Хеширование пароля с bcryptjs

Основной процесс включает генерацию соли и создание хеша:

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 модели и позволяет удобно проверять введённый пароль.

Поведение при обновлении через findOneAndUpdate

Pre-save хуки не срабатывают при использовании findOneAndUpdate, updateOne и аналогичных методов. Это ключевая проблема архитектуры.

await User.findOneAndUpdate(
  { _id: id },
  { password: 'newPassword' }
);

В этом случае хеширование не произойдёт автоматически.

Решение проблемы обновлений

Для обеспечения безопасности есть два подхода:

Использование save()

const user = await User.findById(id);
user.password = 'newPassword';
await user.save();

Этот вариант гарантирует срабатывание pre-save middleware.

Дополнительные middleware для update

Можно добавить 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 в контексте обновлений

При сложных схемах обновлений важно учитывать, что isModified работает только в контексте документа, но не query middleware. Это различие часто приводит к ошибкам в продакшн-системах.

Оптимизация количества salt rounds

Значение salt rounds напрямую влияет на производительность:

  • 8–10 — быстрые операции, подходит для API с высокой нагрузкой
  • 12–14 — баланс безопасности и скорости
  • 16+ — высокая криптостойкость, но значительная нагрузка

Выбор зависит от архитектуры системы и требований к безопасности.

Типичные ошибки реализации

Часто встречаются следующие проблемы:

  • повторное хеширование уже хешированного пароля
  • использование update-методов без middleware
  • отсутствие обработки ошибок в async pre hooks
  • хранение plain-text пароля при обходе save()
  • несоответствие 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

При использовании 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, но критично для корректной архитектуры безопасности данных.

Использование с JWT-авторизацией

После хеширования и проверки пароля результат обычно используется для генерации токена:

const isMatch = await user.comparePassword(password);

if (isMatch) {
  const token = jwt.sign({ id: user._id }, 'secret');
}

bcrypt.js в этой цепочке обеспечивает только безопасную верификацию, не участвуя в генерации токенов.