Взгляд на JWT как на инструмент построения стройной архитектуры приложения
Три года назад мне поручили задачу по распилу сразу двух монолитов с вынесением в общий микросервис данных пользователей и созданием единого механизма аутентификации и авторизации. Конечной целью этого действа было создание надежной базы для перехода всей разработки в команде на микросервисную архитектуру.
Тогда-то я впервые и применил на практике JWT, результатами чего остался очень доволен. В этой статье я хочу поделиться своим опытом работы с этим стандартом на конкретных примерах и рассказать, почему он удовлетворяет все мои потребности как разработчика в самых разных проектах: новых и долгоживущих, микросервисных и монолитных, в ситуациях, когда нужно быстро накидать MVP, и когда требуется не спеша и вдумчиво заложить основу на долгие годы качественной разработки.
Как мы обычно делаем Security
До того, как поставленная задача вынудила меня использовать JWT, я реализовывал Security по стандартному flow, описываемому в документациях фреймворков.
Как правило, первым шагом мы описываем класс User, который несет в себе атрибуты, необходимые для принятия решения о доступе. Эти атрибуты могут помещаться:
- в плоской структуре самого класса User (просто делаем поле role и подобные поля);
- в агрегируемых юзером классах, таких как Profile;
- в смежных классах, ссылающихся на User, например, классе Manager, описывающем уровни и полномочия менеджеров.
Далее мы описываем систему ролей, пермишенов и правил контроля доступа. Иными словами, пишем логику авторизации: тот код, в котором происходит принятие решений о доступе на основании атрибутов пользователя и других параметров, таких как совершаемое действие, атрибуты ресурса, над которым совершается действие и т. п.
После этого мы связываем тем или иным способом компонент фреймворка Security с нашим классом User и кодом, содержащим логику авторизации. Детали здесь уже зависят от фреймворка и подхода разработчика, но концептуально механизм выглядит плюс-минус одинаково:
- Бэкенд получает некие credentials: токен, пару логин-пароль или сессионные куки.
- По этим credentials из хранилища - чаще всего реляционной БД - извлекается инстанс нашего класса User. Этот инстанс обычно доступен через компонент фреймворка Security, то есть, в коде мы можем сделать что-то вроде $security->getLoggedUser().
- В зависимости от запрашиваемого действия (чаще всего это выполнение того или иного контроллера) вызывается определенный код проверки прав доступа, куда передается либо весь User, либо его отдельные атрибуты.
- Если код авторизации не прервал выполнение - то есть, было принято положительное решение о доступе - продолжается обычная работа приложения: выполняется запрашиваемое действие.
Недавно я писал о том, как вот такая работа с Security превращается в боль и о том, какими средствами можно этому противодействовать. Одним из таких средств как раз является JWT.
Схемы применения этого стандарта различаются для монолита и микросервисов. Поэтому сейчас я поделюсь с вами как монолитным, так и микросервисным кейсом применения JWT. Но прежде мне бы хотелось остановиться на тех свойствах JWT, которые делают его таким полезным для построения более качественной архитектуры приложения.
Два полезных свойства JWT
Когда я прочитал некоторое количество из бесчисленных материалов о том, что такое JWT, из каких частей он состоит и т. д., мне показались ключевыми два его свойства: автономность и способность нести payload.
На самом деле, речь идет о двух сторонах одной медали: JWT автономен потому, что он несет payload. Чем это нам помогает? Прежде всего тем, что JWT может и должен содержать в себе всю необходимую для принятия решения о доступе информацию. Иными словами, если ваше приложение получило JWT, оно уже не должно дергать юзера из базы или (упаси Боже) из соседнего микросервиса.
Первая выгода, которую несет автономность JWT, лежит на поверхности: уменьшается количество запросов к базе и/или в соседние микросервисы. Но, когда я начал внедрять JWT, то осознал и другую выгоду. Стандарт как бы навязал мне более правильную архитектуру.
Когда я начал думать о том, как мне сформировать payload для моего JWT, то быстро осознал: payload JWT - это не мой доменный User. Это отдельная структура, которая может содержать в себе данные не только из класса User, но и из других источников. Именно возможность вынести в JWT все, что необходимо для работы Security, ощутимо развязывает руки при проектировании нашего приложения.
Конечно, у автономности JWT есть и оборотная сторона: однажды выданный токен не может быть отозван и продолжает действовать пока не протухнет. На самом деле, это решаемая проблема. Некоторые решают ее через сохранение отозванных JWT, но это в корне неверно. Свое решение я опишу в конце статьи.
Теперь, когда мы разобрались, чем JWT так полезен, давайте я поделюсь своим опытом использования его в монолите и микросервисах.
JWT в монолите: делаем несвязанный слой Security
При проектировании Security приложения я исхожу из того, что JWT payload обязан содержать всю необходимую для принятия решения о доступе информацию. Если следовать этому правилу, то логично предположить, что нам потребуется класс, несущий эту инфромацию, и класс, собирающий ее (нет, не фабрика, фабрика - это другое, и хватит называть этим словом любой порождающий код 😜).
Если класс, необходимый для JWT payload, несет в себе все, что нужно для авторизации, то логично завязать всю работу Security фреймворка именно на этот класс. Я обычно именую этот класс SecurityUser. Не так давно я уже писал о нем, приводя пример кода.
Если вы вынесете из доменного User все, что касается работы Security, то увидите, как ваш слой бизнес-логики стал чистым и независящим от инфраструктурных компонентов. В тоже время вы получите возможность более независимо развивать оба класса: Domain\User и SecurityUser. Давайте я покажу это на схемах.
На первой схеме изображено, как происходит аутентификация и выдача токена. Вы можете видеть, что класс Domain\User выступает только источником данных для SecurityUser и ничего не знает о слое Security. В то же время, слой Security, взяв необходимые данные из Domain\User, сразу же “забывает” о нем, никак с ним более не пересекаясь.
Я намеренно избегаю на схеме указаний на какой-либо конкретный фреймворк, приводя универсальное решение. В конце статьи я дам ссылку на пример кода с использованием Symfony, чтобы дополнить обе эти схемы.
На второй схеме изображено использование ранее выданного JWT, которым будут подписываться все запросы к нашему приложению. Как видите, в этой схеме вообще отсутствует доменный User. Мы десериализуем JWT payload в SecurityUser и далее весь слой Security фреймворка работает именно с этим классом, передавая его экземпляр в контроллер и код с логикой авторизации.
Под кодом с логикой авторизации может пониматься следующее:
- Код, который пишется непосредственно в контроллере, что-нибудь вроде симфонийского $this->denyAccessUnlessGranted().
- Код, выполняющийся до контроллера, например иишный beforeAction() или симфонийский Voter.
За последние три года я применил решение, изображенное на этих двух схемах, в нескольких проектах и остался им очень доволен, видя в нем следующие преимущества:
- Четкое разделение доменного и инфраструктурного слоев.
- Много точек расширения: классы Domain\User и SecurityUser можно развивать независимо. PayloadGenerator и JWTManager так же могут дорабатываться по мере необходимости.
- Авторизация по JWT совместима с любой формой аутентификации: по логин-паролю или, например, с помощью сторонних сервисов. В примере кода к этой статье показана аутентификация с помощью виджета Telegram.
- Если когда-либо потребуется переход с монолитной архитектуры на микросервисную, будь то распил монолита, или просто добавление новых сервисов, слой Security вашего приложения уже готов к этому. Вам ничего не придется переделывать.
- Разгружается хранилище: теперь не нужно в каждом запросе к приложению лазать в базу за данными, необходимыми для принятия решения о доступе.
Будучи полезным и удобным в монолитных приложениях, в микросервисах, я считаю, JWT становится просто незаменим. В качестве примера я приведу тот самый проект, о котором писал вначале статьи.
JWT в микросервисах: единый токен для всех запросов
Если говорить о технических деталях, то решение по применению JWT в микросервисах базируется на решении для монолитов. Выдача токена и его использование для авторизации происходит по тем же схемам. Отличие состоит в том, что за выдачу токена отвечает отдельный сервис, а использовать выданный токен для авторизации может любой из наших сервисов.
Ключевым моментом здесь является соблюдение все того же правила: JWT обязан нести всю, необходимую для принятия решения о доступе, информацию. Если это правило соблюдается, то любой ваш сервис или микросервис, получив запрос, подписанный JWT, сможет самостоятельно принять решение о доступе, не запрашивая данные пользователя: ведь он уже получил их из JWT payload.
Причем неважно, откуда поступает запрос к микросервису: с фронта, API-шлюза или из другого микросервиса. Главное, передавать в этом запросе JWT. То есть, если у вас есть цепочка запросов от одного сервиса к другому (что не очень гуд, но встречается повсеместно), то вы просто перекладываете JWT из одного запроса в другой.
В более сложной схеме логика авторизации сосредоточивается в отдельном сервисе (не в том, где данные пользователя). Такое возможно, если вы, например, реализуете ABAC.
В качестве примера приведу вам схему решения той задачи, о которой я писал вначале статьи: вынос из двух монолитов аутентификации и авторизации в отдельные сервисы.
На этой схеме изображено пошаговое прохождение HTTP запроса через несколько сервисов с использованием единого JWT.
- С фронта приходит запрос с логином и паролем на аутентификацию.
- API шлюз переадресует этот запрос в сервис “Паспорт”.
- “Паспорт” генерирует JWT (см. выше вторую схему из раздела о монолите).
- JWT возвращается фронту, фронт его запоминает.
- С фронта поступает подписанный JWT запрос на удаление рекламной кампании.
- Запрос переадресуется шлюзом в сервис кампаний.
- Сервис кампаний передает JWT из запроса в сервис ABAC для принятия решения о доступе.
- Сервис ABAC без обращения к “Паспорту”, опираясь только на данные из JWT payload, принимает положительное решение о доступе.
- Сервис кампаний удаляет кампанию и посылает запрос в сервис биллинга для пересчета баланса пользователя. Запрос к биллингу содержит все тот же JWT, полученный от фронта.
- Биллинг пересчитывает баланс и возвращает успешный ответ сервису кампаний.
- Сервис кампаний возвращает успешный ответ API шлюзу.
- API шлюз отправляет успешный ответ фронту.
В более простой конфигурации у вас не будет API шлюза и/или сервиса ABAC. Сервисы кампаний и биллинга будут самостоятельно принимать решения о доступе, используя данные из JWT payload, без обращения к “Паспорту”.
Плюсы такого решения по применению JWT в микросервисах те же, что и при применении в монолитах. В коде каждого сервиса вы получаете возможность развести доменные и инфраструктурные слои. Чтобы жилось еще проще, нужно вынести в отдельный composer пакет класс SecurityUser и остальные классы, необходимые для работы с JWT.
Этот пакет должен быть в зависимостях каждого вашего сервиса, которому требуется авторизация для выполнения каких-либо действий.
Вот, собственно, и все, чем я сегодня хотел поделиться с вам из своей практики. Как и обещал, в конце статьи опишу свое решение проблемы отзыва JWT и еще пары моментов, которые портят удовольствие от применения этого стандарта.
Решение проблем с обратной совместимостью и отзывом JWT
У главного плюса JWT - автономности - есть и оборотная сторона: однажды выданный токен обязан приниматься приложением до тех пор, пока не истек срок его действия. Это порождает две проблемы: обратной совместимости и отзыва токена.
Проблема обратной совместимости возникает тогда, когда мы расширяем наш SecurityUser новыми данными и дорабатываем код авторизации, который опирается на эти данные. Как только этот код попадает в прод, все выданные ранее токены начнут ронять приложение еще на этапе десериализации JWT payload с ошибкой Undefined array key или подобной.
Чтобы избежать этой проблемы, нам следует таким образом писать наш код десериализации JWT payload, чтобы при нехватке в токене каких-либо полей, клиенту возвращался отказ в доступе. Это может быть обычный ответ 401 или спецэффичный для данного конкретного случая ответ. Фронт, получив такой ответ, должен перезапросить актуальный JWT по refresh токену.
Что до проблемы отзыва токена, то она возникает, как правило, в двух случаях: пользователь был по тем или иным причинам заблокирован, или же изменились права доступа. Причем неважно, стало у пользователя больше прав, или меньше: все ранее выданные JWT теперь могут приводить к принятию неправильного решения о доступе.
В интернете я встречал решение проблемы отзыва JWT, основывающееся на сохранении выданных токенов. Мне такое решение мягко говоря не нравится, потому что оно нивелирует преимущества JWT. Ведь какой путь проходит информация, чтобы мы в итоге получили свой токен?
- Делается запрос в базу.
- Полученные данные гидрируются в сущность Domain\User.
- Из Domain\User данные переливаются в SecurityUser.
- Из SecurityUser данные переливаются в массив (нормализация, вот картинка, если что).
- Из массива данные перекидываются в JSON строку (кодирование, см. картинку из предыдущего пункта).
- К полученному JSON (который и есть payload) добавляются дополнительные поля, диктуемые стандартом JWT.
- Полученная структура подписывается.
- Все это добро передается в base64_encode().
Итого, что получается? Мы достаем данные из базы, вытворяем над ними все вот эти действия и… Кладем их обратно в базу. Так ведь мы проходили все эти шаги для того, чтобы больше не трогать базу по этому поводу. Я считаю, если разработчик решил сохранять JWT, то ему стоит подумать об отказе от JWT. Я говорю это без насмешки или иронии. Любое решение несет плюсы (дает выгоду) и минусы (имеет цену). Если вы отказываетесь от плюсов, то за что платите цену? Не проще тогда каждый раз дергать юзера из базы, минуя все остальные шаги, которые я перечислил выше?
Если нельзя сохранять весь токен, то что тогда делать? К сожалению, хранилище задействовать все-таки придется. Только вместо сохранения JWT мы будем хранить userId и timestamp, начиная с которого все выданные ранее токены считаются недействительными. Если вы фанат key-value хранилищ, то вам точно понравится такой подход.)
Работает это так: если права юзера были изменены, или юзер был заблокирован, мы сохраняем временную метку этого события вместе с id юзера. Теперь при каждом запросе, получив токен, мы достаем из хранилища по userId временную метку, начиная с которой все JWT этого пользователя считаются протухшими. Если в хранилище ничего нет, продолжаем работу. Если метка есть, сравниваем ее с timestamp в самом JWT. Если JWT выдан до хранимого нами таймштампа, то отвечаем отказом в доступе.
Согласитесь, хранить по одной метке на каждого юзера, чьи права изменились, лучше, чем хранить все выданные JWT для всех пользователей системы. Хранилище этих меток можно автоматически подчищать со временем, удаляя те метки, которые старше заданного вами срока действия JWT. Если вы используете key-value хранилище (что вовсе необязательно), то вы можете просто задавать TTL.
Обратите внимание: timestamp в самом JWT и в хранилище “меток протухания” должен быть с микросекундами. Это нужно для ситуаций, когда фронт посылает запрос, изменяющий права пользователя и следом посылает другой запрос. Например, когда пользователь вступает в некую группу, что открывает ему доступ к материалам этой группы.
Я сталкивался с проблемой, когда фронт посылал такой запрос и сразу же посылал следующий запрос на просмотр чего-либо. В ответ он ожидаемо получал 401: ведь все JWT юзера были отозваны. Получив отказ в доступе по протухшему токену, фронт мгновенно его перезапрашивал и стучался уже с новым токеном. И получал второй отказ: время в секундах у нового токена совпадало со временем, когда были изменены права пользователя. Добавление микросекунд к временным меткам решило эту проблему.
Заключение и пример кода
В заключение, пожалуй скажу пару слов о данных, которые вы складываете в JWT payload. Если вы подписываете свой токен (для этого лучше использовать проверенные библиотеки и не городить свой огород), то подменить какие-либо поля у злоумышленника не получится.
Если же вы переживаете, что злоумышленник раскроет структуру вашего проекта, узнав, например, некоторые значения из вашего enum Status, то вы можете заменить все строковые значения числовыми константами, а само поле внутри payload назвать не статусом, а бананом.
Лично я таким не занимаюсь, но периодически слышу от коллег, что как же так, в JWT payload можно глазками прочитать все поля, нехорошо это. Как будто в инструментах разработчика браузера нельзя сделать то же самое, читая запросы к бэку и ответы. Вы также можете использовать какое-нибудь запредельно простенькое обратимое шифрование всего вашего payload, чтобы защититься от прочтения его глазами. Непростенькое шифрование скушает время обработки запроса, что сведет на нет все преимущества JWT.
Последняя вещь, которую я хотел бы добавить - это то, что JWT payload со временем может разрастаться вплоть до того, чтобы упереться в лимит по размеру HTTP заголовка. Проблема решается все той же заменой строковых значений на числовые, что сделает JSON компактнее. Дополнительно вы можете зазиповать свой токен, например, с помощью bzcompress(). Это, опять же, защитит его от умников, которые попытаются сделать base64_decode(), и, раскрыв ваши енамы, взломают вам всю систему 😂.
Ну а пример кода, для фреймворка Symfony я выложил в специальном посте в своем канале. Что поделать, надо же как-то продвигаться.) Прошу вас, дорогие читатели, не серчать по этому поводу, а лучше подписаться на канал или, хотя бы, проголосовать за тему следующей статьи: согласитесь, чем больше читателей, тем интереснее мне, как автору, что-то писать для вас.)
Кстати, решение для симофни опирается на lexik/jwt-authentication-bundle, который реализует подписывание JWT SSL ключами и интеграцию с Symfony Security. Если вы не нашли для своего фреймворка похожей библиотеки, то я рекомендую вам позаимствовать классы из этого пакета и на их базе доработать собственное решение. Спасибо, что дочитали до конца.)