Вокруг JWT сложилась много распространенных заблуждений. Одно из них, например, что JWT зашифровано (на самом деле только подписано и закодировано base64url). На практике, я часто встречаюсь с довольно странными решениями, когда в JWT хранится только один идентификатор сессии (на самом деле если вы работаете с сессией то JWT вам не нужен). Или же в JWT хранится только один идентификатор пользователя, профайл которого запрашивается при каждом запросе из базы данных (на самом деле JWT имеет смысл, если Вы хотите уменьшить количество запросов в базу данных). Или, что еще более странно — сами JWT хранятся в базе данных.
Одной из объективных причин такого недопонимания роли и места JWT является то, что спецификация JWT описывает формат JWT, и ничего не говорит о том как JWT нужно применять.
Желание написать об этом у меня возникло уже давно. И после просмотра очередного проекта с немного странным использованием JWT я все же решился.
Начнем с самой главной демистификации. JWT может выглядеть вот так:
eyJhbGciOiJSUzI1NiJ9.eyJpcCI6IjE3Mi4yMS4wLjUiLCJqdGkiOiIwNzlkZDMwMGFiODRlM2MzNGJjNWVkMTlkMjg1ZmRmZWEzNWJjYzExMmYxNDJiNmQ5M2Y3YmIxZWFmZTY4MmY1IiwiZXhwIjoxNjA3NTE0NjgxLCJjb3VudCI6MiwidHRsIjoxMH0.gH7dPMvf2TQaZ5uKVcm7DF4glIQNP01Dys7ADgsd6xcxOjpZ7yGhrgd3rMTHKbFyTOf9_EB5NEtNrtgaIsWTtCd3yWq21JhzbmoVXldJKDxjF841Qm4T6JfSth4vvDF5Ex56p7jgL3rkqk6WQCFigwwO2EJfc2ITWh3zO5CG05LWlCEOIJvJErZMwjt9EhmmGlj9B6hSsEGucCm6EDHVlof6DHsvbN2LM3Z9CyiCLNkGNViqr-jkDKbn8UwIuapJOrAT_dumeCWD1RYDL-WNHObaD3owX4iqwHss2yOFrUfdEynahX3jgzHrC36XSRZeEqmRnHZliczz99KeiuHfc56EF11AoxH-3ytOB1sMivj9LID-JV3ihaUj-cDwbPqiaFv0sL-pFVZ9d9KVUBRrkkrwTLVErFVx9UH9mHmIRiO3wdcimBrKpkMIZDTcU9ukAyaYbBlqYVEoTIGpom29u17-b05wY3y12lCA2n4ZqOceYiw3kyd46IYTGeiNmouG5Rb5ld1HJzyqsNDQJhwdibCImdCGhRuKQCa6aANIqFXM-XSvABpzhr1UmxDijzs30ei3AD8tAzkYe2cVhv3AyG63AcFybjFOU8cvchxZ97jCV32jYy6PFphajjHkq1JuZYjEY6kj7L-tBAFUUtjNiy_e0QSSu5ykJaimBsNzYFQ
Если его декодировать base64url — миф о "секретности" сразу же разрушается:
{"alg":"RS256"}{"ip":"172.21.0.5","jti":"079dd300ab84e3c34bc5ed19d285fdfea35bcc112f142b6d93f7bb1eafe682f5","exp":1607514681,"count":2,"ttl":10} O2Mrn%!OPzN{hk11l\9Mkd Z&?WJP%^DA8?*X??|!?C&D0Di?Ak
nue7b?B 6AV*9)S.jNv `EcG9?*6kQDv_xz?Edgbs<wP(?"?K ?WxiHp<>,/EU]T?-Q+\}Pfbu?
7??ZTJ jhon??-v 6j9u
:!z#fEewQ*44 bl"&t!F ?*s>]+U&8z-@Fap2p\S?}0h?Ty*?b1H/A?U3bA$) j)
Первая часть в фигурных скобках называется JOSE Header и описана https://tools.ietf.org/html/rfc7515. Может содержать поля, из которых наиболее важное alg. Если задать {"alg":"none"} — токен считается валидным без подписи. И таким образом к Вашему API может получить доступ любой с токеном, сформированным вручную без подписи. В настоящее время большинство библиотек отвергают такие токены по умолчанию, но все же проверьте свои API на всякий случай.
Вторая часть в фигурных скобках — это полезная нагрузка. Немного неудобно, что в этой части размещаются и пользовательские данные, и стандартные заголовки, например jti — идентификатор токена и exp — срок окончания действия токена. Такие поля как срок окончания действия токена, срок начала действия токена могут проверяться или не проверяться библитеками для работы с JWT — в последнем случае придется в явном виде проверять срок действия на уровне приложения.
И, наконец, подпись. Подпись может быть сформирована при помощи разных алгоритмов. Наиболее удобно, если алгоритмы основаны на паре ключей (публичном и приватном). Тогда проверку подписи можно проводить публичным ключом, который можно без опасения распространять между серверами, принимающими JWT.
Таким образом JWT — это просто текст JSON, имеющий криптографическую подпись.
"Открыв" этот факт, можно поставить вопрос, в каких случаях рационально или не рационально использовать JWT. Можно предположить два основных кейса (если кто-то найдет еще — готов добавить в текст).
Микросервисы. Данные (любые не обязательно авторизация, а например "корзина" с товарами и зафиксированными ценами товаров) формируются и подписываются на одном микросервисе, а используются на другом микросервисе, который проверяет подпись токена публичным ключом.
Авторизация. Этот кейс может быть полезен и для монолита, если нужно сократить количество запросов в базу данных. При реализации "традиционной" сессии каждый запрос API генерирует дополнительный запрос профайла пользователя к базе данных. С JWT все, что берется в базе данных — помещается в JWT и подписывается.
Поняв это, мы поймем на какое время нужно выпускать JWT — на то время, которое не будет чувствительно, если в исходной информации что-то поменялось, а токен еще действует.
Все же есть случаи, когда информация слишком чувствительна, и нужно иметь возможность аннулировать JWT еще до окончания его срока действия. Для этого формируется реестр (например в Redis) аннулированных токенов. Поскольку время действия токена небольшое — реестр будет также небольшим, если удалять из реестра просроченные токены.
Следующий вопрос. Если время жизни JWT небольшое, то как обновлять JWT после окончания срока действия (это актуально для авторизационных токенов)? Для этого предлагается использовать второй, "условно-постоянный" токен с более продолжительным сроком действия. По истечении срока действия авторизационного токена его обновляют с использованием "условно-постоянного" токена.
Есть рекомендации обновлять "условно-постоянный" токен вместе с выдачей нового авторизационного токена. В этом случае некоторые рекомендуют аннулировать "условно-постоянный" токен сразу после его использвания, делая такое использование однократным. В последнем варианте, сессия может прерваться, если сервер зафиксирует выдачу нового токена, но из-за сетевых проблем этот токен не будет получен клиентом.
Есть одна довольно интересная проблема с аннулированием "условно-постоянных" токенов. Если их выпустить бессрочными — то в случае аннулирования их придется хранить также бессрочно. Если выпускать их на какой-то период — то будет происходить разлогин из "вечной" сессии, если за время действия токена не будет обращения к API.
apapacy@gmail.com
9 декабря 2020 года
Rsa97
Таким образом, это будет не реестр конкретных токенов, а запись о том, что все токены конкретного пользователя, выданные ранее определённого времени, надо считать недействительными.
ultrinfaern
Зачем хранить все выданные токены, если внутри них есть дата окончания и они подписаны. Вот именно, что можно хранить только НЕИСТЕКШИЕ и ОТОЗВАННЫЕ токены. Как только у отозванного токена истекает время его можно безболезненно удалить из хранилища.
С другой стороны обычно из-за безопасности хранят все токены. Это нужно для того, чтобы при запросе пользователя о разлогинивании отовсюду, все активные токены сразу отозвать. (Или логика от обратного — если токена нет в базе активных, значит он отозван).
apapacy Автор
Разлогин это отдельная тема. Бывает нужно ещё иметь возможность разлогинить с одного устройства все сессии. Тут мне кажется целесообразно все же хранить id сессии. И удалять при разлогине. В реестр аннулированных будет внесен такой sid а не userId
ultrinfaern
Внутри токена всегда должен быть jti — уникальный идентификатор этого токена, сохранять нужно только его.
apapacy Автор
Смысл сообщения что сохранять вообще ничего не нужно. Зачем нужно сохранять jti? Что с ним делать дальше?
ultrinfaern
По простому — это и есть sid сессии.
apapacy Автор
У самого была такая мысль. Но все же один sid может быть у нескольких токенов которые переиздаются по окончанию срока действия. И тогда не совсем правильно называть его идентификатором. Если в системе выдается на один логин один бессрочный токен то сессия как бы и идентификатор это одно и то же
Myateznik
Мне кажется в стандарте явно не указаны примеры "правильных" использований JWT т.к. это своего рода просто стандартизированный контейнер. А сама реализация, набор данных и т.д. — является инженерной задачей, относящейся к конкретной сфере применения. Есть разные задачи с разными подходами применения JWT и наборами данных.
Например, если речь идёт о коммуникации machine-to-machine (по своей сути микро-сервисы), очевидно, что JWT должны работать на парах ключей, для возможности проверки токена получающей стороной, без уведомления о проверке отправляющей стороны или любой третьей стороны и т.д. Ещё в таких случаях имеет смысл использовать время начала действия токена и наименьшее возможное время жизни токена. Полезная нагрузка токена зависит от назначения сервисов, цели применения токена и т.д.
Если используется не минимальное возможное время жизни токена и/или необходимо иметь возможность отзывать токены, то без реестра в том или ином виде не обойтись. Для таких задач существует jti (идентификатор токена), кстати особых рекомендаций по поводу данных этого поля тоже нет (об этом ниже). Т.е. сами токены хранить не нужно, нужно хранить только их идентификаторы. Важно, что jti не является обязательным полем для всех токенов, решение о его применении принимается для конкретного случая.
Реестр может быть тоже по разному реализован: это может быть Redis с открытым/закрытым доступом, централизованный сервис для проверки валидности токенов (не обязательно выдающий эти токены) или даже открытый, периодически обновляемый файл с перечислением идентификаторов отозванных или активных токенов (в таком случае принимающая сторона может сама проверить отозван ли токен, запросив свободно распространяемый файл с идентификаторами токенов, по принципу проверки подписи через публичные ключи). Очевидно имеет смысл вести реестр именно отозванных токенов (наименьший объём хранимых данных).
Можно например сделать отдельный сервис отозванных токенов с двумя точками входа: точкой отзыва токена и открытый, обновляемый файл с идентификаторами отозванных токенов или точкой проверки наличия в реестре идентификатора. При запросе на отзыв токена необходимо передать либо отзываемый токен, либо идентификатор отзываемого токена с какой-то подписью для авторизации операции отзыва (вот ещё один возможный пример использования JWT для отзыва других токенов JWT).
Если мы рассматриваем JWT относительно аутентификации пользователей, то без сессий нам вероятно не обойтись. В большинстве случаев аутентификация пользователей завязана на сессии и альтернатив нет. В таком случае можно действительно обойтись и без JWT, но это не значит, что использование JWT с сессиями является не верным использованием JWT.
В таком случае имеет смысл сохранять идентификатор такой сессии в поле jti (вспоминаем, что особых рекомендаций по этому полю нет), таким образом jti (а точнее sid) будет идентифицировать не конкретный токен, а скорее группу связанных токенов. Так же в такой реализации у нас уже будет иметься реест действительных токенов (фактически реестр сессий), причём этот реестр по объёму будет где-то по середине между реестром всех активных токенов и реестром отозванных токенов. Для отзыва всех токенов сессии достаточно будет обновить идентификатор сессии или пересоздать её в реесте.
Полезная нагрузка такого токена зависит опять от сферы применения, целей и т.д. Единственное и главное, полезная нагрузка не должна содержать чувствительных данных и желательно не должна содержать часто обновляемых данных (частота обновления выше частоты смены токенов).
В итоге "правильных" вариантов использования нет, есть только рекомендации. Каждый разработчик должен сам придумать правильный вариант использования JWT для своих целей, желательно опираясь на рекомендации.
Мои примеры тоже не претендуют на единственно верные. Действительно для некоторых случаев использование JWT не даёт никаких преимуществ, возможно просто увеличивает трафик. Однако это вопрос уменьшения объёма трафика или применения стандартизированного контейнера.
apapacy Автор
Правильных вариантов нет, но есть неправильные. Неправильный вариант это такой когда разработчик не может сам себе объяснить почему он использует JWT а не сессионный хэш. Чувствтельные данные в JWT хранить не только можно но и нужно. Параллельно необходимо продумать систему отзыва токена до выхода срока его действия.
Myateznik
Дело в том, что неправильных тоже нет.
В конце своего сообщения я указал пример выбора решения использования JWT не смотря на то, что он увеличивает объём трафика и может показаться более жирным чем тупо id + подпись. Главный аргумент — стандартизованный контейнер, об этом даже в самом стандарте написано. JWT это платформо-независимый контейнер токенов, со статусом стандарта. В base64 все вполне умеют в криптографические подписи тоже и в JSON тоже. Его сделали, специально таким гибким и универсальным ведь в большинстве случаев разработка своего формата токенов является задачей сложной, мало сформулировать формат, нужно его внедрить и поддерживать.
Если речь идёт о ваших внутренних сервисах, которые разрабатываете вы лично или небольшая команда, очевидно id + подпись вам будет использовать выгоднее (правда не всегда). Но если речь заходит о том, что это экосистема разрабатываемая огромным количеством независимых людей и взаимодействие не ограничивается только лишь вашими сервисами, то JWT тут побеждает как минимум имея статус стандарта, гибкостью и уже имеющейся интеграцией и поддержкой.
Потом, в моём примере про хранение сессионного id в jti я не писал, что в токене должен храниться только jti, вы можете вполне туда добавить не чувствительные данные (настоятельно подчёркиваю, что не чувствительные данные) пользователя (например логин, аватар, возможно имя и т.д.), в том числе чтобы снизить нагрузку на базу (это к слову является одним из примеров использования в самом стандарте).
Вот с этим поспорю и очень даже сильно. Чувствительные данные в токене вы можете хранить, если используете специальную версию JWE с шифрование, но не JWT. JWT является открытым форматом и именно по этому в полезной нагрузке подчёркнуто нельзя хранить чувствительные данные. Отзыв токена не влияет на доступ к этим данным. Они опубликованы открытым текстом (если не учитывать b64). И этот токен может сохраняться где угодно и сколько угодно и вы даже не будете об этом знать. Данные в нём соответственно тоже. Именно по этому в JWT должны храниться только не чувствительные данные.
Не во всех сферах и реализациях это необходимо, опять таки мой пример варианта реализации сервиса отзывов JWT. Я там упомянул про использование JWT для отзыва других JWT.
А мысль следующая в JWT в поле субъекта указываете id отзываемого токена, время жизни создаваемого токена для авторизации отзыва минимальна, но достаточна на совершение операции. Подпись такого токена производится закрытым ключём клиента, имеющего возможность отзывать токены. Публичный ключ клиента сервису отзывов так или иначе известен. Подпись в данном случае совместно с идентификатором клиента (в поле полезной нагрузки) являются аутентификационными данными клиента, запрашивающего отзыв токена. Причём в авторизационном токене хранить jti не нужно, его просто на просто не нужно отзывать. Сама операция отзыва может быть произведена явно только один раз, т.к. на последующие запросы с этим токеном мы можем ничего не делать (в реестре уже будет идентификатор отзываемого токена). Тут уже помогает время жизни авторизационного токена, можно ещё в нём указать время жизни отзываемого токена.
Обращаю внимание реализация зависит от нужд, ни что не является обязательным, если в стандарте явно не указано об этом и не аргументировано.
apapacy Автор
Чувствительные данные могут быть к разным параметрам. Я говорю данных чувствительных к изменениям.
О том что в стандарте не заданы правильные варианты использования JWT я начал в первых абзацах своего сообщения. То что использование любого средства может быть правильным, неправильным и дико неправильным — мне представляется довольно естественным. Тема с JWT выделяется тем, что примеров дико неправильного использования настолько много, что явно выше 50х50,
Myateznik
Вот в этом мы разошлись, я под словом "чувствительные" понимал данные, попадание которых в открытый доступ является проблемой безопасности в том или ином виде. Ровно так же про чувствительность данных в полезной нагрузке трактуется в стандарте, там есть ремарка по поводу использования JWE для этих целей.
Тут вопрос скорее такой, что считать правильным, а что не правильным. JWT даёт возможность самому исполнять для конкретной задачи конкретный вариант реализации. Причём иногда этот вариант со стороны может выглядеть как "дико неправильный", но это со стороны и смотря только на верхушку айсберга, что внизу вы явно не знаете.
В конце концов то, что сейчас считается не правильным, через несколько лет может оказаться верным.
dmitry_dvm
Для этого лучше использовать Reference-токены.
maximw
Даже если пользователь разлогинивается отовсюду, не обязательно хранить все токены.
Достаточко хранить id пользователя и последнее время разлогина. Все токены этого юзера выпущенные до этого времени считать недействительными.