Вход без паролей в VOG: одна авторизация для браузера и Windows-клиента

Как VOG ушёл от коротких паролей, присланных письмом, к быстрому и надёжному входу без паролей: один поток для входа и регистрации по коду из письма, соцсети с разным набором для России и остального мира, сессии с ротацией, вход в Windows-клиент через браузер по PKCE, подтверждение входа с уже авторизованного устройства — и как вход по коду из письма стал инструментом почтовой бомбардировки

Это шестая статья серии «VOG изнутри». В статье про порядок вывода легаси среди старых проблем было и такое: зарегистрироваться или восстановить доступ можно было только ручным скриптом в базе. Здесь — чем это заменили и как подключили к трём клиентам: браузеру, старому Windows-клиенту и мобильному.

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

Честно о том, что получилось. Мы убрали необходимость придумывать пароль и хранить его в новой системе. Доступ теперь зависит от почты или связанного провайдера: код из письма, ссылка и токены сессии — тоже секреты, и главная граница доверия переехала в почтовый ящик. Старый парольный вход остаётся временным исключением для старых клиентов. Всё ниже описывает состояние на 9 октября 2026 года.

Схема в стиле журнала «Полдень»: вход в клуб настольных игр — дверь без замочной скважины, игрок получает из пневмощели «код» карточку XXX-XXX; слева под пыльным покрывалом ключи с бирками 1234, qwer, admin и табличка «пароли — в архив»; справа у глобуса две панели кнопок «Россия» и «мир»; на столе старый компьютер соединён нитью «127.0.0.1» с браузером; во врезке ноутбук с кодом и телефон с кнопкой «Подтвердить»; за окном охранник ставит шлагбаум «лимит» перед потоком писем

В статье

Как было

Раньше регистрация шла на сайте, а пароль приходил письмом. Пароли делали очень слабыми — 4–7 символов, — чтобы их было проще ввести, и хранились они в базе открытым текстом. Старый сервер сверял MD5 от пароля, присланного клиентом, с MD5 от хранимого. Потом сайт превратился в витрину для установщика, и в 2021–2025 годах завести учётную запись или вернуть доступ можно было только скриптом в базе.

За годы в базе накопилось 375 тысяч «гостевых» учётных записей вида guest.XXXXXX и много повторных регистраций на один адрес: уникальным был только ник, а почта — нет.

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

Код из письма: вход и регистрация одним потоком

Человек вводит почту, получает код, вводит его — и всё. Отдельной формы регистрации нет: если по этой почте учётной записи ещё нет, после кода человек просто выбирает ник. Исходов у проверки кода три:

  • вход — учётная запись одна;
  • выбор — на этой почте несколько учётных записей, наследство тех самых повторных регистраций;
  • регистрация — учётных записей нет.

Код — шесть знаков вида XXX-XXX из алфавита в 32 символа без похожих друг на друга 0/O и 1/I. Это около миллиарда вариантов против миллиона у шести цифр. Живёт код пять минут, попыток пять, потом он удаляется.

// generateCode returns a 6-character alphanumeric code formatted as XXX-XXX.
// Uses charset without ambiguous characters (no 0/O/1/I).
func generateCode() (string, error) {
	const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
	b := make([]byte, 6)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	for i := range b {
		b[i] = charset[b[i]%byte(len(charset))]
	}
	return string(b[:3]) + "-" + string(b[3:]), nil
}

Перекоса от остатка от деления тут нет: 256 делится на 32 нацело, и каждый знак выпадает с одинаковой вероятностью. Сравнение введённого кода с ожидаемым — за постоянное время, через hmac.Equal.

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

В вебе письмо содержит и ссылку для входа в один щелчок. Windows-клиенту и мобильным приложениям ссылка не нужна — им приходит письмо только с кодом.

Чтобы письмо дошло, его собирают аккуратно: две части, текстовая первой, тема по RFC 2047, срок действия кода в тексте, десять языков. У домена настроены SPF, DKIM и DMARC.

Соцсети: два набора по стране

Вход через соцсети работает на сайте, и провайдеров два набора. Какие из них доступны и работают, зависит от страны: в России и Беларуси одни сервисы входа привычны и доступны, в остальном мире — другие. Поэтому кнопки выбираются по стране, которую GeoIP определяет по адресу посетителя:

Откуда посетитель Кнопки
Россия, Беларусь Яндекс, VK
остальной мир Google, GitHub, Telegram, Яндекс

Правила задаются настройкой стенда строкой вида RU,BY=yandex,vk, без выката кода.

Как вход через провайдера находит учётную запись:

  • сначала по сохранённой связи «провайдер + номер пользователя у провайдера» — если человек уже входил так, учётная запись известна;
  • если связи нет — по адресу почты, который вернул провайдер. Нашлась одна учётная запись — связь создаётся, и человек входит; нашлось несколько — он выбирает, в какую войти, как и после кода из письма; не нашлось ни одной — регистрация, а связь создаётся после неё;
  • если провайдер почту не вернул (так бывает у Telegram) — сопоставлять не по чему, сразу регистрация.

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

Сессии и гонка вкладок

После входа у человека две вещи:

  • access-токен — JWT на 15 минут: кто это, ник, роли, права;
  • refresh-токен — случайная строка на 7 дней; сессия за ним лежит в Redis и продлевается при использовании.

В браузере оба лежат в httpOnly-куках: access — на весь сайт, refresh — только для пути /auth и с SameSite=Strict. Windows-клиент и мобильные приложения получают токены в теле ответа и хранят сами.

Refresh-токен меняется при каждом обновлении. Саму кражу это не предотвращает, но ограничивает повторное использование старого токена: после ротации он перестаёт действовать. С апреля по май 2026 года ротация доставила немало хлопот. У человека открыто несколько вкладок, их таймеры срабатывают одновременно, и обе приходят с одним и тем же refresh-токеном. Первая получает новую пару, вторая — отказ, и человека выбрасывает. Или, хуже, обе успевают и плодят «фантомные» сессии.

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

// refreshGracePeriod is how long a just-rotated refresh session stays
// valid after rotation. It absorbs duplicate/late refreshes (multiple
// tabs whose timers fire together, retries, in-cluster latency) so the
// loser of the race re-validates instead of being logged out.
const refreshGracePeriod = 30 * time.Second
	// …
	// Distributed lock: only one refresh per account at a time.
	acquired, lockErr := s.redis.AcquireRefreshLock(ctx, session.AccountID)
	// …
	if !acquired {
		return nil, ErrRefreshInProgress
	}
	defer s.redis.ReleaseRefreshLock(ctx, session.AccountID)

	// A concurrent winner may have rotated this token between our validate
	// and acquiring the lock — replay its successor instead of rotating again.
	if newToken, accountID, found, rotErr := s.redis.GetRotation(ctx, refreshToken); rotErr == nil && found {
		return s.successorPair(ctx, pg, newToken, accountID)
	}

Отказ ErrRefreshInProgress уходит клиенту как 429 с Retry-After: браузер ждёт указанное время и повторяет запрос, так что этот отказ не превращается в выход из учётной записи.

Чего здесь нет: обнаружения повтора. Старый токен, предъявленный после 30-секундного окна, просто получает отказ, а вся цепочка сессий при этом не отзывается. Рекомендации OAuth (RFC 9700) советуют в таком случае отзывать активный токен, и это следующий шаг.

Windows-клиент: вход через браузер

Старый Windows-клиент написан на C++ и MFC. Сначала в августе 2026 года он научился входить по коду из письма прямо в своём окне, а потом основным стал вход через браузер. Причина записана в спецификации прямо: кнопок соцсетей в MFC-окне не будет никогда, а в браузере есть всё, что есть на сайте.

Устроено это по образцу RFC 8252 — OAuth для нативных приложений:

  1. клиент придумывает секрет из 48 случайных байт и отправляет на сайт только его хеш (PKCE);
  2. поднимает слушателя на 127.0.0.1 со случайным портом, который выдаёт система;
  3. открывает в браузере страницу входа с хешем, номером порта и меткой запроса (state), которую потом сверит с вернувшейся;
  4. человек входит как обычно — кодом, через соцсеть или с другого устройства;
  5. сайт возвращает браузер на 127.0.0.1 с одноразовым кодом, который живёт 60 секунд;
  6. клиент меняет код на токены, предъявляя исходный секрет.

Адрес возврата сайт собирает сам, клиент присылает только номер порта:

// Собирает его СЕРВЕР, и от клиента приходит только номер порта. Если бы адрес
// приезжал целиком, хватило бы подделанной страницы, чтобы увести возврат с
// кодом на чужой хост.
func buildDesktopRedirect(port int, code, state string) string {
	return fmt.Sprintf("http://127.0.0.1:%d/vog-auth-callback?code=%s&state=%s",
		port, url.QueryEscape(code), url.QueryEscape(state))
}

// …
func verifyDesktopChallenge(verifier, challenge string) bool {
	if !validDesktopVerifier(verifier) || !validDesktopChallenge(challenge) {
		return false
	}
	sum := sha256.Sum256([]byte(verifier))
	got := base64.RawURLEncoding.EncodeToString(sum[:])
	return subtle.ConstantTimeCompare([]byte(got), []byte(challenge)) == 1
}

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

Почему не своя схема адресов вроде vog3x://: её может зарегистрировать любая другая программа на машине. RFC 8252 допускает оба варианта возврата, и PKCE применим к обоим; мы выбрали возврат на 127.0.0.1. PKCE при этом не делает перехват невозможным, но не даёт обменять перехваченный код без исходного секрета.

Refresh-токен клиент хранит в реестре пользователя, зашифрованным через DPAPI. На проде вход через браузер работает с 16 августа 2026 года.

Вход с уже авторизованного устройства

Код из письма неудобен там, где почты под рукой нет: на чужом компьютере, на телефоне без почтового приложения, в Windows-клиенте у человека, который не помнит, на какой адрес регистрировался. Для этого есть вход с другого устройства, на проде он с конца сентября 2026 года.

Главный вопрос был в направлении. Можно было сделать так, чтобы уже вошедшее устройство выдавало код, а новое его вводило. Это отвергли: такой код — пароль на предъявителя, его легко выманить («продиктуй мне код из приложения»). Сделали наоборот:

  1. новое устройство получает от сервера два значения: короткий код для человека и отдельный секрет для себя. Код и QR оно показывает, а секретом опрашивает сервер — одобренную сессию получит только тот, у кого секрет, а не тот, кто увидел код;
  2. уже вошедший браузер открывает страницу подтверждения, вводит код или сканирует QR;
  3. видит, кто просит вход, — устройство, страна, город; если страны не совпадают, предупреждение выделено красным;
  4. подтверждает — и новое устройство получает свою сессию.

Ограничения снижают риск, но не исключают его. Атакующий может создать запрос на своём устройстве и уговорить человека подтвердить его на настоящем сайте — этот риск у любого входа по коду с другого устройства (RFC 8628, §5.4). Против него работают показ устройства и страны, письмо о входе и ограничения ниже:

  • подтверждать может только браузер с refresh-кукой SameSite=Strict, то есть уже вошедший человек на нашем сайте: чужая страница подтверждение не отправит. От сознательного подтверждения после обмана это не защищает;
  • гость подтверждать не может;
  • сессия, которая сама получена подтверждением, первые сутки подтверждать не может — цепочку «вошёл по подтверждению и сразу подтвердил следующего» это рвёт;
  • о каждом таком входе уходит письмо;
  • запрос живёт пять минут, частота запросов и подтверждений ограничена.

Windows-клиент показывает код и QR сам. QR при этом рисуется из матрицы, которую присылает сервер: так клиенту не понадобилась новая библиотека.

Восстановление и удаление

Код восстановления — на случай, когда доступа к почте больше нет. Это 24 знака, 120 бит, в базе хранится только хеш. По нему можно сменить почту, а у прежнего владельца остаётся 14 дней, чтобы всё вернуть: письма об этом уходят на прежний адрес.

Ссылка «вернуть как было» по GET только показывает, что будет возвращено, а возвращает кнопка с POST. Причина — почтовые сканеры, которые сами открывают ссылки из писем: возврат по GET срабатывал бы раньше, чем письмо прочтёт человек.

Удаление учётной записи — тоже самостоятельно, с отсрочкой 30 дней, после которой запись обезличивается. Это требование магазинов приложений, и оно пригодилось сайту тоже.

Мост в старый сервер

Старый C++-сервер, пока он жив, тоже должен пускать игроков по новому входу. Для него появился пакет входа по JWT: клиент приносит access-токен, сервер проверяет подпись и забирает из него ник и права. Разбор этого места нашёл несколько интересных вещей:

  • MD5 пустого пароля. У «теневых» строк, заведённых для игроков, зарегистрированных уже в новой системе, пароль в базе пустой. Клиент, приславший MD5 от пустой строки, прошёл бы проверку против любой такой строки. Закрыто явной проверкой:
// Пустой profile.Password означает теневую строку (Task 10):
            // password = NULL в БД, прочитанный как "" (storage.cpp). У неё
            // по конструкции нет MD5-входа. Без этой проверки клиент,
            // приславший MD5(""), прошёл бы memcmp против ЛЮБОГО такого
            // аккаунта — ровно то свойство, которое NULL-пароль обязан
            // гарантировать. ...
            if (profile.Password[0] == '\0') {
                return false;
            }

            // if password is correct
            unsigned char md5Password[VPK_MAX_PWORD + 1];
            MD5String(profile.Password, strlen(profile.Password), md5Password);
  • Длинные ники. Старый протокол держит ник до 14 байт, а новая регистрация сначала разрешала до 64 символов. Такой ник в старом сервере переполнил бы буфер на стеке. Регистрация теперь ограничена 14 байтами — это 14 латинских букв, но только 7 кириллических в UTF-8.
  • Соединение без пинга умирало через два часа — ровно через системный таймаут keepalive, и при входе по токену это выглядело как «неверный пароль». Помогли явные настройки keepalive и прикладной пинг.

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

Когда вход по коду становится оружием

У входа по коду из письма есть неочевидная уязвимость, и она проявилась в октябре 2026 года — не на vogclub, а на соседних проектах с той же механикой входа, Khorost.Lab и khorost.space.

С 3 октября на служебный ящик посыпались отказы в доставке писем «код для входа» на чужие адреса: «принимает слишком часто», «ящик переполнен». Разбор журнала почтового сервера показал признаки подписочной бомбардировки: много разных адресов, по одному-два письма на каждый, у получателей одновременно отказы «принимает слишком часто» и «переполнен» — их бомбят отовсюду. Типичный сценарий такой атаки — завалить ящик жертвы письмами с сотен легитимных сайтов, чтобы похоронить в потоке важное, например уведомление о взломе или о списании денег. Цель конкретной атаки из наших журналов не видна, это вывод по признакам. Форма, которая шлёт код на любой введённый адрес и всегда отвечает «отправлено», — ровно то, что нужно атакующему. За пять дней через один только Khorost.Lab ушло 255 писем на 201 разный адрес, большинство — по одному письму. Настоящих пользователей среди адресатов не было.

Вред здесь двойной. Прямой — нагрузка на чужие ящики и наше невольное участие в атаке. Косвенный — риск для репутации домена: если такие письма продолжатся, почтовые сервисы начнут резать или класть в спам всю почту домена, включая коды настоящим игрокам. В Google Postmaster урона репутации пока не видно, но это риск, а не свершившийся факт.

Что сделали сразу, не трогая приложения:

  • заблокировали подсети источников на краю сети — почти весь поток шёл из нескольких сетей прокси и хостинга. Мера временная: такие сервисы меняют адреса за часы;
  • поставили сторожа на почтовом сервере: получатели, по которым пришёл отказ «перегружен», временно не получают служебных писем; коды старше часа удаляются из очереди — они уже бесполезны, а повторы бьют по тем, кто нас ограничивает; на один адрес — не больше пяти служебных писем в сутки.

Отсечь ботов на HAProxy не получилось бы: трафик проходит через него транзитом, TLS снимается только в Kubernetes, и HAProxy видит соединение, но не запрос. А лимит частоты соединений с IP бесполезен — боты медленные и распределённые. Отличить бота от человека на форме может только само приложение.

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

  • разные правила для входа и регистрации: код входа уходит только на адрес существующей учётной записи, а для незнакомого адреса — та же страница «проверьте почту», но без письма. Регистрация — с невидимой капчей, которую человек не замечает;
  • лимиты по адресу почты и по сети и суточный бюджет;
  • разные отправители для входа и регистрации, чтобы строгие правила почтового сервера применять только к регистрации;
  • журнал запросов кода, чтобы видеть новую волну не по отказам почты.

На vogclub бомбардировки пока нет, но механика та же, и мы проверили его вход на те же слабости. Конкретные найденные проблемы и то, как их закрыли, расскажем после того, как они будут устранены.

Что дальше

  • Вход через Apple — для нашего iOS-приложения планируем Sign in with Apple, чтобы выполнить требования правила 4.8 App Store, когда в приложении появится вход через сторонние сервисы. План готов.
  • Гость, ставший игроком — гостевой вход написан, но пока выключен.
  • Объединение учётных записей — тех самых повторных регистраций на одну почту.
  • Отключение старого парольного входа — когда им перестанут пользоваться.

Итог

  • Без паролей быстрее, но граница доверия — почта. Код из письма заменяет и вход, и регистрацию, и восстановление пароля, которого больше нет; старый парольный вход — временное исключение.
  • Один поток вместо двух форм. Три исхода проверки кода — вход, выбор, регистрация — закрывают и наследство повторных регистраций.
  • Соцсети — по стране. Набор провайдеров, доступных в России и вне её, разный, и это настройка, а не код.
  • Ротация refresh требует дисциплины. Без окна, блокировки и записи о ротации несколько вкладок выбрасывают человека.
  • Нативному клиенту — браузер, PKCE и 127.0.0.1. Мы выбрали возврат на 127.0.0.1; PKCE не даёт обменять перехваченный код без секрета.
  • Подтверждать вход должно старое устройство, а не выдавать код. Код от доверенного устройства — пароль на предъявителя. Обман при подтверждении это не исключает, но показ устройства, страны и письмо о входе его затрудняют.
  • Форма «пришлём код на любой адрес» — инструмент бомбардировки. Следующий шаг для всех наших проектов, включая VOG, — развести вход и регистрацию: код входа только существующим, регистрация с капчей и лимитами.

Обсуждение в Telegram

Присоединиться →

Комментарии