Третий лишний: как мы реализовали сбор почты с использованием OAuth 2.0

e2ec7f0fe147405286c37688a589536c.jpg

«Может тебе еще и ключ от квартиры, где деньги лежат?» — примерно так выглядит нормальная реакция человека, у которого посторонний сервис требует пароль от основной почты. Тем не менее, большинству из нас регулярно приходится сообщать пароль сторонним сервисам. Сегодня я хочу рассказать о том, как мы реализовали процедуру авторизации при сборе писем с наших ящиков через OAuth 2.0, тем самым избавив пользователей Mail.Ru от необходимости доверять «ключи» от своей почты третьей стороне.
Обычно при настройке сборщика почты, почтового клиента или стороннего мобильного приложения нужно вводить имя, адрес ящика и пароль. Самое неприятное в этой процедуре — ввод пароля. Если вы заботитесь о безопасности, вы специально придумали сложный пароль для этого почтового ящика и вводили его только на сайте сервиса. А сейчас вам приходится доверять пароль третьей стороне, которая будет его хранить и передавать по сети. Если с передачей все не так страшно (Почта Mail.Ru поддерживает SSL-передачу данных для IMAP-протокола), то хранение пароля может быть опасно. В каком виде хранится пароль? Могут ли его украсть? Может ли кто-то посторонний читать почту? И только ли к почте получает доступ сторонний сервис? Не удалит ли он случайно, скажем, файлы из облака? Пользователи часто задаются подобными вопросами.

Избежать хранения пароля на сервере стороннего ресурса можно. Решение очевидное: предоставить всем желающим возможность работы через OAuth 2.0 при сборе почты с Mail.Ru по протоколу IMAP на ящики других почтовых провайдеров, а также при взаимодействии с почтовыми клиентами и сторонними мобильными приложениями. И мы этот шаг сделали. А теперь обо всем по порядку.


Что в общих чертах представляет собой OAuth? Полная спецификация протокола описана в RFC 6749. Существует более одного варианта авторизации. Например, мобильное приложение получает доступ к ресурсу несколько иначе, чем веб-приложение или устройство. Мы же для простоты изложения ограничимся частным случаем веб-приложения.

В OAuth существует несколько ролей.

Resource owner (владелец ресурса) — это пользователь, который хочет, чтобы ваше приложение могло выполнять действия от его имени.

Resource server — сервер, который обслуживает то, чем владеет resource owner (например, resource server-ом может быть почтовый сервер, где размещен ящик пользователя).

Authorization server — сервер, который со стороны OAuth-провайдера занимается авторизацией. В самом простом случае authorization сервер и resource сервер — это одно и то же, по крайней мере, с точки зрения внешнего мира.

Client — в терминологии OAuth это веб-приложение, которое получает от пользователя доступ к ресурсу. Каждый клиент должен быть зарегистрирован на сервере авторизации; при этом он получает client_id и client_secret. Фактически, это логин и пароль, по которым OAuth-провайдер может идентифицировать клиентское приложение. Важно, что эта пара логин+пароль служит исключительно для идентификации и никоим образом не совпадает с логином и паролем пользователя. Таким образом, пользователь ни при каких условиях не передает свой пароль третьим лицам: обмен этими данными он осуществляет только с сервером авторизации — это так же безопасно, как войти в свой почтовый ящик.


Итак, пользователь (resource owner) некоторого сайта (OAuth-провайдер) хочет передать другому сайту (client) право работать с частью функций от своего имени. Эта процедура называется в OAuth authorization grant. Для ее осуществления клиент просит пользователя перейти на сервер OAuth-провайдера и получить там access code, передав определенные параметры, о которых речь пойдет ниже. Технически это выглядит как перенаправление в браузере на заранее известный URL. При переходе пользователя по этому URL OAuth-провайдер просит пользователя авторизоваться и спрашивает его, действительно ли стоит предоставить запрашиваемый доступ данному приложению. Если пользователь соглашается, OAuth-провайдер перенаправляет браузер пользователя обратно на сервер клиента и передает туда код доступа. После этого клиент формирует специальный HTTP-запрос для обмена кода авторизации на токен доступа, используя свои client_id, client_secret для аутентификации клиента и полученный код для обмена его на токен доступа (access_token). Запрос выполняется с server side. Этот токен будет выполнять для приложения роль пароля для входа в API OAuth-провайдера.

0851b4e160f14c4199dce4d83e49e386.jpg

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

В качестве одного из параметров в стадии authorization grant передается scope. Этот параметр определяет, какие именно права хочет получить приложение. Параметры представляют собой строку, состоящую из разделенных пробелом последовательностей, понятных OAuth-провайдеру. Примечательно тут то, что access_token позволит клиентскому приложению выполнять только действия, которые были перечислены в параметре scope. Этот же список разрешений OAuth-провайдер покажет пользователю, прежде чем тот подтвердит согласие на передачу данных прав приложению.

Еще один интересный параметр стадии authorization grant называется state и позволяет избежать неочевидной проблемы безопасности. Приложение, перенаправляя пользователя на сайт OAuth-провайдера, генерирует случайный маркер (CSRF-токен) и передает его в параметре state. OAuth-провайдер ничего с ним не делает, но возвращает его обратно вместе с access code. Приложение сверяет полученный state с тем, что был отправлен, и прерывает стадию authorization grant, если state неверный. Если бы этого не происходило, потенциальный злоумышленник мог бы авторизовать наше приложение для доступа к своему ящику и передать свой код авторизации в наше приложение.

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

a715884535e8496dad574da2f26b3eb1.jpg

В некоторых случаях вместе с access_token OAuth-провайдер выдает клиенту refresh_token. Этот токен позволяет получить новый access_token или даже несколько. В простейшем случае пользователь дает разрешение приложению разово. Например, ваше приложение хочет добавить в календарь пользователя некоторое событие. Каждый раз, когда это происходит, пользователь получает запрос: разрешить ли приложению выполнить оговоренное действие? Если он соглашается, выдается access_token на небольшой промежуток времени, например, на час. Если завтра ваше приложение попытается добавить еще одно событие, доступ будет запрошен у пользователя повторно. Примерно так работает App Store в устройствах Apple. Чтобы установить приложение, необходимо ввести пароль, но в следующие 15 минут при установке других приложений этого делать не потребуется. Если же попытаться установить другое приложение позже, чем через 15 минут, пароль придется ввести снова.

В ряде же случаев пользователь хочет дать приложению право работать от его имени всегда. Яркий пример — как раз сборщики почты. Независимо от того, в онлайне пользователь или отправился в поход по алтайским горам на месяц, сборщик должен забирать почту из одного или нескольких ящиков. Вот в этой-то ситуации и требуется refresh_token. Клиентское приложение может запросить так называемый offline-доступ и получить в ответе refresh_token, а с ним и возможность авторизовать в сервисе OAuth-провайдера без участия пользователя, получая все новые и новые access_token-ы.


Недавно мы включили поддержку работы наших сборщиков почты с использованием OAuth. Теперь мы не заставляем пользователя вводить пароль от почтового ящика, и, даже собирая почту с ящика в Mail.Ru, сборщик по отношению к почтовому серверу выступает в роли OAuth-клиента. Мы поддерживаем OAuth для тех сервисов, которые позволяют работать по этому протоколу, а именно Google и Misrosoft. Для хранения токенов мы написали внутренний сервис Fluor. В его задачи, помимо хранения базы токенов, входит выдача их сборщикам и другим внутренним потребителям по запросу с минимальной задержкой. Обменом согласия пользователя на токен из внешнего сервиса занимается отдельный демон, который отвечает за авторизацию. Он проводит пользователя через процесс выдачи необходимых приложению прав (стадия authorization grant) и сохраняет полученные токены во Fluor.

Для сервисов, которые поддерживают refresh_token и ограничивают время жизни access_token, необходимо своевременно обновлять токены в базе. При этом надо не попасть под ограничения OAuth-провайдеров по количеству запросов в сутки от одного приложения или с одного IP. Этой задачей занимается демон fluor-refresh. Семейство демонов Fluor написано на Perl. Запросы к ним обрабатываются асинхронно с использованием библиотеки AnyEvent. Для взаимодействия с OAuth-демоном и сборщиками используется наш собственный протокол IPROTO. У нас есть так же свой HTTP-сервер на Perl, но из-за необходимости парсинга заголовков производительность обработки запросов по IPROTO оказывается выше в пять раз. Наиболее критичные с точки зрения процессора задачи вынесены из Perl в XS. XS позволяет писать часть кода на C и передавать результаты его работы в Perl.

В один момент времени может быть запущено несколько копий Fluor и fluor-refresh. Хранение токенов и взаимодействие между демонами мы организуем через Tarantool (разработанный тоже в Mail.Ru, имеющий открытый исходный код проект, о котором уже не раз писали на Хабре). Tarantool — это NoSQL база данных, целиком размещенная в памяти сервера, но позволяющая записывать данные на диск. В Tarantool есть репликация и возможность писать довольно сложные процедуры на языке Lua, что очень помогает в организации нашей специфичной очереди на обновление токенов.

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

Fluor-refresh просто вызывает функцию в Tarantool и получает список токенов для обновления. Для заданий он получает свежий access_token и сохраняет его в Tarantool через другую Lua-функцию. Lua-функции гарантируют, что обновление одного токена не будет поручено нескольким рефрешерам, и что всегда будут выбираться токены, срок истечения которых наступит в рамках заданного интервала. Таким образом, мы экономим несколько запросов в базу, которые необходимо было бы сделать, если бы вместо Tarantool был, скажем, memcached.

Если все же случится так, что токен для данного email не успел обновиться и истек, сборщик может попросить Fluor получить новый access_token незамедлительно, минуя очередь. Бывают также ситуации, когда пользователь отзывает доступ у приложения со стороны OAuth-провайдера. Протокол OAuth не предоставляет приложениям механизма для оповещения о такой ситуации. Мы узнаем о проблеме, когда refresh_token перестанет работать. В этом случае приходится удалять токен, а сборщик при этом переходит в состояние extra_auth, которое означает, что у пользователя необходимо запросить доступ повторно.

В настоящее время в базе Fluor хранится 4.8 млн токенов для различных сервисов, занимая в памяти 7 Гб. В сутки происходит порядка 100 миллионов обновлений токенов. Вместе с тем за сутки Fluor обрабатывает 125 миллионов запросов от сборщиков. Физически, с этим справляется один сервер, если не брать в расчет резервирование на случай сбоев.


В самом простом случае OAuth-сервер должен уметь следующее:

  1. Иметь возможность проверять авторизацию.
  2. Генерировать токены accsess и refresh, а также код авторизации.
  3. Проверять, хранить, инвалидировать и удалять токены.
  4. По refresh_token обновлять access_token, по коду авторизации выдавать refresh_token и access_token.


Проверка авторизации, как правило, является отдельным сервисом. Он авторизует пользователя по паре логин + пароль, либо по более сложным комбинациям (например, если речь идет о двухфакторной авторизации). Если вы пишете OAuth, этот сервис у вас уже есть.

Генерация токенов. Общий совет: токены должны быть максимально случайными, рандом должен быть криптографически стойким.

Управление токенами. Каждый из токенов имеет срок жизни и привязан к пользователю. Несложная таблица в БД позволит хранить токены, привязку к пользователю и время жизни. Данных не очень много, а скорость работы требуется высокая, поэтому желательна база, которая хранит данные в оперативной памяти. Также понадобится демон, который будет обходить базу и удалять устаревшие токены.

Выдача новых access-токенов по refresh-токену процедура довольно банальная, заострять на ней внимание мы не будем. Мы для этого используем Tarantool. Он хранит данные в памяти, обеспечивает их целостность. А самое главное, он инкапсулирует в себя логику удаления устаревших токенов. Это можно реализовать на внутренней Lua-процедуре. Еще один интересный момент — удаление токенов в случае, если пользователь сменил пароль. Для этого придется достать все токены, которые привязаны к пользователю. Здесь необходим secondary index, который строится по пользователю — у Tarantool, в отличие от многих других БД, такая возможность есть.

Особенности конфигурации системы. Здесь важны три пункта: скорость работы, утилизация железа, отказоустойчивость. Скорость работы нам обеспечивает Tarantool за счет взаимодействия только с оперативной памятью и secondary index. Для утилизации железа мы шардим Tarantool, что позволяет максимально использовать процессорные ядра сервера. Отказоустойчивость достигается за счет репликации в разных ДЦ. Репликация позволяет перезапускать как отдельные демоны, так и машины целиком.

Итак, сегодня мы анонсировали возможность подключиться к IMAP-протоколу почтового сервиса Mail.Ru, используя OAuth-авторизацию. Призываем разработчиков и клиентов для десктопных и мобильных устройств реализовать ее при сборе почты с наших ящиков.

Документация по подключению доступна на нашем сайте. На данный момент мы, со своей стороны, тоже собираем почту более безопасным способом с сервисов, которые предоставляют такую возможность, и хотим, чтобы их число увеличивалось. Надеемся, что в скором времени работа по OAuth 2.0 станет таким же золотым стандартом для почтовых сервисов, как работа по HTTPS.

© Habrahabr.ru