Восстановление пароля и письмо, которое обязано дойти

доступ≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Форма «Забыли пароль?» открывает учётку без пароля: кто откроет ссылку из письма, тот и задаст новый. Всё нужное для неё уже собрано — хеш пароля в базе («Пароли: почему хеш, соль и стоимость») и подписанный токен («Собираем токен: три части, подпись, срок, подделка»). От восстановления требуют трёх вещей: ссылка срабатывает один раз, форма не подсказывает, зарегистрирован ли адрес, а запрос не ждёт, пока думает почтовый сервер. API автора решает первое без таблицы выданных ссылок — секрет подписи выводится из текущего хеша пароля. Соберём это на настоящем HMAC, пустим настоящий nodemailer на молчащий почтовый сервер и посчитаем, чем за такое решение заплачено.

Разделы темы:·

Что меняет сброс пароля? Хеш в строке пользователя. Если секрет подписи ссылки зависит от этого хеша, ссылка проверяется, только пока хеш прежний: первый же сброс даёт новый хеш, а с ним и новый секрет, которым старую подпись уже не проверить. У автора секрет ссылки — строка из общего секрета приложения, слова password-reset и текущего хеша пароля.

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

Письмо долго не приходило, и Аня попросила ссылку второй раз. Пароль она сбрасывает по второй, а через полчаса обе ссылки пробует тот, кто нашёл их в истории. Прогноз: годны ли обе ссылки до сброса и что ответит каждый переход после него?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Гасит ссылки любая смена хеша, не только сброс: смена пароля в настройках, перехеширование на новую стоимость при входе («Строка, которая помнит свою стоимость») и даже сброс на тот же самый пароль. Соль при каждом хешировании новая, и строка хеша выходит другой. Последнее проверено на коде автора с bcryptjs: после сброса на прежний пароль ссылка, выданная до него, получает RESET_TOKEN_INVALID.

Производный секрет заодно разводит виды токенов. Токен входа подписан общим секретом, ссылка — производным от него, поэтому ссылку не предъявить вместо токена входа, а токен входа не откроет сброс: подпись не сойдётся ни в одну сторону. Это второе средство из задачи «Ссылка из письма открывает API» — разные секреты для разных видов токенов. А слово password-reset отделит этот секрет от следующего производного, если у приложения появится, скажем, ссылка подтверждения адреса.

Одноразовость — не флажок «использована», а условие, которое перестаёт выполняться после использования. Хеш пароля меняется ровно тогда, когда ссылка сделала своё дело.

Форма восстановления принимает любой адрес и потому легко становится справочником: ввёл адрес — узнал, зарегистрирован ли он. Список адресов пригодится для подбора паролей и для писем, которые притворяются вашим сервисом, а на некоторых сайтах сам факт регистрации уже тайна. OWASP в памятке Forgot Password Cheat Sheet ставит первыми два правила: одно и то же сообщение для существующих и несуществующих учёток и одинаковое время ответа. У автора forgotPassword на любой адрес отвечает { ok: true }, а для неизвестного просто не шлёт письма.

С текстом справиться легко, со временем труднее. Вход выдавал себя стоимостью хеша, и там спасала сверка с хешем-пустышкой (задача «Вход подсказывает, есть ли такой адрес»). Здесь дорогая работа другая — письмо. Отправка — это разговор по сети с почтовым сервером, который пересылает письмо дальше, с релеем: соединение, приветствие, несколько команд. И ведётся этот разговор только для зарегистрированных адресов. Ниже два обработчика, один ждёт отправки, другой нет. Релей в примере лежит: соединение висит 300 мс и рвётся. Прогноз: что увидит тот, кто вводит в форму nobody@mail.ru и anya@mail.ru?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик, который ждёт письмо, выдал регистрацию дважды: известный адрес ответил 500, неизвестный — 200, и известный отвечал дольше на всё время разговора с релеем. Исправный релей убрал бы только первую подсказку, время разговора осталось бы. Второй обработчик ответил одинаково и кодом, и временем, а неудача отправки легла в журнал сервера — туда, где её увидит тот, кому положено. Асинхронный вызов OWASP и называет первым способом выровнять время.

У автора так и сделано. forgotPassword запускает отправку через void и не ждёт её, MailService ловит ошибку sendMail сам и пишет её в журнал, а в самом forgotPassword на промис всё равно повешен .catch. Зачем второй обработчик ошибки при первом, покажет прогон в следующем разделе.

У той же формы есть второй способ навредить — завалить чужой ящик: сто просьб о сбросе, и у жертвы сто писем, об этом в памятке OWASP написано прямо. У автора два ограничения, и отвечают они на разные вопросы. Ограничитель частоты на гостевых процедурах пропускает не больше 10 вызовов в минуту на пару «процедура + адрес клиента» (Middleware tRPC вместо guard: где ломается пайплайн Nest) — он бережёт сервер. Минутная пауза на каждого пользователя бережёт его ящик: письмо ему не уйдёт чаще раза в минуту, откуда бы ни просили. И пауза молчит: второй запрос в ту же минуту получает тот же { ok: true }, что и первый.

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

Не ждать письма — не значит о нём забыть. Отправка, которую никто не ждёт, по-прежнему держит сокет и память, и держит ровно столько, сколько позволят таймауты. На разговор с релеем у nodemailer их три, и в версии 10.0.10 по умолчанию это две минуты на установку соединения (connectionTimeout), 30 секунд на приветствие сервера после неё (greetingTimeout) и десять минут простоя сокета (socketTimeout). Релей, который принял соединение и замолчал, при умолчаниях держит каждую отправку полминуты. Автор ставит 5, 5 и 10 секунд: письмо восстановления не срочное, не дозвонились — пишем в журнал.

Прогон поднимает такой релей на node:net: соединение принимает, ни байта не шлёт. Сначала отправка с одним connectionTimeout в 300 мс — таймаутом, который первым приходит в голову «от зависаний». Потом транспорт со всеми тремя таймаутами, укороченными до долей секунды, и обработчик, как у автора: письма не ждёт, ошибку ловит в .catch. И последним — тот же обработчик без .catch. Прогноз: где будет письмо через секунду в первом случае и чем кончится последний?

прогон node 26.5.0 · 15 сентября 2026
Релей настоящий — сокет node:net на свободном порту. Смотрите на вторую строку и на то, что идёт после последнего ответа
import { createServer } from 'node:net';
import { createTransport } from 'nodemailer';

// почтовый релей, который принимает соединение и молчит, — так выглядит зависший сервер
const relay = createServer(() => {});
await new Promise((resolve) => relay.listen(0, '127.0.0.1', resolve));
const { port } = relay.address();
console.log(`релей 127.0.0.1:${port}: соединение принимает, приветствия не шлёт`);

const registered = new Set(['anya@mail.ru']);
const letter = (to) => ({
  from: 'Приложение <noreply@app.local>',
  to,
  subject: 'Восстановление пароля',
  text: 'Чтобы задать новый пароль, откройте ссылку: https://app.example/reset-password?token=…',
});

// таймаут только на соединение
const connectOnly = createTransport({ host: '127.0.0.1', port, connectionTimeout: 300 });
let state = 'ещё в пути';
connectOnly.sendMail(letter('anya@mail.ru')).then(
  () => (state = 'ушло'),
  (err) => (state = `не ушло: ${err.message}`),
);
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log('только connectionTimeout: 300 — через секунду письмо', state);
connectOnly.close();

// все три таймаута, как в API автора, только короче
const transport = createTransport({
  host: '127.0.0.1',
  port,
  connectionTimeout: 300,
  greetingTimeout: 300,
  socketTimeout: 600,
});

// отправки, которых обработчик не ждёт; здесь их дожидается только печать журнала
const background = [];

function forgotPassword(email) {
  if (registered.has(email)) {
    const sending = transport
      .sendMail(letter(email))
      .catch((err) => console.log(`журнал: письмо на ${email} не ушло — ${err.code}, ${err.message}`));
    background.push(sending);
  }
  return { ok: true };
}

function forgotPasswordWithoutCatch(email) {
  if (registered.has(email)) void transport.sendMail(letter(email));
  return { ok: true };
}

for (const email of ['nobody@mail.ru', 'anya@mail.ru']) {
  console.log(`с catch, ${email} →`, forgotPassword(email));
}
await Promise.all(background);

console.log('без catch, anya@mail.ru →', forgotPasswordWithoutCatch('anya@mail.ru'));
node send.mjs

Через секунду письмо всё ещё в пути, хотя таймаут соединения 300 мс. Сработать он и не мог: соединение установилось сразу, и таймер connectionTimeout в этот момент снимается. Дальше клиент ждёт приветствия, а на это свой таймаут — те самые 30 секунд по умолчанию. От релея, который молчит после соединения, защищает greetingTimeout, от замолчавшего посреди разговора — socketTimeout. Ставить нужно все три.

Обработчик с .catch ответил обоим адресам сразу и одинаково, а когда истёк greetingTimeout, в журнале появилась строка с кодом ETIMEDOUT и причиной Greeting never received. Обработчик без .catch тоже ответил сразу, клиент получил свой { ok: true } — а потом процесс упал с кодом 1. В браузере отклонённый промис без обработчика — строка в консоли, и программа идёт дальше («Необработанное отклонение»). В Node он завершает процесс вместе со всеми чужими запросами, которые тот обслуживал, и виноват в этом запрос, давно получивший ответ. Поэтому промис, которого никто не ждёт, всегда заканчивают .catch, даже если функция внутри свои ошибки ловит. Ловит она обычно не всё: у автора try в MailService обнимает только sendMail, а транспорт создаётся до него. Да и этот try может убрать следующая правка, а цена ошибки — весь процесс.

Чем же тогда письмо «обязано дойти»? Обязательство в почте есть, и у него точная граница. По RFC 5321 релей, ответивший 250 на письмо, переданное командой DATA, принимает ответственность за доставку или пересылку письма и не вправе потерять его по пустяковой причине, а если доставить потом не выйдет, обязан отправить уведомление о неудаче. До этого ответа письмо целиком на совести приложения, и у автора оно в таком случае теряется, оставив строку в журнале. Пользователь это переживёт: подождёт минуту и попросит снова. Письмо, которое терять нельзя вовсе, отправляют иначе: запись о нём сохраняют в базу, а отдельный работник шлёт и повторяет, пока релей не ответит 250. Это снова таблица — та самая, без которой автор решил обойтись.

Транспорт у автора создаётся лениво, при первом письме, и пустой SMTP_HOST не роняет API, а включает режим разработки: письмо не отправляется, ссылка печатается в журнал. Без этого сброс не пройти на ноутбуке. Но проверки NODE_ENV у этого режима нет. Забыли переменную на сервере — и рабочие ссылки восстановления всех, кто просит сброс, по часу лежат в журнале: кто читает журнал, тот может сменить любой из этих паролей. В compose-файле автора этот режим описан так: в проде пользователи писем не получат, проверять после выката. Что ссылки в журнале рабочие, там не сказано.

Надёжнее правило из статьи о конфигурации — «Строгость по умолчанию»: ссылка в журнал — только при явном NODE_ENV=development, а в любом другом окружении пустой SMTP_HOST — отказ на старте.

Не ждать промис и не ловить его ошибку — разные решения. Первое бережёт ответ, второе роняет процесс.

Цену своего решения автор записал прямо рядом с ним:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Пройдём по пунктам. Выданные подряд ссылки равнозначны — это было видно в примере, где первая ссылка оставалась годной после выдачи второй. Таблица позволила бы правило «жива только последняя»: при выдаче новой ссылки прежние строки помечаются. Следов нет: когда просили ссылку, с какого адреса, по какой из них сменили пароль — по базе не восстановить, а это первое, что спросят при разборе угнанной учётки.

Пауза между письмами живёт в Map внутри сервиса. Перезапуск её обнуляет, а второй экземпляр API о ней не знает: на коде автора два экземпляра AuthService над одной базой отправили два письма в течение минуты там, где один экземпляр отправил бы одно. Пока экземпляр в compose один, это не беда; при масштабировании паузу придётся хранить там же, где всё общее для экземпляров.

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

Предпоследняя строка таблицы не про таблицу. Одноразовость у автора держится на том, что после перехода меняется хеш. Но чтение хеша, которым проверяется ссылка, и запись нового — два разных запроса к базе, а между ними считается bcrypt, около 80 мс. Два перехода по одной ссылке, пришедшие в этот промежуток, оба проверят её по старому хешу. На коде автора с базой-заглушкой оба одновременных сброса получили { ok: true }, а в базе остался только один из двух паролей — хотя письмо обещает, что ссылка «срабатывает один раз». Как закрыть эту щель одним условием в UPDATE, — в первой задаче.

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

Отсутствие таблицы — не бесплатная простота, а список того, чего у системы нет. Хорошо, когда этот список записан рядом с кодом, как у автора.

  • Ссылка восстановления — временный пароль: кто её открыл, тот и задаёт новый. Токен с подписью и сроком её не заменяет: верная подпись верна до exp, и такая ссылка срабатывает сколько угодно раз.
  • Одноразовость без таблицы: секрет подписи ссылки выводится из общего секрета и текущего хеша пароля. Любая смена хеша — сброс, смена пароля, перехеширование, даже тот же пароль с новой солью — гасит все выданные ссылки разом.
  • Форма восстановления отвечает всем одинаково: текстом, кодом, временем и на паузе между письмами. Письма обработчик не ждёт — ожидание выдаёт регистрацию временем разговора с релеем и ошибкой при его сбое.
  • Промис отправки, которого никто не ждёт, всё равно заканчивают .catch: в Node необработанное отклонение завершает процесс со всеми чужими запросами.
  • У nodemailer три таймаута разговора с релеем — соединение, приветствие и простой сокета, по умолчанию 2 минуты, 30 секунд и 10 минут; релей, замолчавший после соединения, ловит только greetingTimeout. За письмо отвечает релей с ответа 250 после DATA, до него — приложение.
  • Без таблицы ссылки равнозначны, следов выдачи нет, а пауза живёт в памяти процесса. Одноразовости нужна условная запись хеша, а токены входа сброс пароля переживают, пока проверка личности не сравнит их со временем смены пароля.

1/4Аня запросила ссылку и сбросила пароль по ней, задав тот же пароль, что был. Сработает ли в течение часа другая ссылка, выданная ей до сброса?

2/4Релей принял соединение и молчит. Письмо отправляют с одним connectionTimeout: 300. Что с отправкой через секунду?

3/4Почему forgotPassword не дожидается отправки письма?

4/4Пауза между письмами хранится в Map внутри сервиса, а экземпляров API за балансировщиком два. Аня дважды за минуту просит ссылку. Сколько писем уйдёт?

Ссылка сработала дважды

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Чтение строки и запись нового хеша разнесены во времени: между ними проверка подписи и PBKDF2 на 100 000 итераций. Оба перехода успевают прочитать строку и проверить ссылку по старому хешу, и оба пишут. В базе остаётся пароль того, чей хеш посчитался позже, а второй человек уверен, что пароль сменил он. У автора то же самое, только вместо PBKDF2 — bcrypt, а вместо копии — строка из Postgres.

Раз одноразовость держится на хеше, запись должна проверить, что хеш всё ещё тот, по которому проверяли ссылку. Это обновление с условием:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

и в resetPassword вместо безусловной записи:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: ответов «пароль сменён»: 1 и из двух новых паролей подходит: 1. Кто из двоих получит отказ, решает то, чей хеш посчитается раньше, но теперь отказ честный: пароль сменён ровно один раз, и второй об этом знает.

В модели сравнение и запись идут без await между ними, в базе — одним оператором UPDATE … WHERE, и потому второй не вклинится. Ноль изменённых строк у такого обновления — не ошибка, а проигранная гонка; подробно об этом — «Транзакция, откат и гонка: собираем CAS-обновление». В репозитории автора запись идёт через Drizzle, и сегодня setPassword обновляет строку по одному id: условие на прежний хеш встанет в нём в тот же where, рядом с условием на id.

Пауза между письмами подсказывает

В форму восстановления добавили паузу: второе письмо на тот же адрес — не раньше чем через минуту, и пользователю честно объясняют, почему письма нет. Скрипт перебирает адреса и о каждом просит дважды. Посмотрите, что он узнал, и почините так, чтобы зарегистрированный адрес по ответу было не отличить, а пауза продолжала работать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сообщение о паузе получают только зарегистрированные адреса: у незнакомого паузы не бывает, потому что письма не было. Второй запрос превратил паузу в проверку регистрации — ровно то, что старательно скрывал первый ответ. Пауза должна молчать:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: оба адреса получают Если адрес зарегистрирован, письмо уже в пути, а писем ушло: 1 — пауза на месте. У автора так и сделано: второй запрос в ту же минуту получает тот же { ok: true }.

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

Ссылка из письма ведёт на чужой сайт

Чтобы ссылка в письме была верной и на стенде, и в проде, адрес сайта берут из запроса. Посмотрите, какие письма ушли Ане после двух запросов, объясните, чем опасно второе, хотя и письмо, и токен в нём настоящие, и почините.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заголовок Host пишет клиент. Скрипт злоумышленника просит сброс для Ани и подставляет свой домен — и Аня получает настоящее письмо с вашего сервера, с настоящим токеном, только ссылка ведёт на attacker.example. Стоит нажать, и токен уедет злоумышленнику в строке адреса, а у того будет час, чтобы задать пароль на настоящем сайте. Подозрений письмо не вызывает: отправитель ваш, текст ваш, а просила ли Аня сброс, она могла и не помнить.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В forgotPassword вызов становится resetUrl("токен-из-письма"), и оба письма ведут на https://app.example. У автора так и есть: основа ссылки — APP_URL из окружения. OWASP в памятке о восстановлении пароля требует того же: адрес в ссылке задан заранее или сверен со списком своих доменов, но не взят из Host. Отсечёт ли такой запрос обратный прокси, зависит от его настройки, и код на это полагаться не должен.