← Назад в блог

Почему соцвход плодит дубли аккаунтов и как это чинит таблица-замок

Опубликовано
3 мин чтения
--- просмотров

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

А потом поддержка начинает вручную склеивать дубли клиентов.

В чём сбой

Колбэк авторизации - это обычный HTTP-запрос, а обычные HTTP-запросы случаются не по одному разу. Пользователь дважды нажал кнопку подтверждения. Мобильная сеть моргнула, и браузер повторил запрос. Предзагрузчик ссылок прогрел адрес до того, как палец закончил движение. Любого из этих сценариев хватает, чтобы один колбэк пришёл дважды с разницей в миллисекунды.

Оба запроса несут один код авторизации. Оба его обменивают. Оба ищут пользователя по почте. Оба ничего не находят, потому что ни один ещё не закончил запись. Оба создают аккаунт.

В итоге у вас два клиента с одинаковым адресом почты. А если провайдер почту не отдаёт, то два аккаунта с почти одинаковыми логинами, и понять, за каким из них история заказов, нельзя.

Почему очевидная правка не помогает

Первый порыв - прикрыть запись:

if (!email_exists($email)) {
    wp_insert_user([...]);
}

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

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

Занимать код, а не пользователя

Уникален здесь не пользователь. Уникален код авторизации: провайдеры выдают его на попытку входа, и он одноразовый по замыслу.

Значит, занимать надо код, причём до всего остального:

public function acquire(string $code): bool
{
    if ($this->checkCodeIsProcessing($code)) {
        return false;          // этим входом уже кто-то занимается
    }
    $this->setCodeIsProcessing($code, true);
    return true;
}

Обработчик колбэка после этого выглядит так:

$code = $request->get_param('code');

if (!$this->acquire($code)) {
    return $this->redirectByCode($code);   // идём за победителем
}

$user = $service->authClientByCode($code);
$wp_user_id = $user->findOrCreateWpUser();

return $this->redirectByCodeAndUser($code, $wp_user_id);

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

Строка занятия хранит счётчик обращений, а не булев флаг. Удаляется она только после того, как обслужены все ожидаемые запросы. Это важно, когда провайдер законно дёргает колбэк более одного раза.

Чего это не решает

Два ограничения, о которых стоит сказать честно.

Во-первых, это не распределённая блокировка. checkCodeIsProcessing и setCodeIsProcessing остаются двумя выражениями. На одной базе с запросами в миллисекундах друг от друга окно закрывается на практике, но под настоящей конкуренцией уникальность должна обеспечивать сама база. Тогда занятием становится вставка в колонку с уникальным индексом, а пойманное нарушение ограничения работает сигналом «ты проиграл».

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

Общая форма

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

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

Открыт для работы по контракту

Я доступен для работы по контракту. Если у вас есть интересная идея проекта, запишитесь на звонок через Calendly.

Записаться на 30-минутный звонок