Многие мобильные приложения, от социальных сетей до стриминговых сервисов, позволяют авторизоваться. Это открывает доступ такой функциональности, как история действий, список избранного, хранение и синхронизация между устройствами созданного пользователем контента, персонализация внешнего вида приложения и многому другому.
С ростом аудитории и функциональности сервисов появляются пользователи, которым по различным причинам необходимо использовать несколько учётных записей для абсолютно разных целей: личных, рабочих или учебных. Чтобы они могли быстро и удобно переключаться между ними, разработчики добавляют в свои приложения функцию мультиаккаунта — с функцией переключения, multi push и т. д.
Но если бы идея мультиаккаунта всегда закладывалась при разработке приложения заранее, то и причин писать эту статью не было бы. Мы расскажем о своём опыте интеграции мультиаккаунта и перечислим основные проблемы, с которыми вам, скорее всего, придётся столкнуться в аналогичной ситуации.
Что может пойти не так?
Основная задача мультиаккаунта — это максимально быстрое переключение приложения на другую учётную запись без повторного ввода факторов авторизации и лишних запросов к серверу. При этом важно чётко разделять данные переключаемых аккаунтов, путаница недопустима! Вряд ли пользователь обрадуется, когда узнает, что его сообщения отправились с другого аккаунта.
Приложения должны уметь вовремя реагировать на внешние события (например, на push-уведомления), анализировать, с каким из аккаунтов связано это событие, и при необходимости переключаться. Для этого необходима возможность совершать действия и обрабатывать сетевые запросы для всех авторизованных аккаунтов, а не только для активного. Все модули приложения должны хранить набор данных для каждого аккаунта и уметь перестраиваться под переключение на каждый из них. Звучит непросто!
Разберём каждую задачу в отдельности.
Отправка запросов от лица нескольких пользователей одновременно
Все сетевые запросы можно условно поделить на авторизованную и анонимную зоны, ни в коем случае не допуская их смешения. В авторизованной зоне получается доступ к пользовательским данным — самой большой ценности для любого приложения. В ней осуществляются чувствительные с точки зрения безопасности операции, поэтому запросы в ней обязательно должны подтверждаться токеном доступа (об этом подробнее расскажем ниже). В анонимной зоне, напротив, происходят действия, которые могут быть совершены без авторизации. В таких запросах токен не нужен, но могут передаваться дополнительные обезличенные параметры, например идентификатор устройства.
Получается, что при разделении авторизованной и анонимной зоны в запросах передаётся разный набор параметров. В неавторизованной зоне обычно используется анонимный идентификатор устройства, такой как IDFA на iOS. Подобный механизм можно назвать авторизацией запроса — это проставление в параметры или заголовки запроса информации, используемой в авторизации.
Зачастую запрос авторизуется в коде в месте использования. Узнали, согласны? При внедрении мультиаккаунта, когда в приложении может быть несколько активных профилей одновременно, такой подход чреват путаницей: запрос может быть отправлен не от того аккаунта! Если не обеспечено централизованное хранение сессий, подобные ситуации практически неизбежны. Всё из-за того, что в приложении получается слишком много мест, где что-то может пойти не так: можно сохранить не те токены, перепутать пользователя в момент записи сессии, и наступить на те же грабли при формировании каждого сетевого запроса, которых в кодовой базе может быть сотни.
Хорошим решением проблемы будет принудительная централизованная авторизация запросов, каждый из которых инкапсулирован в отдельном модуле. Для формирования правильного списка параметров нашему модулю авторизации запросов нужно знать только идентификатор пользователя, «от лица» которого осуществляется действие, если запрос принадлежит к авторизованной зоне. При этом идентификатор текущего активного пользователя можно проставлять автоматически, что сводит к минимуму вероятность ошибки в месте отправления запроса.
Для формирования списка необходимых параметров нашему модулю нужно узнать из запроса, к какой зоне он относится (эта информация должна быть заранее известна для каждого конкретного запроса и не может меняться, помните?). Если запрос поступил из авторизованной зоны, то модуль должен использовать данные из сессии, соответствующей этому пользователю.
Логика довольно проста, а благодаря тому, что она сконцентрирована в одном месте, ещё и легко тестируема. Более того, эта система позволяет расширить логику авторизации запросов, например, добавить обновление токенов доступа. Представьте себе, как сложно было бы обновить токены, разбросанные по разным модулям всего приложения, да ещё и для конкретного пользователя.
UML-диаграмма отправки запросов от лица нескольких пользователей представлена на рисунке.
Используя эти подходы, одновременная отправка сообщений от разных аккаунтов становится тривиальной задачей. Скорее всего, большинство запросов в вашем приложении будет отправляться от текущего активного пользователя. Таким образом, его сессию в сетевом слое можно использовать по умолчанию. Если понадобится отправить запрос от другого пользователя, достаточно сообщить его идентификатор модулю авторизации, и он сделает всю работу за нас: подберёт правильную сессию и проставит нужные параметры.
Например, у нас учтён следующий интерфейс для отправки запроса от конкретного пользователя:
ApiCall
.withAccessTokenOf(userId)
.url("url")
...
.execute()
Хранение сессий и метаданных
По стандарту OAuth 2.0, для отправки запросов в авторизованной зоне, как правило, используется секретный токен доступа, который клиенты получают при авторизации. Он служит «подтверждением» возможности действия пользователя в своём аккаунте и отправляется с каждым соответствующим запросом.
Эти токены необходимо где-то хранить. Для наших задач важно делать это централизованно, в одном хранилище и максимально безопасным способом. Также необходимо различать, какому пользователю принадлежит токен, так что все они должны быть ассоциированы с идентификатором пользователя.
А что, если нам потребуются какие-то дополнительные данные пользователя, например никнейм? Не запрашивать же его каждый раз у сервера при переключении? Получается, что нам не обойтись без новой отдельной сущности — сессии. Помимо самого токена, она должна хранить в себе ассоциацию с конкретным пользователем с минимальным набором часто используемых данных.
Оптимальным местом для хранения сессий в iOS может стать Keychain, так как он отвечает всем требованиям, а самое главное — требованиям по безопасности, предоставляя максимальную защищённость современных устройств Apple.
Таким образом, мы получаем:
Безопасность.
Тонкую настройку доступа.
Возможность сохранения данных при переустановке приложения.
Возможность сохранения и восстановления из резервной копии на другом устройстве.
Возможность совместного доступа из App Extensions и даже других наших приложений.
Это ни в коем случае не означает, что нужно без оглядки использовать все вышеперечисленные возможности. Наоборот, с точки зрения безопасности разумным подходом было бы использование минимального набора. При разработке мультиаккаунта достаточно того, что авторизация, а значит и данные наших пользователей, надёжно защищены.
Недостатки у использования Keychain, к сожалению, тоже есть — это:
Сложный и устаревший API. Скорее всего, без удобной обёртки не обойтись.
Необходимость дополнительного кодирования и декодирования произвольных типов в Data.
Для хранения информации о сессиях в приложении под Android мы решили использовать EncryptedSharedPreferences
из пакета androidx.security. Данные сессии сериализуются (например, в формате JSON) и сохраняются в этих зашифрованных настройках. Этот подход сочетает в себе высокую безопасность с быстрым доступом к данным и широкой совместимостью с большинством вендоров. Если на определённом устройстве возникают проблемы с использованием шифрования, то в качестве запасного решения применяются стандартные Preferences.
На приведённом ниже рисунке показаны минимальные данные, которые необходимо хранить для каждой сессии.
Нюансы переключения между учётными записями
При смене аккаунта нужно как можно быстрее показывать актуальные данные для активной учётной записи. Идеальным вариантом было бы визуально сохранить навигацию приложения, при этом обновляя контент на открытом экране для каждой новой учётной записи. Такой подход возможен, например, с помощью подписки на сервис управления сессиями, который будет сообщать, что сменилась активная учётная запись.
При получении уведомления о смене аккаунта каждый экран приложения должен подставить сохранённую в кеше информацию для соответствующего пользователя, запросить у сервера обновление данных и всё это красиво заменить, пока пользователь смотрит на экран телефона. Это можно легко реализовать, если у вас небольшое приложение или вы закладывали мультиаккаунт на самых ранних этапах создания. Иначе придётся переделывать почти все экраны, чтобы поддержать на них смену текущего пользователя.
Если количество экранов в вашем приложении не посчитать на пальцах всех разработчиков вашей компании, то стоит поискать более оптимальное решение. Как вариант, можно полностью сбрасывать всю навигацию в приложении и пересоздавать стартовый экран приложения, но уже с данными новой учётной записи. В таком случае вся работа с кешами и сетевыми запросами для разделов заработает автоматически, как это происходит при запуске.
Единственное, что мы теряем — сохранение открытых пользователем экранов с прошлой учётной записи. Собирать все параметры навигации заново может быть слишком сложно и неоправданно затратно с точки зрения разработки. С другой стороны, если пользователь переключает учётную запись, то сохранённые экраны ему могут быть и не нужны.
Следующим шагом будет перенастройка всех сервисов приложения на работу с новой учётной записью. Основой может послужить процесс выхода из учётной записи и авторизации в другой. Стоит посмотреть, что и с какими сервисами происходит в этот момент: удаление кешей, остановка текущих запросов, отписки от множества уведомлений.
Скорее всего, большую часть этих операций можно оптимизировать, но к каждому изменению надо подходить индивидуально. Зная нюансы реализации и требования каждого раздела, можно повысить скорость переключения между учётными записями, убирая ненужные действия с сервисами, пересоздание объектов и т. д.
При переключении также стоит предусмотреть способ информирования пользователя о том, в какую учётную запись он перешёл. Это можно сделать с помощью всплывающих элементов интерфейса, отображающих информацию о новой активной учётной записи. Это важный элемент при разработке мультиаккаунта, чтобы избавить пользователя от путаницы, в какой учётной записи он находится — некоторые переключения могут происходить быстро, и визуально это может быть не очень очевидно.
Не менее важным этапом является выбор точек и способов переключения между учётными записями. На этом шаге стоит проанализировать существующий дизайн приложения, а также паттерны, которым следуют пользователи в предыдущем пути переключения между аккаунтами (разлогина и входа в сервис). Стоит разместить точки переключения на пути к привычному выходу из приложения, а также добавить новые удобные для пользователей, которые в дальнейшем будут активно пользоваться новой функциональностью.
Также не стоит забывать о том, что пользователю могут приходить push-уведомления от неактивного аккаунта, и это может стать ещё одной отличной точкой для смены текущей учётной записи. Она также поможет увеличить время, которое пользователь проводит в приложении, погружая его в контент, получаемый сразу через несколько учетных записей.
Кстати, о push-уведомлениях…
Управление push-уведомлениями
Практически ни одно приложение не обходится без уведомлений — с их помощью вы можете информировать пользователя о новых сообщениях и интересующем контенте, или просто напомнить о существовании вашего приложения. Если в нём есть авторизация, то уведомления для каждой учётной записи, скорее всего, формируются индивидуально. При внедрении мультиаккаунта нужно уметь показывать уведомления не только от текущей учётной записи, но и от остальных добавленных — иначе для пользователя частично пропадает смысл добавлять несколько аккаунтов.
Чтобы на устройства приходили уведомления, необходимо сообщить серверу его идентификатор. При рассылке push’ей только с одной учётной записи всё просто: запрос на сервер вы подписываете токеном текущего пользователя, и тогда бекенд понимает, для какой именно учётной записи нужно присылать уведомления. Меняется активная учётная запись — устройство снова подписывается на уведомления.
Как только в приложении появляется несколько аккаунтов, для каждого из которых нужны свои уведомления, приходится менять старый механизм. Теперь при подписке мы должны сообщать серверу обо всех учётных записях внутри приложения. Получается, что на бекенде к одному идентификатору устройства будет привязано несколько пользователей.
Таким образом, дальнейшая логика рассылки уведомлений лежит на сервере, ведь теперь он знает обо всех учётных записях, привязанных к приложению на конкретном устройстве. При таком механизме смена учётной записи никак не влияет на подписку на уведомления. Поэтому, когда пользователь переключается на другую учётную запись, нам не нужны никакие сетевые запросы для обновления подписки.
Однако, стоит предусмотреть возможность получать уведомления только от текущей учётной записи, чтобы пользователь мог оградить себя от потока уведомлений с неактивных аккаунтов. В этом случае логика подписки будет оставаться такой же, как и до внедрения мультиаккаунта: на каждую смену учётной записи нужно будет делать подписку на уведомления.
Если уведомления в приложении будут приходить от нескольких учётных записей, то пользователю необходимо визуально различать, для какого аккаунта приходит push. Для этого отлично подойдёт уникальный никнейм, который пользователь может выбирать для каждой своей учётной записи. Однако, тут может возникнуть проблема с никнеймами по умолчанию — например, цифровыми ID, — но это лишь позволит мотивировать пользователя сменить его на более понятный и выделяющийся.
Последний штрих — локальные уведомления в вашем приложении. Их используют для ускорения доставки наиболее важной информации. В этом случае для рассылки не требуется взаимодействие с промежуточным сервером, который отправляет уведомления — информация формируется на самом устройстве (например, о входящем сообщении). Локальным уведомлениям стоит уделить отдельное внимание и синхронизировать их поведение с серверными уведомлениями, иначе можно ввести пользователя в замешательство разной логикой — наличием никнейма, получением уведомления на неактивную учётную запись и т. д.
Грабли, боли и тестирование
При реализации мультиаккаунта мы обнаружили несколько неожиданных трудностей и организационных вопросов, которые было важно решить до запуска продукта. Среди них:
Feature Toggles. У нас они изначально работали на основе идентификатора пользователя. Возник вопрос: что делать, если мы хотим, чтобы одна и та же функциональность была доступна для всех пользователей на одном устройстве? Это было важно для сохранения целостности пользовательского опыта и проведения корректных A/Б-тестов. Для решения проблемы мы решили использовать Feature Toggles на основе deviceId.
Система аналитики. События в ней могут накапливаться и отправляться позже. Это может привести к ситуации, когда данные отправляются от другого пользователя после смены аккаунта. Чтобы избежать этого, мы модернизировали нашу систему аналитики, добавив функциональность, которая учитывает отправку запросов от разных пользователей.
Общие хранилища и кеши для всех приложений. При поддержке мультиаккаунта в приложении разработчикам всех команд потребовалось разделять кеши для разных учётных записей — хранить их по идентификаторам пользователей, а также реализовывать миграцию хранящихся данных на этот механизм.
Иерархия экранов. Вне зависимости от того, откуда произошло переключение, текущий «стек» должен адекватно на него реагировать, в идеале — сбрасываться до какой-то определённой точки и адаптироваться под нового пользователя.
Организационные тонкости разработки. Работая над функциональностью, которая затрагивает весь продукт, важно обеспечить эффективное межкомандное взаимодействие. Необходима чёткая коммуникация между командами, чтобы избежать дублирования работы. Регулярные совещания и обмен информацией о прогрессе и возникающих проблемах становится ключом к успеху такого масштабного проекта.