В нашем сервисе появились обращения, которые не складывались в картину: человек запрашивает сброс пароля, получает письмо, нажимает на ссылку и видит «ссылка недействительна или устарела». Запрашивает второй раз - то же самое. Пятый раз - то же самое.
Первое, что я проверил, - срок жизни токена. Срок - час, время на серверах синхронизировано, при генерации токен пишется в базу с отметкой времени, при переходе она сверяется. Всё сходилось. Потом я посмотрел не на создание токена, а на его использование, и увидел, что токен уже израсходован. Израсходован за несколько секунд до того, как пользователь вообще открыл письмо.
Ссылку открыл не человек
Между нашим письмом и почтовым клиентом получателя стоит защитный шлюз (он же secure email gateway - программа, через которую компания пропускает всю входящую почту). Одна из его обязанностей - проверять ссылки в письме до того, как по ним пойдёт сотрудник. Способ проверки самый прямой: шлюз сам делает запрос по каждой ссылке и смотрит, что там отдаётся.
То же самое делает антивирус на рабочем компьютере, «предпросмотр ссылок» в мессенджере, куда переслали письмо, и функция «безопасные ссылки» в почтовых сервисах. Ни одна из них не запрашивает подтверждения перед открытием. По HTTP запрос GET относится к безопасным методам (RFC 9110, раздел 9.2.1): предполагается, что он только читает и состояние сервера не меняет. На этом предположении и построены все проверяльщики ссылок.
Наша ссылка меняла. Она была GET-запросом, который сразу помечал токен использованным.
Для сервера этот запрос выглядит так же, как переход человека, открывшего письмо. Надёжного признака, по которому эти два случая можно различить, нет: современный сканер ходит через настоящий браузер, исполняет скрипты и присылает обычный User-Agent.
Почему это не лечится сроком жизни токена
Первое, что предлагают, - увеличить этот срок. Здесь важно различать два режима работы шлюзов. Одни переписывают ссылку и проверяют её в момент клика - тогда сканер приходит вместе с человеком. Другие раскрывают ссылку при доставке письма, то есть заведомо раньше человека, и вот в этом режиме срок жизни не спасает ни при каком значении. Наш случай был вторым.
Второе предложение - разрешить несколько переходов по одному токену. Это ровно то, против чего одноразовость и вводили: если по ссылке можно пройти дважды, то её ценность как секрета падает, а лежит она в почтовом ящике, к которому есть доступ у нескольких систем сразу.
Третье - фильтровать сканеры по адресам и по User-Agent. Часть поставщиков свои диапазоны публикует, но список неполон, постоянно меняется и не покрывает антивирусы, мессенджеры и почтовые сервисы получателя. User-Agent сканеры подделывают под браузер намеренно: иначе фишинговые страницы показывали бы им безобидную версию. Признак работает ровно до первой смены поставщика почты у клиента.
Разделить чтение и действие
Работающее решение простое и старое: переход по ссылке ничего не меняет, а состояние меняет только то действие, которое совершил человек.
Ссылка ведёт на форму. Форма показывает поле нового пароля и кнопку. Токен на этом шаге только проверяется на существование и на срок - и остаётся действительным. Использованным он помечается в обработчике POST, вместе с установкой нового пароля.
@app.get("/reset") def show_form(token: str): rec = find_by_hash(sha256(token)) # только чтение if not rec or rec.expired or rec.used: return render("reset_invalid.html") return render("reset_form.html", token=token) @app.post("/reset") def do_reset(token: str, password: str): rec = find_by_hash(sha256(token)) if not rec or rec.expired or rec.used: return render("reset_invalid.html") if not password_policy_ok(password): # сначала проверяем пароль, return render("reset_form.html", token=token, error=...) if not consume_token(rec.id): # потом помечаем токен использованным, return render("reset_invalid.html") # и только если он ещё не был использован - set_password(rec.user_id, password) # меняем пароль invalidate_all_tokens(rec.user_id) invalidate_all_sessions(rec.user_id) return render("reset_done.html")
Три детали в этом коде важнее самого разделения методов.
Токен ищется по хешу: в базе лежит sha256 от токена, а не он сам. Утечка таблицы тогда не даёт готовых ссылок.
Пароль проверяется до того, как токен помечен использованным. Иначе пустой или не прошедший политику пароль израсходует токен, и человек останется без входа - а заодно вернётся та самая жалоба «ссылка не работает», только уже по другой причине.
Отметка об использовании ставится условным апдейтом, а не чтением с последующей записью: UPDATE tokens SET used = true WHERE id = ? AND used = false, и consume_token возвращает, изменилась ли строка. Без этого два одновременных запроса проходят оба, и одноразовость токена существует только на бумаге.
Сканер при всём этом сделает GET, получит страницу с формой и уйдёт. Ломать на GET уже нечего.
Правило это не про сброс пароля, а про любую операцию, ссылка на которую уезжает в письмо: подтверждение адреса, приглашение в проект, отписка от рассылки, принятие приглашения в команду. Всё, что письмо предлагает «просто нажать», однажды нажмёт не человек, а программа проверки.
Что ещё аннулируется вместе с паролем
Раз уж речь зашла об обработчике, отмечу ещё два действия, про которые забывают.
Остальные токены сброса того же пользователя. Человек, у которого «не работает ссылка», нажимает кнопку пять раз, и в базе оказывается пять записей - в нашей истории израсходованных сканером, а после починки обработчика вполне живых. После успешной смены пароля живым не должен остаться ни один: иначе письмо недельной давности из почтового ящика по-прежнему открывает вход в аккаунт.
Заодно на выдачу писем сброса нужен предел частоты - иначе пять нажатий кнопки превращаются в пять писем, а автоматическое нажатие кнопки в цикле превращает вашу почтовую репутацию в чужую проблему.
Активные сессии. Смена пароля, инициированная через сброс, - это почти всегда реакция на подозрение, что доступ у кого-то ещё. Если сессии не завершены, старый вход продолжает работать, и пользователь остаётся с ощущением, что он всё починил.
Ограничения этого подхода
Первое: форма на GET показывает факт существования токена: получив чужую ссылку, можно узнать, что она ещё жива, не тратя её. При нормальной длине токена практического выигрыша это почти не даёт, но свойство такое появляется.
Отдельно - утечка через Referer. Токен теперь живёт в адресе страницы с формой, и любой внешний ресурс на этой странице - шрифт, счётчик, сборщик ошибок - получит адрес целиком в заголовке Referer. Лечится двумя способами сразу: заголовком Referrer-Policy: no-referrer на этой странице и тем, что страница формы не тянет ничего внешнего. Радикальнее - принять токен из адреса, положить его в скрытое поле формы и сразу же сделать редирект на адрес без параметров.
Ещё одно ограничение: часть шлюзов не просто открывает ссылку, а отправляет простые формы, если страница по своим признакам классифицируется как фишинговая. Встречается это редко и обычно в агрессивных режимах, но полностью исключить нельзя. Помогает то, что форма сброса требует поле пароля, а корректным значением сканер его не заполнит.
И последнее: POST не спасёт, если ссылка ведёт на страницу, которая сама шлёт запрос при загрузке - например, одностраничное приложение, которое считывает токен из адреса и сразу вызывает свой метод подтверждения. Формально это POST, фактически - тот же переход по ссылке. Смотреть надо не на метод, а на то, что происходит без участия человека.
Что посмотреть у себя
Найти все обработчики, которые меняют состояние по GET. Признак - параметр token, code, confirm, id в адресе и запись в базу в том же обработчике.
Взять из журнала свежую ссылку подтверждения адреса и открыть её в любом клиенте, который не выполняет скрипты. Если после этого токен помечен использованным - у вас та же история, и обращения пользователей по этому поводу уже приходят, просто под другой формулировкой: «письмо приходит, а ссылка не работает».
Посмотреть, сколько живых токенов сброса лежит у одного пользователя. Число больше единицы - это не ошибка сама по себе, но повод проверить, что происходит с остальными после успешной смены пароля.
Отдельно стоит посчитать, какая доля обращений в поддержку про «ссылка недействительна» приходится на корпоративные почтовые домены. У нас перекос был очевидным: на личных почтах проблема почти не встречалась, а внутри компаний с централизованной проверкой почты - постоянно. По этому перекосу диагноз ставится быстрее, чем по коду.
У нас после переноса отметки об использовании в POST обращения этого вида прекратились полностью - и это, пожалуй, самая дешёвая правка с таким эффектом, какая мне попадалась.
Radjah
А письмо со ссылкой на страницу для ввода токена и сам токен отдельной строкой — это уже усложнение для пользователя?
Что‑то типа писем с одноразовыми кодами для входа и прочего подобного, что нужно в любом случае копировать и вставлять в форму.
Ещё видел варианты в письмах "Если ссылка не кликабельна, то скопируйте её и вставьте в адресную строку браузера", но это было 3000 лет назад. :)