JWT простым языком: что такое JSON токены и зачем они нужны

Краткий, но исчерпывающий обзор JWT и его возможностей. JSON токены, их структура, построение и распространенные способы использования.

После беглого знакомства с JSON web tokens может сложиться впечатление, что они встроены в современные механизмы авторизации и аутентификации, такие как OAuth или OpenID. Однако это не совсем так. JSON токены действительно используются в этих системах, но не являются их частью. Более того, сфера их использование гораздо шире авторизации.

Эта статья посвящена детальному разбору JWT и его возможностей. Мы изучим структуру токена и построим его с нуля. Затем рассмотрим наиболее распространенные способы использования, затронув попутно тему серверных и клиентских сеансов. Наконец, доберемся до криптографических функций безопасности, которые и делают JSON токены важным звеном в процессах авторизации.

Что такое JWT?

Если обратиться с этим запросом в Google, вероятнее всего, он выдаст что-то подобное:

... способ представления передаваемых данных ...
... объект JSON, который определен в RFC 7519 как безопасный способ обмена информацией между двумя сторонами ...
... открытый стандарт, который определяет компактный и автономный способ безопасной передачи информации ...

Все эти определения верны, но они звучат чересчур научно и абстрактно. Попробуем дать JWT собственное описание.

Веб-токен JSON, или JWT (произносится "jot"), представляет собой стандартизированный, в некоторых случаях подписанный и/или зашифрованный формат упаковки данных, который используется для безопасной передачи информации между двумя сторонами.

Проанализируем эту формулировку.

Формат упаковки данных

JWT определяет особую структуру информации, которая отправляется по сети. Она представлена в двух формах – сериализованной и десериализованной. Первая используется непосредственно для передачи данных с запросами и ответами. С другой стороны, чтобы читать и записывать информацию в токен, нужна его десериализация.

Десериализованная форма

В несериализованном виде JWT состоит из заголовка и полезной нагрузки, которые являются обычными JSON-объектами.

Заголовок (заголовок JOSE) в основном используется для описания криптографических функций, которые применяются для подписи и/или шифрования токена. Здесь также можно указать дополнительные свойства, например, тип содержимого, хотя это редко требуется. Чтобы узнать больше о заявках заголовка, обратитесь к спецификации.

Если JWT подписан и/или зашифрован, в заголовке указывается имя алгоритма шифрования. Для этого предназначена заявка alg:

{
   "alg": "HS256"
}

Слово "заявка" в спецификации обозначает просто часть информации и аналогична ключу JSON-объекта. Она представлена в виде пары имя: значение, где имя всегда является строкой. Значением заявки может быть любой сериализуемый тип данных. Например, следующий объект JSON состоит из трех заявок: iss, exp и http://example.com/is_root:

{
   "iss": "joe",
   "exp": 1300819380,
   "http://example.com/is_root": true
}

Заявки бывают служебными и пользовательскими. Первые обычно являются частью какого-либо стандарта, например, реестра JSON Web Token Claims, и имеют определенные значения. Наиболее распространенные служебные заявки:

  • iss – издатель токена;
  • sub – описываемый объект;
  • aud – получатели;
  • exp – дата истечения срока действия;
  • iat – время создания.

Полный список заявок приведен в реестре.

Неподписанные JSON токены

Заголовок описывает криптографические операции, которые применяются к веб-токену. Но в некоторых случаях подпись и шифрование могут отсутствовать. Обычно это происходит, когда JWT является частью некоторой уже зашифрованной структуры данных. В заголовке такого неподписанного токена заявка alg должна быть равна none:

{
   "alg": "none"
}

Полезная нагрузка

Полезные данные – часть токена, в которой размещается вся необходимая пользовательская информация. Как и заголовок, полезная нагрузка представляет собой обычный объект JSON. Здесь ни одно поле не является обязательным. Обычно используются уже рассмотренные служебные заявки iss, sub и aud, а также специфические для приложения данные. Например, вот так выглядит JWT во фреймворке OpenID:

{
  "iss": "https://auth-provider.domain.com/",
  "sub": "auth|some-hash-here",
  "aud": "unique-client-id-hash",
  "iat": 1529496683,
  "exp": 1529532683
}

В спецификации OpenID можно ознакомиться с полным списком заявок.

Сериализованные JSON токены

JSON web token в сериализованной форме – это строка следующего формата:

[ Header ].[ Payload ].[ Signature ]

Заголовок (header) и полезная нагрузка (payload) присутствуют всегда, а вот подпись (signature) может отсутствовать.

Пример компактной формы:

eyJhbGciOiJub25lIn0.
eyJzdWIiOiJ1c2VyMTIzIiwicHJvZHVjdElkcyI6WzEsMl19.

Она получена из следующих данных:

// заголовок
{"alg":"none"}
// полезная нагрузка
{"sub":"user123", "productIds":[1,2]}

Здесь определен неподписанный токен, его заявка alg равна none. Поэтому в строке нет третьей части, однако точка после второго фрагмента все равно добавляется.

Сериализация

Процесс сериализации JWT состоит из кодирования заголовка, полезной нагрузки и подписи, если она есть, с помощью алгоритма base64url. Это простая вариация base64, которая использует URL-безопасный символ _ вместо небезопасных + и /. Дело в том, что некоторые инструменты обработки данных распознают управляющие символы в строке, поэтому их быть не должно. Узнать больше о base64 можно здесь.

На рисунке изображен процесс сериализации неподписанного токена:

В коде это можно представить следующим образом:

function encode(h, p) {
   const header = base64UrlEncode(JSON.stringify(h));
   const payload = base64UrlEncode(JSON.stringify(p));
   return `${header}.${payload}`;
}

Чтобы декодировать токен, достаточно просто разбить его по точкам и конвертировать заголовок и полезную нагрузку из кода base64url обратно в строку. Пример кода, который это делает:

function decode(jwt) {
   const [headerB64, payloadB64] = jwt.split('.');
   const headerStr = base64UrlDecode(headerB64);
   const payloadStr = base64UrlDecode(payloadB64);
   return {
       header: JSON.parse(headerStr),
       payload: JSON.parse(payloadStr)
   };
}

Использование библиотек

Разумеется, JSON токены не кодируются вручную. Существует множество библиотек, предназначенных для этого. Например, jsonwebtoken:

const jwt = require('jsonwebtoken');
const secret = 'shhhhh';
// шифрование
const token = jwt.sign({ foo: 'bar' }, secret);
// проверка и расшифровка
const decoded = jwt.verify(token, secret);
console.log(decoded.foo) // bar

Этот код создает подписанный JWT с использованием секретного слова. Затем он проверяет подлинность токена и декодирует его, применяя тот же секрет. Подпись и другие механизмы безопасности будут разобраны далее.

Приложения

Пора переходить к практическому применению JWT. В принципе, JSON токены может использовать любой процесс, связанный с обменом данных через сеть. Например, простое клиент-серверное приложение или группа из нескольких связанных серверов и клиентов. Отличным примером сложных процессов со множеством потребителей данных служат фреймворки авторизации, такие как AuthO и OpenID.

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

Чтобы понять принцип работы токенов, нужно разобраться в концепции клиентских сеансов. Для этого вспомним о традиционных серверных сессиях и узнаем, почему же произошел переход на сторону клиента.

Серверные сессии с хранением состояния

Как известно, HTTP – протокол без учета состояния. Это означает, что он не обеспечивает механизм для объединения нескольких запросов одного клиента. Тем не менее, большинство приложений нуждается в такой функциональности. Чтобы предоставлять пользователям индивидуальный опыт, необходимо отслеживать некоторую информацию, например, учетные данные или список товаров в корзине покупателя.

Многие годы для этого использовались сеансы на стороне сервера. Пользовательские данные при этом хранятся в файловой системе, базе данных или memcache. Вот для примера список различных сессионных хранилищ известного модуля Node.js express-session.

Чтобы связать данные на сервере с клиентскими запросами, идентификатор сеанса помещается в файлы cookie или сохраняется с помощью других HTTP-функций. Он отправляется на сервер с каждым запросом и используется для поиска сеанса этого конкретного клиента. Важный момент: в файлах cookie сохраняется только идентификатор, но не сама информация.

Данные сеанса представляют собой определенное состояние, поэтому мы говорим о сессиях с хранением состояния.

Реализация серверных сеансов – дело непростое. Наличие состояний затрудняет репликацию и исправление ошибок. Самым большим недостатком серверных сессий является то, что их трудно масштабировать. Необходимо либо дублировать данные одного сеанса на всех серверах, либо использовать центральное хранилище, либо гарантировать, что данный пользователь всегда попадает на один и тот же сервер. Любой из этих подходов связан с серьезными сложностями.

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

Сеансы на стороне клиента

В отличие от серверных сессий, данные клиентских сеансов хранятся на машине пользователя и отправляются с каждым запросом. Один из способов реализации этого механизма – использование файлов cookie. В них можно хранить не только идентификатор сеанса, но и сами данные. Куки-файлы очень удобно использовать, поскольку они автоматически обрабатываются веб-браузерами. Однако это не единственный способ передавать информацию сеанса. Это также можно делать через HTTP-заголовки, чтобы избежать проблем с CORS, или с помощью URL-параметров.

С сеансами на стороне клиента снимается проблема с масштабируемостью. Однако появляются уязвимости безопасности. Нельзя гарантировать, что клиент не изменяет данные сессии. Например, если идентификатор пользователя хранится в файлах cookie, его легко изменить. Это позволит получить доступ к чужой учетной записи. Чтобы предотвратить подобные действия, нужно обернуть данные в защищенный от несанкционированного воздействия пакет.

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

Впрочем, у сеансов на стороне клиента также есть свои минусы. Например, приложение может требовать большого объема пользовательских данных, и их придется отправлять туда-обратно для каждого запроса. Это может даже перекрыть все преимущества простоты и масштабируемости. Таким образом, в реальном мире приложения зачастую совмещают клиентские и серверные сеансы.

Защита веб-токенов

Как уже упоминалось выше, JWT использует два механизма для защиты информации: подписи и шифрование. Их описывают стандарты безопасности JSON Web Signature (JWS) и JSON Web Encryption (JWE).

Подпись JWT

Цель подписи заключается в том, чтобы дать возможность одной или нескольким сторонам установить подлинность токена. Помните пример подделки идентификатора пользователя из cookie для получения доступа к чужой учетной записи? Токен можно подписать, чтобы проверить, не были ли изменены данные, содержащиеся в нем. Однако подпись не мешает другим сторонам читать содержимое JWT, это уже дело шифрования. Подписанный веб-токен известен как JWS (JSON Web Signature). В компактной сериализованной форме у него появляется третий сегмент – подпись.

Самый распространенный алгоритм подписи – HMAC. Он объединяет полезную нагрузку с секретом, используя криптографическую хеш-функцию (чаще всего SHA-256). С помощью полученной уникальной подписи можно верифицировать данные. Это схема называется разделением секрета, поскольку он известен обеим сторонам: создателю и получателю. Таким образом, и тот, и другой могут генерировать новое подписанное сообщение.

Другой алгоритм подписи – RSASSA. В отличие от HMAC он позволяет принимающей стороне только проверять подлинность сообщения, но не генерировать его. Алгоритм основан на схеме открытого и закрытого ключей. Закрытый ключ может использоваться как для создания подписанного сообщения, так и для проверки. Открытый ключ, напротив, позволяет лишь проверить подлинность. Это важно во многих сценариях подписки, таких как Single-Sign On, где есть только один создатель сообщения и много получателей. Таким образом, никакой злонамеренный потребитель данных не сможет их изменить.

Шифрование

В отличие от подписи, которая является средством установления подлинности токена, шифрование обеспечивает его нечитабельность.

Зашифрованный JWT известен как JWE (JSON Web Encryption). В отличие от JWS, его компактная форма имеет 5 сегментов, разделенных точкой. Дополнительно к зашифрованному заголовку и полезной нагрузке он включает в себя зашифрованный ключ, вектор инициализации и тег аутентификации.

Подобно JWS, он может использовать две криптографические схемы: разделение секрета и открытый/закрытый ключи.

Схема разделенного секрета аналогична механизму подписки. Все стороны знают секрет и могут шифровать и дешифровать токен.

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

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

Перевод статьи Max NgWizard KA plain English introduction to JSON web tokens (JWT): what it is and what it isn’t.

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ