Регистрация и хеширование паролей

Пароль нельзя хранить в открытом виде — только хеш с солью через bcrypt или argon2.
«Если базу украдут, открытые пароли — катастрофа. Хеш с солью превращает кражу в бесполезный набор символов.»

Регистрация — первая по-настоящему ответственная часть бэкенда. Здесь нельзя ошибиться: пароли пользователей нужно хранить так, чтобы даже при утечке базы их нельзя было восстановить. В этом уроке разберём хеширование, соль и почему bcrypt лучше простого хеша.

Почему нельзя хранить пароль

Если хранить пароли в открытом виде, любая утечка базы раскрывает их все. Хеширование — односторонняя функция: из пароля легко получить хеш, но из хеша пароль не восстановить. При входе мы хешируем введённый пароль и сравниваем с сохранённым хешем.

Соль против радужных таблиц

Простой хеш уязвим: одинаковые пароли дают одинаковый хеш, и атакующий использует заранее посчитанные таблицы (радужные таблицы). Соль — случайная добавка к каждому паролю — ломает этот приём:

без соли:   hash('123456') = a1b2c3   (всегда одинаково -> взлом по таблице)
с солью:    hash('123456' + 'x9f2') = z8y7   (у каждого свой)
            hash('123456' + 'k1m4') = q3w2   (тот же пароль -- другой хеш)

bcrypt делает это автоматически: генерирует соль, встраивает её в результат и намеренно работает медленно, чтобы перебор был дорогим.

Регистрация с bcrypt

const bcrypt = require('bcrypt');

app.post('/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: 'нужны email и password' });
  }
  // 10 -- 'стоимость': чем больше, тем медленнее и безопаснее
  const hash = await bcrypt.hash(password, 10);
  const user = await db.user.create({ email, passwordHash: hash });
  res.status(201).json({ id: user.id, email: user.email });
  // НИКОГДА не возвращаем passwordHash!
});

Проверка пароля при входе

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.user.findByEmail(email);
  // одинаковый ответ, есть юзер или нет -- не подсказываем атакующему
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'неверные данные' });
  }
  res.json({ message: 'вход выполнен' });
});

Сравнение хешей в браузере

Смоделируем идею хеширования с солью простой (учебной!) функцией. Настоящий bcrypt считает иначе, но принцип "пароль + соль = детерминированный отпечаток" тот же:

// УЧЕБНАЯ хеш-функция (не для реального использования!)
function toyHash(str) {
  let h = 0;
  for (let i = 0; i < str.length; i++) {
    h = (h * 31 + str.charCodeAt(i)) % 1000000;
  }
  return h;
}

const salt = 'x9f2';
const stored = toyHash('123456' + salt);  // сохранили при регистрации

function check(input) {
  return toyHash(input + salt) === stored;
}

console.log(check('wrong'));   // false
console.log(check('123456'));  // true

Как работает под капотом

bcrypt — это не просто хеш, а алгоритм с настраиваемой "стоимостью" (cost factor). Чем выше число, тем больше раундов вычислений и тем медленнее хеширование. Это сделано специально: для одного входа задержка незаметна, но для перебора миллионов вариантов — непреодолима. Соль bcrypt генерирует сам и хранит прямо в строке хеша, поэтому отдельное поле для неё не нужно.

Частые ошибки

  • Хранить пароль или его обратимое шифрование. Только односторонний хеш — и только bcrypt/argon2, не md5/sha1.
  • Возвращать хеш клиенту. Формируй ответ явно, исключая поле пароля.
  • Разные ответы для "нет юзера" и "неверный пароль". Это подсказывает атакующему существование email. Отвечай одинаково.

Best practices

  • Используй bcrypt (cost 10-12) или argon2id для хеширования паролей.
  • Никогда не логируй и не возвращай пароли и хеши.
  • Валидируй силу пароля при регистрации и используй HTTPS.

Итоги

Пароли хранят только как хеш с солью, и bcrypt делает это правильно и медленно — назло переборщикам. Регистрация и вход проверяют пароль через bcrypt.compare. Но после входа нужно как-то помнить, что пользователь авторизован, — этим займёмся в следующем уроке про JWT.

Тайминг и утечки по времени

Тонкий момент безопасности входа — атаки по времени ответа. Если для несуществующего email сервер отвечает мгновенно, а для существующего тратит время на bcrypt.compare, атакующий по задержке угадывает, какие адреса зарегистрированы. Грамотные реализации выравнивают время: сравнивают пароль даже для несуществующего пользователя с фиктивным хешем, чтобы ветви "нет юзера" и "неверный пароль" занимали примерно одинаковое время и давали одинаковый ответ. Это та степень аккуратности, которая отличает учебную форму входа от боевой. Хорошая новость: bcrypt и связка с единообразным 401-ответом уже закрывают большую часть подобных утечек.

Проверьте себя
1. Зачем bcrypt добавляет к паролю соль?
AЧтобы ускорить хеширование
BЧтобы одинаковые пароли давали разные хеши и не работали радужные таблицы
CЧтобы пароль можно было расшифровать
DЧтобы уменьшить длину хеша
2. Почему bcrypt намеренно работает медленно?
AИз-за устаревшего кода
BЧтобы массовый перебор паролей был вычислительно дорогим
CЧтобы экономить память
DЭто случайный побочный эффект