Печенья, кеки, кукисы, ку-ку, кексы… нет, куки!
Cookie — это что?
Почему их не нужно принимать?
Безопасно ли это?
CookieJar, CookieManager, CookieStore… WebView?
А также истории про то, как жить с cookie в большом приложении, и сколько эмоций вы испытаете, если в вашем приложении есть WebView.
Долгополов Денис
Android Dev, блог - @dolgo_polo_dev
За техническую консультацию (и моральную поддержку при работе с cookie на Android) спасибо Сергею Мазулеву и Максиму Шестоперову, команда OzonID.
За консультацию по юридическим вопросам спасибо Михаилу Ратушному, руководителю практики защиты персональных данных Ozon.
Cookie — они зачем?
Сначала определим, из чего состоят cookie и для чего используются. В общем смысле, не только на Android.
В большинстве случаев их удобно использовать для передачи:
токенов авторизации;
флагов с бэка на мобилку;
параметров устройства с мобилки на бэк.
Но зашить можно любую информацию, ограничений нет. Например, можно удобно организовать авторизацию:
1. пользователь вводит логин и пароль;
2. бэкенд авторизует юзера и присылает в ответ cookie-токен;
3. клиент начинает отсылать в cookie-токен на все следующие запросы.
Готово, бэкенд знает, от какого пользователя летят запросы.
Как выглядят cookie?
Cookie — это строка, состоящая из:
ключа;
значения;
атрибутов.
Ключ и значение могут быть произвольные. Атрибуты заранее определены, но при этом все они опциональны — можно присылать только нужные.
Например,
Cookie передаются как один из header (заголовков) http(s)-запроса. Причем как с клиента на бэкенд, так и в обратную сторону.
Cookie == Header?
Да, cookie передаётся в списке хедеров, как и все остальные хедеры (например, user-agent).
Но есть отличие: cookie автоматически прикрепляются к домену (см. атрибут domain) и начинают отсылаться в каждом запросе на этот domain (хост).
Например, с домена ozon.ru бэк прислал cookie userId = 123
. Это значит, что в следующий запрос на ozon.ru автоматически добавится header с этой же cookie userId = 123
. И она будет улетать, пока не протухнет (см. Session vs Permanent Cookie) или не будет перезаписана устройством или новой cookie userId = 456 с бэкенда.
Будьте осторожны со значениями — если положить строку, содержащую символ точки с запятой «;» или запятой «,», некоторые клиенты могут неправильно распарсить cookie.
Какие атрибуты бывают
Атрибуты в основном отвечают за безопасность, то есть ограничивают область видимости или срок жизни cookie:
expiresIn (max-age)
— определяет, когда cookie протухнет (и ее не нужно будет больше пересылать);httpOnly
— запрещает доступ к cookie из JS-кода;secure
— cookie можно передавать только по HTTPS и SSL (не по HTTP);domain
— определяет область видимости cookie. Например, домен 3 уровня page.ozon.ru имеет доступ к cookie домена 2 уровня ozon.ru, но не наоборот;path
— указывает путь, который должен содержать url, чтобы работать с cookie;sameSite
— регулирует доступность cookie между сайтами с разными доменами.
Подробнее: https://developer.mozilla.org/ru/docs/Web/HTTP/Cookies/
Почему приложения не спрашивают у нас разрешения?
Уверен, где-нибудь в интернете есть коллекция всех вариаций запроса на работу с cookie на web-сайтах…
Но почему подобный запрос не вылазит в каждом приложении?
Важно: не является истиной в последней инстанции. Если вы Большая компания — проконсультируйтесь с юристом ;)
Важноx2: речь идет про РФ. В вашей местности всё может быть совсем по-другому ;)
Общее правило:
явно запрашивать у пользователя разрешение на передачу cookie нужно, если они не являются технически необходимыми для работы приложения.
Например, запрашивать разрешение
не нужно: в cookie передаются токены авторизации пользователя, которые используются только внутри компании;
нужно: в cookie передаются рекламные идентификаторы, передаваемые другим компаниям.
Где почитать подробнее:
РФ — Федеральный закон № 152-ФЗ «О персональных данных»
В приложении Ozon мы не запрашиваем разрешение на cookie. А на сайте даём пользователю выбор:
Как передаются Cookie
Cookie передаются в хедерах http-запросов в обе стороны: от бэка на мобилку и с мобилки на бэк.
бэк присылает cookie в заголовке с
name = Set Cookie
, они прикрепляются к domain;Android-приложение при формировании запроса на domain находит прикрепленные к нему cookie и отсылает их в заголовке
name = Cookie
.
Также создать cookie и прикрепить их к domain можно локально через CookieManager.put(...)
. Но в этом случае необходимо самостоятельно следить за их жизненным циклом, не перепутать домены и не забыть про атрибуты.
В каждом запросе (и ответе) может быть несколько заголовков Cookie (Set Cookie).
Session vs Permanent Cookie
Permanent — если передать атрибут expiresIn (max-age), то cookie будут жить в памяти, пока не протухнут;
Session — если не передавать атрибут expiresIn (max-age), то cookie будут жить в памяти, пока не прервётся соединение (например, не будет перезагружено приложение).
Это описание стандартного поведения. Его, например, реализует CookieHandler.getDefault()
(что это за класс — рассмотрим ниже).
Вам, конечно, никто не помешает сохранить cookie в ПЗУ навсегда или стереть их в любой момент.
Безопасность
К cookie стоит относиться так же, как и к обычным https-запросам.
С одной стороны, это всё шифруется вместе с телом запроса, но с другой, — кому надо, тот найдет способ перехватить.
Поэтому передаём минимум чувствительной информации (пароли в открытом виде не стоит).
Как работать с cookie в Android
Нам потребуется несколько классов из стандартных библиотек:
OkHttpClient — базовый класс, настраивающий все взаимодействие с сетью;
CookieJar — DAO для работы с CookieManager;
CookieManager — DAO для работы с CookieStore;
CookieStore — хранилище кук.
Собрав цепочку из них, мы организуем хранение, управление и передачу cookie.
При этом многие из этих классов имеют стандартные реализации, переопределять которые стоит только при необходимости.
Иерархия
Пойдем по цепочке снизу вверх.
CookieStore
Основная задача: хранит cookie.
Имеет дефолтную реализацию InMemoryCookieStore (хранит cookie в ОЗУ, то есть они будут стираться после перезапуска приложения).
Хранит cookie в объектах java.net.HttpCookie
При желании можно реализовать свой CookieStore. Например, для сохранения cookie в ПЗУ. Главное — не забыть про протухание cookie, атрибуты и зависимость от domain.
А также помнить, что чтение cookie происходит на каждом сетевом запросе, поэтому в любом случае необходим ОЗУ-кэш для быстрого доступа к cookie.
CookieManager (extends CookieHandler)
Основная задача: достает cookie из CookieStore и дает возможность их модифицировать. Например, заменить атрибуты, докинуть или удалить значения.
Работает со строками в формате:
Map<String, List<String>> requestHeaders
// например
mapOf(
"Set-Cookie" to listOf(
"Secure_access_token = kds1n1…ja; secure; httpOnly",
"Debug = true; secure"
)
)
Полагается на CookiePolice
(ACCEPT_ALL, ACCEPT_NONE, ACCEPT_ORIGINAL_SERVER) для принятия решений, какие cookie на какой запрос доставать.
CookieJar
Основная задача: получает cookie из CookieManager и дает возможность их модифицировать.
Хранит cookie в объектах okhttp3.Cookie.
JavaNetCookieJar
Стандартная реализация CookieJar
, которая принимает ваш CookieManager в конструктор, достает из него cookie-строки и парсит в okhttp3.Cookie
-объекты.
OkHttpClient
Основная задача: связывает всех обработчиков сетевого запроса.
При создании в OkHttpClient.Builder()
принимает CookieJar
и обращается к нему на каждом http-запросе:
во время подготовки cookie для запроса на бэк;
в момент получения cookie ответа с бэка.
Готово: после того, как вы подставите CookieJar
в OkHttpClient
, cookie будут автоматически подставляться во все запросы.
Пример кода
Как это используется на практике:
OkHttpClient().Builder()
.cookieJar(MyCookieJar())
build()
class MyCookieJar(
javaNetCookieJar: JavaNetCookieJar = JavaNetCookieJar(MyCookieManager),
) : CookieJar {
// когда хедер Cookie для request
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return javaNetCookieJar.loadForRequest(url)
}
// когда получен reponse с хедером Set Cookie
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
javaNetCookieJar.saveFromResponse(url, cookies)
}
}
class MyCookieManager: CookieManager() {
// когда хедер Cookie для request
override fun get(uri: URI?, requestHeaders: MutableMap<String, MutableList<String>>?): MutableMap<String, MutableList<String>> {
return doSomethingWithCookie(requestHeaders)
}
// когда получен reponse с хедером Set Cookie
override fun put(uri: URI?, responseHeaders: MutableMap<String, MutableList<String>>?) {
doSomethingWithCookie(requestHeaders)
}
}
Разделение по доменам
Как оговаривалось выше, у cookie есть атрибут domain — он определяет, на какой домен (например, ozon.ru или ozon.kz) будут улетать cookie, сохранённые в CookieStore.
Если domain не указан явно, то он будет взят из url http-запроса, с которого пришли cookie.
При этом cookie домена 2-го уровня доступны домену 3-го уровня, но не наоборот.
Например, cookie, сохраненные для ozon.ru, автоматически улетят вместе с запросом на 123.ozon.ru. Но cookie 123.ozon.ru не доступны ozon.ru.
Другой подход — Interceptors
Необязательно работать с куками через связку CookieJar, CookieManager…
Можно перехватывать response бэкенда в Interceptor и вытаскивать cookie оттуда.
val client = OkHttpClient.Builder()
.addInterceptor(CookieInterceptor())
.build();
Но тогда придется реализовывать свою логику хранения cookie + корректную обработку всех атрибутов… Нужно ли оно вам?
А если ничего не делать, cookie будут работать?
Если не переопределить свой CookieJar, то cookie не будут обрабатываться и присылаться приложением, потому что по дефолту OkHttpClient использует заглушку, которая не сохраняет cookie:
val NO_COOKIES: CookieJar = NoCookies()
private class NoCookies : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return emptyList()
}
}
Как с cookie жить в большом приложении?
Если в вашем приложении несколько модулей и SDK, то в какой-то момент вы столкнетесь с трудностями.
Проблемы
cookie-хранилища не имеют ограничения по ролям — любой разработчик может затереть все cookie в CookieStore;
внутри разных модулей и SDK могут быть свои CookieStore / HttpClient / CookieManager / CookieJar, работающих с одними и теми же доменами. Значит, их нужно постоянно между собой синхронизировать.
Решение
Мы используем следующий архитектурный подход:
один-синглтон CookieJar, к которому подключен один-синглтон CookieManager;
каждый, кто создает свой HttpClient, должен подключить наш CookieJar.
Если у кого-то возникает потребность редактировать cookie, он добавляет свой callback в список CookieListener’ов. На каждом запросе мы оповещаем все CookieListener’ы, давая им возможность узнать о новых cookie и отредактировать их.
Удар ниже пояса — WebView
Если в вашем приложении есть WebView, готовьтесь страдать.
У WebView своя реализация CookieManager (android.webkit.CookieManager).
Важно: все запросы, которые улетают из WebView, берут и кладут cookie в android.webkit.CookieManager, а не в ваш CookieManager (CookieStore).
Это справедливо и в обратную сторону: без дополнительных костылей, cookie из вашего CookieManager не попадут в android.webkit.CookieManager. И запросы будут улетать без них.
Подвохи
android.webkit.CookieManager, который имеет ограниченное API;
Не хватает нескольких, казалось бы, удобных методов.
Например, android.webkit.CookieManager не имеет колбэка, который бы сообщил вам об изменении cookie.
android.webkit.CookieManager — синглтон в рамках приложения;
но не в рамках всего устройства. Поэтому у браузера и WebView в каждом приложении независимые CookieStore.
android.webkit.CookieManager — не наследуется от CookieManager, о котором говорили выше. То есть у них лишь одинаковые названия, но по сути это два несвязанных класса с разным API;
android.webkit.CookieManager.getCookie() отдаст cookie без атрибутов. Можно получить только значения.
Как следствие — атрибуты, отвечающие за безопасность, будут утеряны.
android.webkit.CookieManager сохраняет данные в ПЗУ, а не ОЗУ (как остальные CookieManager’ы по умолчанию)
Но можно придумать костыль — затирать cookie пустыми значениями в нужный момент.
Последствия
нужно костылить отслеживание cookie в WebView;
нужно костылить насыщение cookie-атрибутами, если забираете их из android.webkit.CookieManager;
нужно синхронизировать cookie между android.webkit.CookieManager и остальными CookieManager’ами.
Решения
Как синхронизировать cookie между android.webkit.CookieManager и остальными CookieManager’ами:
по таймеру;
-
отслеживать уход WebView с экрана и в этот момент забирать cookie;
но если у вас будут запросы из бэкграунда в момент нахождения пользователя в WebView, то свежие cookie из android.webkit.CookieManager не возьмутся;
-
переопределить WebViewClient.shouldInterceptRequest() и отправлять запросы через OkHttpClient
но не все запросы проходят через WebViewClient.shouldInterceptRequest() — например, мимо летят редиректы;
использовать только android.webkit.CookieManager, синхронизируя в него cookie из всех остальных CookieManager’ов.
Как насыщать cookie атрибутами:
хардкодить атрибуты для каждой cookie на клиенте;
запрашивать атрибуты cookie отдельным запросом / в другом хедере;
хранить атрибуты от предыдущих cookie, полученных вне WebView.
Вывод
Cookie — удобный механизм для передачи служебной информации сразу во все http-запросы.
Но с ним нужно быть всегда настороже:
не забывать про синхронизацию между CookieStore’ами;
помнить про протухание и привязку к доменам;
следить за их изменением и оповещать всех интересантов.
MaxPro33
В статье вы упоминаете применение Cookie для передачи токенов авторизации, флагов с бэка на мобилку и параметров устройства. Могли бы вы поделиться конкретными примерами сценариев, где использование Cookie оказалось наиболее эффективным и удобным решением?
DolgopolovDenis Автор
например, пользователь подтвердил, что ему можно смотреть на товары 18+
мы можем положить в куки информацию об этом
все дальнейшие запросы на бекенд (например, получение картинок для карточки товара) будут содержать эту куку
и бекенд сможет по ней понять, можно пользователю отправлять картинку или нет
при этом сервису, отвечающему за картинки, не придется делать запрос в сервис, отвечающий за информацию о юзере - экономим миллисекунды и rps
но в основном конечно куки идеально подходят для токенов авторизации - прикрепляешь токены к домену, и они сразу начинают улетать со всеми запросами, не нужно их "прикладывать" к каждому запросу вручную
nikpopov
Еще в озоне на основе куки показывали разные лэйауты поисковой выдачи. Пользователь мог выбрать предпочтительный для него вариант в фильтрах, этот вариант положился в куку и каждый раз когда пользователь открывал выдачу, то у него открывался именно тот вариант, который у него выбран. Плюсы такого решения, что не надо было менять контракт поисковой выдачи и добавлять туда поле и не надо было где то отдельно создавать класс и хранить стейт выбора пользователя в каком нибудь синглтоне