Печенья, кеки, кукисы, ку-ку, кексы… нет, куки!

  • 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-ФЗ «О персональных данных»

  • GDPR

  • ePrivacy Directive

В приложении 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’ами;

  • помнить про протухание и привязку к доменам;

  • следить за их изменением и оповещать всех интересантов.

Комментарии (3)


  1. MaxPro33
    29.11.2023 17:28
    +2

    В статье вы упоминаете применение Cookie для передачи токенов авторизации, флагов с бэка на мобилку и параметров устройства. Могли бы вы поделиться конкретными примерами сценариев, где использование Cookie оказалось наиболее эффективным и удобным решением?


    1. DolgopolovDenis Автор
      29.11.2023 17:28
      +1

      например, пользователь подтвердил, что ему можно смотреть на товары 18+

      мы можем положить в куки информацию об этом

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

      и бекенд сможет по ней понять, можно пользователю отправлять картинку или нет

      при этом сервису, отвечающему за картинки, не придется делать запрос в сервис, отвечающий за информацию о юзере - экономим миллисекунды и rps

      но в основном конечно куки идеально подходят для токенов авторизации - прикрепляешь токены к домену, и они сразу начинают улетать со всеми запросами, не нужно их "прикладывать" к каждому запросу вручную


    1. nikpopov
      29.11.2023 17:28
      +1

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