Сервис хранения ключей
Каждый крупный проект имеет систему аутентификации и авторизации. Аутентификация необходима только для того, что бы утвердить - данный клиент является владельцем некоторой учетной записи. Таким примером может служит некий пользователь мобильного телефона, который зашел на некоторый сайт и представился (отправив логин) Иваном Ивановым, а для подтверждения того, что он дейсвтительно Иван Иванов - предоставил к логину еще и пароль. На этом аутентификации завершена. Авторизация же представляет собой процесс, который разрешает или запрещает данному конкретному пользователю ( аутентифицированному пользователю) доступ к данному конкретному действию. Так, после аутентификации, наш Иван Иванов пытается создать новый пост у себя в блоге и данное действие проверяется системой авторизации (авторизует Ивана Иванова на создание записи в своем блоге). В случае, если Иван Иванов попытается создать пост на странице Елены Ольговны - система авторизации откажет ему, так как полномочий для создания поста от имени другого пользователя недостаточно. Возможно, система авторизации разрешит подобное действие, если Иван Иванов получит права администратора, которые разрешают выполнять подобные операции, однако это зависит от конкретной системы управления правами доступа.
В примерах выше были рассмотрены пользователи - люди. Однако пользователями могут быть так же и программы. Так, например, при необходимости отправлять твиты в автоматическом режиме, разработчик от своего имени создаст приложение в интерфейсе разработчика Твиттера, получит ключи и с их помощью будет отправлять запросы к API Твиттера, добавляя в каждый из них ключ своего приложения.
Два данных типа пользователя (человек и программа) имеют некоторые отличия. Самое главное из них - программа не участвует напрямую в процессе аутентификации. Это происходит потому, что программа уже аутентифицирована. Ключ, который она добавляет во все свои запросы выписан на ее конкретное имя, и в отличие от токена (jwt/sessionID) пользователя( человека), программа имеет токен, который живет условно бесконечно (все зависит от конкретного провайдера API: у кого-то токен бесконечный, у кого-то очень долгоживущий - от года до 1000 лет (google service account) ). В свою очередь человек обязан каждый раз представляться собой, а так же периодически обновлять свой токен во время пользования системой.
Зачастую, в современном интернете в качестве ключей пользователей-человеков выступает jwt. Это довольно удобно, особенно в разрезе инфраструктуры, которая построена по принципу SOA. Всем сервисам, для проверки ключа запроса необходимо только проверять его подпись и граници действия ключа (TTL). Для этого каждому сервису необходимо передать только публичную часть ключа через систему конфигурации. На каждый запрос пользователя с JWT токеном, данный конкретный сервис проверит его подпись при помощи публичного ключа и срок жизни токена. Если все ок - данный сервис может быть уверен в достоверности информации, находящейся в ключе (на минималках ключ содержит идентификатор пользователя).
Подобная система полностью развязывает взаимодействие системы аутентификации, авторизации, а так же взаимодействие конечных сервисов с сервисом “сессий”. Сервиса сессий более не существует, все проверки происходят локально. За это необходимо платить некоторую цену. Самая дорогая из них - продолжительность жизни токена. Дело в том, что после создания JWT токена в процессе аутентификации пользователя, токен получает срок жизни (для человеческих сессий обычно измеряется в минутах), в течение которого токен никак не может быть отозван. Это означает, что при необходимости заблокировать доступ некоторому пользователю, адимнистратор будет вынужден дождаться окончания срока действия данного токена. До момента окончания срока действия - токен может быть полноправно и без ограничений использован во всех подсистемах.
Если представить, что токеном приложения является JWT или подобный механизм (прим. google cloud), то встает вопрос - что делать, если нам все-таки необходимо максимально быстро отозвать(заблокировать) данный токен? Для удобства далее все примеры будут описаны в рамках JWT, однако вместо него может быть что угодно, что имеет подобные свойства (некоторые данные, подписанные или зашифрованные приватным ключом).
Устройство JWT#
Вернемся на шаг назад - к JWT и механизму его работы. Это понадобится как бекграунд для решения проблемы.
JWT состоит из трех блоков: блок полезной нагрузки (туда мы записываем то, что хотим передать другим сервисам, например идентификатор пользователя или приложения), блока подписи (при помощи приватного ключа мы подписываем токен, который можно будет проверить только при помощи публичного ключа), блока метаинформации (собственно, описание того, каким алгоритмом была создана подпись данного токена).
Зачастую, блок с подписью формируется на основе приватного ключа, переданного в конфигурации приложения. Данный ключ используется для подпись всех токенов, создаваемых данным сервисом аутентификации. Публичная же часть данного ключа рассылается на этапе деплоя остальных сервисов, которые используют ключ для проверки подписи токена. Соответственно, если изменить приватный ключ и подписать токен именно им, то все остальные сервисы будут отвергать данный токен (их публичные ключи не связаны с новым приватным, как резульат - ошибка проверки подписи). Действует и обратное правило. Если некоторые или все сервисы получат новый публичный ключ - токены так же будут отвергаться, так как подпись будет невалидна.
Ключи для ключей#
Имея на руках эту информацию можно изменить нашу систему, добавляя в блок полезной нагрузки нашего токена информацию о ключе, которым данный токен был подписан. При создании подписи для токена, система добавит в payload идентификатор приватного ключа, и подпишет им данный токен. Система, которая проверяет данный токен, получит публичную часть ключа для данного токена и проверит его подпись.
Что это дает? Теперь каждый токен, который мы выпустили - имеет связь с некоторым приватным ключом. Для простоты представим, что каждый новый токен имеет свой отдельный приватный ключ. Если нам потребуется срочно отозвать токен ( токены) некоторого пользователя (Ивана Иванова или же некоторой компьютерной программы), мы найдем все ключи, которые были использованы дляподпись токенов данного пользователя и просто удалим их из системы. При попытке доступа к некоторому сервису с данным токеном, данный сервис не найдет публичную часть ключа у себя и отбросит токен как токен, с невалидной подписью.
Теперь мы получили возможность разлогинивать пользователей почти моментально, повысили безопасность системы (каждый токен имеет уникальный ключ, скомпроментировать токены стало на порядок сложнее, имеем возможность ротировать ключи в любой момент). Получили так же бонус ввиде того, что нет необхоимости распространять публичную часть ключа во все сервисы на этапе деплоя (развязали конфиги). Однако получили и проблему - в описанной схеме, каждый проверяющий сервис должен часто ходить в сервис ключей, для проверки наличия данного ключа (чем это вообще отличается от механизма " сессий"?). Для устранения проблемы необходимо добавить механизм распространения ключей в режиме push (от сервиса ключей ко всем сервисам, занимающимся проверкой ключей).
Доставка обновлений ключей#
Каждый раз, когда создается новый ключ, удаляется или протухает - сервис ответственный за ключи (назовем его keychain) рассылает оповещение всем, кому это интересно. Схем распространения может быть великое множество, и идеального решения нет. Проанализируем, что получается.
Каждый ключ имеет уникальный идентификатор. При затрагивании данного ключа, его идентификатор, действие (create/revoke) и ttl рассылается всем заинтересованым сервисам (прим через kafka). Приоритет на отзыв ключей. Все сервисы получают информацию о идентификаторе и меняют свое состояние, добавляя информацию об активных идентификаторах и удаляя информацию об устаревших. Хранение толькко идентификаторов ключей позволит быстро проверять токен на валидность по хеш таблице. При наличии идентификатора ключа в хеш таблице и отсутствии самого ключа - необходимо получить ключ и сохранить его в памяти на некоторое время.
Все выглядит просто и отлично, однако если посчитать сколько это будет стоить для большого проекта, то получим сотни мегабайт. Так, если у вас 10_000_000 ключей (на каждого пользователя), идентификатор ключа занимает 4 байта (uint32), ttl занимает 8 байт (unix timestamp - uint64), получаем (10000000*(4+1+8))/(1024^2) = 124 MB. И это только идентификаторы ключей. К тому же, копии этой таблицы должны находиться в каждом сервисе.
Решить проблему можно со стороны конечного сервиса. Некоторым сервисам (или в некоторых случаях - всем сервисам) можно не хранить эту таблицу вовсе, обрабатывая информацию только об отозванных ключах, а для проверки обращаться к хранилищу ключей только в момент первой проверки. Некоторые сервисы вообще не получают запросов от пользователя и могут не хранить ключ постоянно.
Так же решить проблему можно и со стороны сервиса аутентификации. Сервис аутентификации может более грамотно использовать сервис ключей, переиспользуя один и тот же ключ для всех сессий пользователя или для целых групп пользователей. Конечно, в данном случае нужно заплатить тем, что некоторых пользователей может разлогинить вместе со злоумышленником. Но часто ли такое бывает? Есть ли у вас вообще такой механизм как моментальный разлогин? Возможно, вам достаточно только короткоживущих access токенов, а разлогином будет запрет на использование refresh токена по его идентификатору?
Еще одним вариантом может быть прокси сервис (API Gateway) с прозрачной аутентификацией всех запросов к сервисам. Gateway может потратиться на хранение всех идентификаторов и всех ключей для проверки запросов, а после проверки пробрасывать запрос в конкретный сервис уже без токена, но с авторитетной информацией об идентификаторе пользователя.