Это шестая статья серии «VOG изнутри». В статье про порядок вывода легаси среди старых проблем было и такое: зарегистрироваться или восстановить доступ можно было только ручным скриптом в базе. Здесь — чем это заменили и как подключили к трём клиентам: браузеру, старому Windows-клиенту и мобильному.
Цель формулировалась коротко: вход и регистрация должны быть быстрыми и надёжными. Быстрыми — значит без придумывания пароля и без лишних шагов. Надёжными — значит без слабых паролей, хранящихся у нас, и с одной учётной записью для всех клиентов.
Честно о том, что получилось. Мы убрали необходимость придумывать пароль и хранить его в новой системе. Доступ теперь зависит от почты или связанного провайдера: код из письма, ссылка и токены сессии — тоже секреты, и главная граница доверия переехала в почтовый ящик. Старый парольный вход остаётся временным исключением для старых клиентов. Всё ниже описывает состояние на 9 октября 2026 года.
В статье
- Как было
- Код из письма: вход и регистрация одним потоком
- Соцсети: два набора по стране
- Сессии и гонка вкладок
- Windows-клиент: вход через браузер
- Вход с уже авторизованного устройства
- Восстановление и удаление
- Мост в старый сервер
- Когда вход по коду становится оружием
- Что дальше
- Итог
Как было
Раньше регистрация шла на сайте, а пароль приходил письмом. Пароли делали очень слабыми — 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 для нативных приложений:
- клиент придумывает секрет из 48 случайных байт и отправляет на сайт только его хеш (PKCE);
- поднимает слушателя на
127.0.0.1со случайным портом, который выдаёт система; - открывает в браузере страницу входа с хешем, номером порта и меткой запроса (
state), которую потом сверит с вернувшейся; - человек входит как обычно — кодом, через соцсеть или с другого устройства;
- сайт возвращает браузер на
127.0.0.1с одноразовым кодом, который живёт 60 секунд; - клиент меняет код на токены, предъявляя исходный секрет.
Адрес возврата сайт собирает сам, клиент присылает только номер порта:
// Собирает его СЕРВЕР, и от клиента приходит только номер порта. Если бы адрес
// приезжал целиком, хватило бы подделанной страницы, чтобы увести возврат с
// кодом на чужой хост.
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 года.
Главный вопрос был в направлении. Можно было сделать так, чтобы уже вошедшее устройство выдавало код, а новое его вводило. Это отвергли: такой код — пароль на предъявителя, его легко выманить («продиктуй мне код из приложения»). Сделали наоборот:
- новое устройство получает от сервера два значения: короткий код для человека и отдельный секрет для себя. Код и QR оно показывает, а секретом опрашивает сервер — одобренную сессию получит только тот, у кого секрет, а не тот, кто увидел код;
- уже вошедший браузер открывает страницу подтверждения, вводит код или сканирует QR;
- видит, кто просит вход, — устройство, страна, город; если страны не совпадают, предупреждение выделено красным;
- подтверждает — и новое устройство получает свою сессию.
Ограничения снижают риск, но не исключают его. Атакующий может создать запрос на своём устройстве и уговорить человека подтвердить его на настоящем сайте — этот риск у любого входа по коду с другого устройства (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, — развести вход и регистрацию: код входа только существующим, регистрация с капчей и лимитами.
Комментарии