Перейти к содержанию

Пароли и регистрация пользователей

Постоянное хранилище DeskQueue содержит таблицу пользователей, но заявки пока не связаны с подтверждённой личностью. Начнём с регистрации и проверки пароля. Пароль нужен для подтверждения знания секрета, однако исходную строку не следует хранить в базе. Вместо неё сохраним соль, параметры и результат специального вычисления. Эта глава добавляет пользователей; право изменять чужие заявки появится только после проверки токена в следующем состоянии.

Работаем с stages/16, PostgreSQL 18 и Node.js 24. Для учебной реализации используем встроенный асинхронный scrypt. Подход к регистрации отделён от старых примеров с файловым массивом пользователей: уникальность адреса обеспечивает база, а производная пароля вычисляется отдельным модулем. Получить удачный JSON-ответ регистрации ещё не значит войти в систему на все будущие запросы.

Соль и производная

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

Файл passwords.js использует фиксированные параметры N=131072, r=8, p=1, длину ключа 64 байта и предел памяти 256 МиБ. Это параметры учебного договора, а не замер скорости на вашем сервере. Их следует оценивать в своей среде вместе с ограничением числа одновременных попыток входа. API и требования к памяти описаны в документации crypto Node.js.

import { randomBytes, scrypt as rawScrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(rawScrypt);
const options = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scrypt(password, salt, 64, options);
  return `scrypt$131072$8$1$${salt.toString('hex')}$${key.toString('hex')}`;
}
export async function verifyPassword(password, encoded) {
  const parts = encoded.split('$');
  if (parts.length !== 6 || parts.slice(0, 4).join('$') !== 'scrypt$131072$8$1') return false;
  if (!/^[a-f0-9]{32}$/.test(parts[4]) || !/^[a-f0-9]{128}$/.test(parts[5])) return false;
  const expected = Buffer.from(parts[5], 'hex');
  const actual = await scrypt(password, Buffer.from(parts[4], 'hex'), 64, options);
  return timingSafeEqual(actual, expected);
}

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

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

Регистрация и уникальность

Маршрут POST /auth/register принимает email и password. Учебный договор нормализует адрес удалением крайних пробелов и приведением к нижнему регистру, затем проверяет простую допустимую форму и длину. Это выбранная политика идентификатора аккаунта, а не универсальное описание всех существующих почтовых адресов. Подтверждение владения почтой в эту серию не входит.

Пароль не обрезаем через trim: пробел может быть частью секрета. Ограничиваем длину от двенадцати до ста двадцати восьми кодовых точек и число байтов UTF-8 до пятисот двенадцати. Верхняя граница ограничивает расходы, нижняя выражает учебную политику. Нельзя внезапно применять к паролю нормализацию заголовка заявки: разные поля имеют разные договоры.

После вычисления производной выполняем параметризованную вставку в users. Публичная регистрация всегда создаёт роль requester. Если клиент передал поле role: "agent", оно не используется. Возможность получить права сотрудника не должна зависеть от свободного поля публичной формы. На читательском стенде роль сотрудника назначает администратор базы отдельным осмысленным действием.

Уникальность email проверяет UNIQUE. Предварительное чтение может улучшить сообщение, но не защищает от двух одновременных регистраций: обе способны прочитать отсутствие пользователя. Одна вставка в таком случае получит нарушение уникальности. Контроллер сопоставляет именно эту известную ситуацию с 409 и кодом email_exists. Другие ошибки базы остаются внутренними сбоями.

Проверка знания секрета

Маршрут POST /auth/login на этой ступени читает пользователя по нормализованному адресу и проверяет производную. Для отсутствующего аккаунта и неверного пароля возвращается одинаковый код invalid_credentials. Не сообщаем публично, какая часть пары оказалась неправильной. Успешный ответ пока содержит только ограниченные данные пользователя; access-токен будет добавлен в следующем уроке.

В журнал нельзя записывать тело регистрации или входа. Даже производная пароля не должна возвращаться клиенту: она нужна только серверу и базе. В успешном результате оставляем id, email и role. Исходная строка живёт в памяти обработки запроса ровно столько, сколько требуется вычислению; обычный JavaScript не позволяет обещать гарантированное мгновенное стирание всех копий.

Асинхронный scrypt освобождает главный путь JavaScript от ожидания вычисления, но расходует память и вычислительные ресурсы. Поток многочисленных попыток способен перегрузить приложение. Итоговый учебный API не реализует полную промышленную защиту входа: для публичного сервиса понадобится ограничение попыток, наблюдение и работа через HTTPS. Эти обязанности связаны с реальным размещением, а не скрыты за хешированием.

Ожидаемый результат на отдельном стенде — новая строка пользователя без исходного пароля и успешная проверка только правильного секрета. Для одинаковых паролей двух пользователей соли и производные должны различаться. Теперь можно выдать короткий токен и проверять личность каждого запроса, отдельно назначая права на операции с заявками.

Успешное создание аккаунта не подтверждает владение указанным адресом. Человек может зарегистрировать чужую строку, если нет отдельной проверки почты. Наш учебный сервис рассматривает адрес только как нормализованный идентификатор. Для публичного продукта подтверждение, восстановление доступа и изменение адреса потребуют собственных одноразовых токенов и правил срока. Они не возникают автоматически от наличия столбца email и уникального ограничения.

Также отличайте производную от шифрования. Мы не выбираем ключ, с помощью которого можно восстановить исходный пароль. Проверка повторяет вычисление с известной солью и сопоставляет результат. Именно поэтому служба поддержки не должна «посмотреть пароль пользователя». Если доступ утрачен, назначают новый секрет через отдельную подтверждённую процедуру, а не возвращают старый из базы.

Для отсутствующего пользователя вычисление может завершаться быстрее, чем для существующего. В этой минимальной ступени одинаковое сообщение не обещает одинаковую длительность ответа. Более строгий вариант применит заранее подготовленную фиктивную производную и одинаковую дорогостоящую проверку, сохранив ограничение попыток. Решение оценивают вместе с нагрузкой, потому что выравнивание также расходует ресурсы. Это полезный пример того, как удобное правило ответа и техническая граница времени являются разными задачами.

Оглавление курса

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