Стараясь оставаться в тренде и следуя веяниям моды веб разработки, последнее веб приложение я решил реализовать как набор микросервисов на ruby плюс “толстый” клиент на ember. Одна из первых проблем, вставших перед мной была связана с аутентификацией запросов. Если в классическом, монолитном, приложении все просто, используем куки, сессии, подключаем какой-нибудь devise, то тут все как в первый раз.
За базу я выбрал JWT — Json Web Token. Это открытый стандарт RFC 7519 для представления заявок (claims) между двумя участниками. Он представляет из себя структуру вида: Header.Payload.Signature, где заголовок и payload это запакованые в base64 json хэши. Здесь стоит обратить внимание на payload. Он может содержать в себе все что угодно, в принципе это может быть и просто client_id и какая-то другая информация о пользователе, но это не очень хорошая идея, лучше передавать там только ключ идентификатор, а сами данные хранить где-то в другом месте. В качестве хранилища данных можно использовать что угодно, но мне показалось, что redis будет оптимальным, тем более что он пригодится и для других задач. Еще один важный момент — каким ключем мы будем подписывать наш токен. Самый простой вариант использовать один shared key, но это явно не самый безопасный вариант. Коль скоро мы храним данные сессии в redis, ничто не мешает нам генерировать уникальный ключ для каждого токена и хранить его там же.
Понятно, что генерировать токены будет сервис отвечающий за авторизацию, но кто и как будет их проверять? В принципе можно проверку затолкать в каждый микросервис, но это противоречит идеи их максимального разделения. Каждый сервис должен будет содержать логику обработки и проверки токенов да еще и иметь доступ к redis. Нет, наш цель получить архитектуру в которой все запросы приходящие в конечные сервисы уже авторизованы и несут в себе данные о пользователе (например в каком-нибудь специальном заголовке).
Тут мы и подходим к основной части этой статьи. Нам нужен какой то промежуточный элемент, через который бы проходили все запросы а он их аутентифицировал, заполнял клиентскими данными и посылал дальше. В идеале сервис должен быть легковесным и легко масштабироваться. Очевидным решением будет NGinx reverse proxy, благо мы можем добавить к нему логику аутентификации с помощью lua скриптов. Если быть точным, то мы будем использовать OpenResty — дистрибутив nginx с кучей “плюшек” из коробки. Для пущей красоты реализуем все это в виде Docker контейнера.
Начинать полностью с нуля пришлось. Есть прекрасный проект lua-resty-jwt уже реализующий проверку подписи JWT. Там даже есть пример работы с redis кешем для хранения подписи, осталось только его допилить чтобы:
Результат работы можно найти тут: resty-lua-jwt
В nginx.conf нужно прописать в http секцию ссылку на lua пакет:
Теперь для того чтобы аутентифицироваться запрос осталось в секцию location довавить:
Запускаем все это дело:
И готово… ну почти. Надо еще положить в redis сессию и отдать клиенту его токен. jwt.lua плагин ожидает, что токен в своей Payload секции будет содержать хэш виа {kid: SESSION_ID}. В redis этому SESSION_ID должен соответствовать хэш как минимум с одним ключем secret, в котором находится общий ключ для проверки подписи. Еще там может быть ключ data, если он найдет то его содержимое уйдет в upstream сервис в заголовке X-Data. В этот ключ мы сложим сериализованый объект пользователя, ну или, как минимум, его ID, чтобы апстрим сервис понимал от кого же пришел запрос.
Для генерации JWT есть великое множество библиотек, полное описание тут: jwt.io В моем случае я выбрал jwt гем. Вот как выглядит action SessionController#create
Теперь в нашем UI (ember, angular или же мобильное приложение) нужно получить у authorization сервиса токен и передавать его во всех запросах в заголовке Authorization. Как именно вы это будете делать зависит от вашего конкретного случая, так что я приведу лишь пример с cUrl.
Логично будет поинтересоваться, есть ли готовые решения? Я нашел только Kong от Mashape. Для когото это будет неплохим варинатом, т.к. кроме разных видов авторизации он умеет работать с ACL, управлять нагрузкой применять ACL и много чего еще. В моем случае это была бы стрельба из пушки по воробьям. Кроме того он зависит от БД Casandra, которая, мягко скажем, тажеловата да и довольно чужеродна этому проекту.
P.P.S. Незаметно "добрые люди" слили карму. Так что плюсик будет очень кстати и будет хорошей мотивацией к написанию новых статей на тему микросервисов в веб разработке.
Архитектура
За базу я выбрал JWT — Json Web Token. Это открытый стандарт RFC 7519 для представления заявок (claims) между двумя участниками. Он представляет из себя структуру вида: Header.Payload.Signature, где заголовок и payload это запакованые в base64 json хэши. Здесь стоит обратить внимание на payload. Он может содержать в себе все что угодно, в принципе это может быть и просто client_id и какая-то другая информация о пользователе, но это не очень хорошая идея, лучше передавать там только ключ идентификатор, а сами данные хранить где-то в другом месте. В качестве хранилища данных можно использовать что угодно, но мне показалось, что redis будет оптимальным, тем более что он пригодится и для других задач. Еще один важный момент — каким ключем мы будем подписывать наш токен. Самый простой вариант использовать один shared key, но это явно не самый безопасный вариант. Коль скоро мы храним данные сессии в redis, ничто не мешает нам генерировать уникальный ключ для каждого токена и хранить его там же.
Понятно, что генерировать токены будет сервис отвечающий за авторизацию, но кто и как будет их проверять? В принципе можно проверку затолкать в каждый микросервис, но это противоречит идеи их максимального разделения. Каждый сервис должен будет содержать логику обработки и проверки токенов да еще и иметь доступ к redis. Нет, наш цель получить архитектуру в которой все запросы приходящие в конечные сервисы уже авторизованы и несут в себе данные о пользователе (например в каком-нибудь специальном заголовке).
Проверка JWT токенов в NGinx
Тут мы и подходим к основной части этой статьи. Нам нужен какой то промежуточный элемент, через который бы проходили все запросы а он их аутентифицировал, заполнял клиентскими данными и посылал дальше. В идеале сервис должен быть легковесным и легко масштабироваться. Очевидным решением будет NGinx reverse proxy, благо мы можем добавить к нему логику аутентификации с помощью lua скриптов. Если быть точным, то мы будем использовать OpenResty — дистрибутив nginx с кучей “плюшек” из коробки. Для пущей красоты реализуем все это в виде Docker контейнера.
Начинать полностью с нуля пришлось. Есть прекрасный проект lua-resty-jwt уже реализующий проверку подписи JWT. Там даже есть пример работы с redis кешем для хранения подписи, осталось только его допилить чтобы:
- вытягивать токен из Authorization заголовка
- в случае успешной проверки доставать данные сессии и посылать их в X-Data заголовке
- немного причесать ошибки, чтобы отдавался валидный JSON
Результат работы можно найти тут: resty-lua-jwt
В nginx.conf нужно прописать в http секцию ссылку на lua пакет:
http {
...
lua_package_path "/lua-resty-jwt/lib/?.lua;;";
lua_shared_dict jwt_key_dict 10m;
...
}
Теперь для того чтобы аутентифицироваться запрос осталось в секцию location довавить:
location ~ ^/api/(.*)$ {
set $redhost "redis";
set $redport 6379;
access_by_lua_file /lua-resty-jwt/jwt.lua;
proxy_pass http://upstream/api/$1;
}
Запускаем все это дело:
docker run --name redis redis
docker run --link redis -v nginx.conf:/usr/nginx/conf/nginx.conf svyatogor/resty-lua-jwt
И готово… ну почти. Надо еще положить в redis сессию и отдать клиенту его токен. jwt.lua плагин ожидает, что токен в своей Payload секции будет содержать хэш виа {kid: SESSION_ID}. В redis этому SESSION_ID должен соответствовать хэш как минимум с одним ключем secret, в котором находится общий ключ для проверки подписи. Еще там может быть ключ data, если он найдет то его содержимое уйдет в upstream сервис в заголовке X-Data. В этот ключ мы сложим сериализованый объект пользователя, ну или, как минимум, его ID, чтобы апстрим сервис понимал от кого же пришел запрос.
Логин и генерация токенов
Для генерации JWT есть великое множество библиотек, полное описание тут: jwt.io В моем случае я выбрал jwt гем. Вот как выглядит action SessionController#create
def new
user = User.find_by_email params[:email]
if user && user.authenticate(params[:password])
if user.kid and REDIS.exists(user.kid) > 0
REDIS.del user.kid
end
key = SecureRandom.base64(24)
secret = SecureRandom.base64(24)
REDIS.hset key, 'secret', secret
REDIS.hset key, 'data', {user_id: user.id}.to_json
payload = {"kid" => key}
token = JWT.encode payload, secret, 'HS256'
render json: {token: token}
else
render json: {error: "Invalid username or password"}, status: 401
end
end
Теперь в нашем UI (ember, angular или же мобильное приложение) нужно получить у authorization сервиса токен и передавать его во всех запросах в заголовке Authorization. Как именно вы это будете делать зависит от вашего конкретного случая, так что я приведу лишь пример с cUrl.
$ curl -X POST http://default/auth/login -d 'email=user@mail.com' -d 'password=user'
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJraWQiOiI2cDFtdFBrVnhUdTlVNngxTk5yaTNPSDVLcnBGVzZRUCJ9.9Qawf8PE8YgxyFw0ccgrFza1Uxr8Q_U9z3dlWdzpSYo"}%
$ curl http://default/clients/v1/clients -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJraWQiOiI2cDFtdFBrVnhUdTlVNngxTk5yaTNPSDVLcnBGVzZRUCJ9.9Qawf8PE8Ygxy
Fw0ccgrFza1Uxr8Q_U9z3dlWdzpSYo'
{"clients":[]}
Послесловие
Логично будет поинтересоваться, есть ли готовые решения? Я нашел только Kong от Mashape. Для когото это будет неплохим варинатом, т.к. кроме разных видов авторизации он умеет работать с ACL, управлять нагрузкой применять ACL и много чего еще. В моем случае это была бы стрельба из пушки по воробьям. Кроме того он зависит от БД Casandra, которая, мягко скажем, тажеловата да и довольно чужеродна этому проекту.
P.P.S. Незаметно "добрые люди" слили карму. Так что плюсик будет очень кстати и будет хорошей мотивацией к написанию новых статей на тему микросервисов в веб разработке.