Обычно требования к аутентификации такие: нужно защитить информацию пользователей, обеспечить надежное шифрование учетных данных и легкий вход в систему. Со временем и ростом сервиса возникает необходимость переосмыслить механизмы аутентификации с учетом конкретной ситуации.
Меня зовут Роман Литвинов, я разработчик в команде Учи.ру. Хочу вам рассказать именно о такой истории из практики и о нашем сервисе под названием Butler, о «дворецком», через которого проходит каждый пользователь, прежде чем зайти на платформу.
Итак, овсянка, сэр.
По мере роста популярности платформы система аутентификации перестала соответствовать нашим требованиям, как и некоторые другие части нашей архитектуры. Когда весной 2020 года в базе Учи.ру стало больше 11 млн активных пользователей (8 млн учеников, примерно 350 тыс. учителей и около 3,5 млн родителей), существующая реализация стала медленной, непрозрачной и потребляла слишком много памяти. Мы решили ее обновить.
Поставили перед собой следующие цели:
повысить производительность;
внедрить новые технологии защиты учетных данных;
выделить аутентификацию из монолита в микросервис;
подключить мобильное приложение.
При этом хотелось иметь возможность поддерживать функции, которые до этого не реализовывались.
В список новых функций попали:
принудительное отключение пользователя;
блокировка аккаунта;
верификация;
шифрование паролей учеников;
поддержка jwt-токенов и двухфакторной аутентификации;
интеграция с социальными сетями;
обеспечение единой точки входа для множества сервисов с защитой от брутинга.
Сложности работы с таблицами. Роли пользователей
Долгая аутентификация
На Учи.ру заходят разные пользователи: ученики, их родители, учителя (в числе которых есть воспитатели или завучи), а также админы. Изначально для каждой из этих категорий пользователей была сформирована отдельная таблица. Поскольку форма аутентификации была единой, каждый раз поиск пользователя проходил по всем таблицам. Но когда пользователей стало почти в полтора раза больше, это стало занимать ощутимое время, до 2–3 секунд.
Проблема уникальности почты и логина
Если родитель одновременно является учителем и хочет зарегистрироваться на Учи.ру, он должен завести две учетные записи. Это значит, что его e-mail будет числиться в двух разных таблицах. Та же история может происходить, если один человек является и сотрудником, и родителем или и сотрудником, и учителем и т. д. Если e-mail один, то сложно определить, какого именно пользователя нужно аутентифицировать.
Чтобы упростить процесс начала работы с системой, детям из одного класса выдают общий логин, привязанный к номеру школы. То есть у ребят в одной школе отличаются только пароли.
Теперь мы используем Butler
Чтобы исправить ситуацию, мы решили вынести аутентификацию в отдельный сервис. Для этого был создан Butler. Саму аутентификацию было решено проводить на базе открытой библиотеки Ruby под названием rodauth. Ее, конечно, пришлось немного доработать, но в целом решение подходило. Параллельно с улучшением своего продукта мы внесли небольшой вклад в развитие open source-сообщества.
Любое нормальное решение в области аутентификации подразумевает наличие единого поля, которое позволяет однозначно идентифицировать пользователя. В нашем случае это не могли быть логины, почта и телефон. Поэтому пришлось создать для учеников дополнительное поле, stud_hush — комбинацию логина и пароля в виде хеш-суммы.
Всех пользователей перенесли из нескольких таблиц в единую базу данных. Принадлежность к разным таблицам в прошлом позволила определить роли и записать их в отдельный столбец.
На базе rodauth был организован сервис единого входа, для работы которого мы стали выдавать пользователям токены. В случае успешной авторизации каждый пользователь получает пару ключей: один содержит данные о сессии, второй отвечает за хранение информации, необходимой для перевыпуска первого.
Но нужно было заставить такое решение работать с оставшейся частью монолита. Для этого пришлось формировать дополнительный запрос от имени пользователя. То есть потребовалась дополнительная аутентификационная прослойка, которая посредством синхронного взаимодействия запрашивает разрешение на получение доступа, используя имеющиеся у пользователя данные учетной записи, и запоминает его с помощью выставления куки.
Небольшой тюнинг решения
Конечно, нам пришлось изменить настройки окружения. Например, для web-версии системы срок действия токена был выставлен небольшим — около 30 минут. Потому что иначе, если ученик забудет разлогиниться на компьютере в школе, то его учетной записью может воспользоваться кто-то из другого класса на следующих уроках.
К тому же у подразделения мобильной разработки был запрос сделать пару токенов с временем жизни в 14 дней, потому что от пользователя на мобильном устройстве скрыты реквизиты доступа (процесс логина происходит сложнее). Да и с одного смартфона, как правило, в систему заходит один и тот же человек. А у детей младшего возраста иногда бывают сложности со входом.
К счастью, благодаря невероятно гибкой модульной системе, оказалось, что во фреймворке rodauth изначально имелась поддержка множества параметров конфигурации, которые выбираются по определенным входным данным. Это избавило нас от необходимости поднимать несколько экземпляров сервиса с разными настройками, ведь можно было установить свое время жизни для различных сервисов.
Обновление — камень преткновения
Обновлять исходный код open-source компонента нужно аккуратно, потому что в открытых проектах вопросы обратной совместимости не всегда оказываются решены полностью. Так, нам нужно было установить обновление, которое требовалось для внедрения очередной фичи. Но с этим обновлением возникал обязательный параметр связи между access-токеном и refresh-токеном. Раньше он был необязательным и от нашего внимания ускользнул. Это значит, что все выданные токены перестали бы работать разом, если бы мы накатили это обновление. То есть, если бы мы сразу обновили всю систему, это привело бы к сбросу всех активных сессий.
Согласно бизнес-требованиям о недопустимости такого поведения было решено провести плавную миграцию токенов. Переходный период составлял 1,5 месяца, то есть чуть дольше, чем максимальное время жизни одного из токенов. На протяжении этого времени в системе работали как связанные токены, так и несвязанные. Такой функции платформа не предоставляла — пришлось дописывать самостоятельно.
Криптография и защита от брутинга
При хранении логинов и паролей в новой базе данных в зашифрованном виде дополнительным «тормозом» стал один из наиболее используемых алгоритмов в экосистеме — Ruby BCrypt.
Его реализация на Ruby отнимала слишком много процессорного времени, затрачивая 250 мс на создание хеша со стандартным костом, равным 12. Зачастую подобную операцию необходимо проводить дважды в течение цикла «вопрос-ответ», поскольку требуется проверка на использование этого же пароля пользователем прежде. Вкупе со множественным использованием колбеков в монолите ситуация стала приводить к большому времени ожидания внутри транзакций БД (idle in transaction), что начало сказываться на производительности системы. На этих самых колбеках висело слишком много бизнес-логики, что не позволило свести проблему к простому рефакторингу.
Выходом из положения стала смена алгоритма шифрования на более гибкий, который поддерживает конфигурацию как по затрачиваемой памяти, так и временному ресурсу. Мы взяли открытый алгоритм Argon2 и постепенно мигрировали пользователей с одного на второй, благо в rodauth имелись зачатки функционала для осуществления плавного перехода. Для этого потребовалось внести изменения в саму библиотеку rodauth. К счастью, Jeremy Evans — ее автор — оказался очень открытым человеком, пошел нам навстречу.
В сервисе rodauth также нашлось встроенное решение по защите от брутинга паролей. После определенного количества попыток аккаунт блокируется на некоторое время. Разблокировку можно провести за счет подтверждения через e-mail или вручную.
Перенос аккаунтов
Данные о пользователях были разнесены по нескольким таблицам для каждой роли. Их все потребовалось уместить в одну базу данных, чтобы гарантировать уникальность логина (будь то почта или телефон), а также упростить процесс выбора нужной записи.
В процессе миграции были обнаружены множественные записи с «грязными» данными, которые содержат повторы и не соответствуют RFC. Например, пользователи ошибались при введении почты или делали это намеренно.
Важным пунктом миграции стала необходимость не завершать активные сессии пользователей и не менять данные их учетных записей, то есть пароли нельзя было сбрасывать. К счастью, зная salt и cost-factor, можно просто перенести хеш без потери функциональности.
Чтобы исторические данные могли использовать аналитики, нужно было поправить вручную все «грязные» записи. Так, мы подчистили явно некорректные адреса электронных почт, в отдельных случаях постарались исправить в них ошибки, а заведомо неправильные элементы просто удалили. В некоторых аккаунтах пришлось разделить имя и фамилию на разные поля, в других — поправить роли или скорректировать данные о прикреплении к школе. В итоге удалось сохранить историю регистраций наших пользователей для дальнейшего изучения и бизнес-аналитики.
Результаты
В итоге нам удалось оторвать от монолита кусочек, снизив его требования к производительности, и запустить современный сервис аутентификации. Разработчикам теперь стало немного сложнее: они не могут просто загрузить себе копию монолита и проверять свои гипотезы. Теперь вместе с монолитом нужно запускать Butler, чтобы хотя бы первый раз пройти аутентификацию. Но я уверен, что все мы к этому легко привыкнем.
Главный плюс же в том, что мы получили внимательного «дворецкого»: полноценный быстрый и безопасный сервис аутентификации с единой точкой входа. При этом он так же работает на базе Ruby, как и основная часть системы Учи.ру. Теперь процессы аутентификации происходят быстрее в десятки раз, а памяти нужно на это примерно в 5 раз меньше, чем раньше.