Как отдавать магазину сменяемые коды 2FA и не разнести секрет по системе
- Опубликовано
- • 4 мин чтения•--- просмотров
Часть бизнесов выполняет заказы на аккаунтах, защищённых двухфакторной авторизацией. Перепродажа цифровых товаров - очевидный случай, но форма общая. Оператору нужно куда-то войти от имени клиента, а вход требует кода, живущего тридцать секунд.
Вручную это выглядит как человек, читающий цифры с телефона и вставляющий их в чат. Ломается предсказуемо: код истекает на полпути, два оператора одновременно лезут в один аккаунт, и никто не обслужит клиента в три часа ночи.
Первый порыв - сложить TOTP-секреты в базу магазина и генерировать коды на месте. Так делать не стоит, и вот более удачное разложение.
Секрет живёт ровно в одном месте
TOTP-секрет - это постоянные учётные данные. У кого он есть, тот генерирует валидные коды вечно и без всякого дополнительного доступа. Этим он принципиально отличается от сессии или токена, которые истекают и отзываются.
Скопировав секрет в базу магазина, вы отдали его всему, что эту базу может прочитать: плагину, резервной копии, тестовому клону, SQL-инъекции в постороннем коде, подрядчику с дампом. Все обычные риски WordPress разом повышаются в ранге до бессрочной компрометации аккаунта.
Поэтому плагин не хранит ни секретов, ни логики генерации. Он клиент выделенного сервиса:
class CustomTwoFaService extends TwoFaService
{
public function __construct()
{
$endpoint = get_option('tfa_endpoint');
$token = get_option('tfa_token');
if (empty($endpoint) || empty($token)) {
throw new Exception('2FA service endpoint or token is not set');
}
parent::__construct($endpoint, $token);
}
}
Две настройки и брошенное исключение. Магазин знает адрес и токен, секреты остаются за сервисом. Смена бэкенда превращается в правку настроек, а скомпрометированная база магазина отдаёт коды на тридцать секунд вместо аккаунтов навсегда.
Привязывать по QR, а не набором
Регистрация аккаунта - это перенос секрета, и просьба перепечатать base32-строку руками порождает обращения в поддержку. Лучше читать QR-код привязки: в браузере, через jsQR, с камеры или из загруженного скриншота.
POST /wp-json/2fa/v1/auth-user-by-qr (multipart: qr)
распознать, зарегистрировать в сервисе, вернуть { account_id, email, code }
Загруженная картинка удаляется сразу после обработки. В ней лежит секрет, поэтому пережить запрос она не должна. QR-изображение, забытое в каталоге загрузок, даёт ту же утечку, что и хранение секрета, с бонусом в виде веб-сервера, раздающего его публично.
Два способа спросить, один источник
Коды отдают два эндпоинта: по адресу аккаунта и по внутреннему идентификатору.
| Эндпоинт | Кто пользуется |
|---|---|
get-2fa-code-by-email | клиентская страница |
get-2fa-code-by-id | внутренние инструменты |
Оба читают из одного сервиса, и в этом весь смысл разделения. Альтернатива - копия данных привязки во внутреннем инструменте, то есть второе место, откуда можно утечь, и второе место, которое может устареть.
Закрыть публичный эндпоинт и понимать цену этой защиты
Запрос кода по адресу почты может попробовать кто угодно. Формы отрисовываются за проверкой на бота, и это отсекает случайный парсинг.
Но стоит трезво понимать, что именно куплено. Проверка перед формой не защищает эндпоинт, тот по-прежнему вызывается напрямую. Если он обязан быть публичным, честных вариантов три: лимит запросов на адрес, требование авторизованной сессии для поиска по адресу либо принятие того, что любой знающий адрес аккаунта читает его текущий код. Последний вариант тоже рабочий, если спроектировать остальную систему с учётом этого.
Проверять на оплате, а не на выдаче
Более тонкий выигрыш вообще не про коды. Когда заказ переходит в работу, стоит проверить, можно ли реально получить код по указанному в нём аккаунту:
if ($new_status !== 'processing') return;
if ($order->get_meta('_billing_generate_psn') == 'yes') return; // аккаунт выдаёт магазин
$psn_id = $order->get_meta('_billing_psn_id');
if (empty($psn_id)) throw new Exception('PSN ID is empty');
$code = $twoFaService->get2faCodeByEmail($psn_id);
if (empty($code['password'])) throw new Exception('2FA password is empty');
Без этого невыполнимый заказ выглядит ровно как выполнимый, пока за него не возьмётся оператор, то есть уже после списания денег. С проверкой сбой попадает в примечание к заказу в момент оплаты.
Обратите внимание, чего проверка не делает. Она не останавливает заказ. Помечать вместо блокировки правильно, пока вы ещё не знаете долю ложных срабатываний новой проверки. Блокировать заказы логикой, которая ошибается в пяти случаях из ста, дороже, чем проблема, которую она лечит.
Общее правило
Постоянные учётные данные должны жить в одной системе, а все остальные - спрашивать у неё короткоживущие ответы. Как только секрет скопирован ради удобства, безопасность каждого потребителя становится безопасностью целого. Разносить по системе надо ответ, живущий тридцать секунд, а не то, что его производит.
Открыт для работы по контракту
Я доступен для работы по контракту. Если у вас есть интересная идея проекта, запишитесь на звонок через Calendly.
Записаться на 30-минутный звонок