Архитектура
За базу я выбрал 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. Незаметно "добрые люди" слили карму. Так что плюсик будет очень кстати и будет хорошей мотивацией к написанию новых статей на тему микросервисов в веб разработке.
Комментарии (16)
Kolyuchkin
22.02.2016 02:21Автор, исправьте ошибку в первом слове заголовка статьи… пожалуйста.
shuron
22.02.2016 15:14Он может содержать в себе все что угодно, в принципе это может быть и просто client_id и какая-то другая информация о пользователе, но это не очень хорошая идея
Немоглибы пояснить почему?
как я понимяю JWT это был-бы иделаьный варинат для микросервисов… А дополнительная валидация в nginx какрыз костыль…svyatogor
22.02.2016 18:18Идея в том, что аутентификация и проверка токена это не есть непосредственная задача микросервиса. Пускай этим занимается промежуточный сервис. Кроме того конкретному микросервису как правило нужно очень мало информации о клиенте, ID в принципе для большинства задач хватит, но не хочется это все в токене хранить, не всегда это нужно пользователю знать. и на каждый чих пых дергать auth сервис тоже не здорово.
shuron
23.02.2016 00:58Идея в том, что аутентификация и проверка токена это не есть непосредственная задача микросервиса.
Ну это понятно, но какой-то boilerplate есть всегда темболее что в данном случае это решает какая-нибудь либа.
Утрировано проблема лишней строчки кода помоему не страшно…
Далее не всем сервисам нужда аутентикация/авторизация в принципе… но тем которым нужна нужна скорее всего достаточно псецифически (именно авторизация). Так что куда-то делегироваТь эту задачу мене кажется далеко не суперской идеей.
Кроме того конкретному микросервису как правило нужно очень мало информации о клиенте, ID в принципе для большинства задач хватит, но не хочется это все в токене хранить, не всегда это нужно пользователю знать
Это не факт есть сервисы которым вообще ничего о пользователе знать не надо а есь те которым, надо знать.
И год рождения и департамент, роль, групу и т.д. и какраз если положить все это в токен то можно отлично передавать этот токен с каждым реквестом от сервиса к сервису (общий секрет) не дергая никаких центральных сервисов авторизации…
Какраз в этом то я и вижу фишку…
И не понятно причем тут то, что видноко клиенту… Можете не показывать ему вовсе содержимое, если не нужно… просто пусть носит ссобой шифрованый токен.
Может я конечно не совсем верно понял JWT, но помеомеу о идеален именно для это-го
Scf
23.02.2016 09:15Как уже писали выше, стандартное решение для микросервисной архитектуры — OAuth2. Преимущества — изолированный сервис аутентификации, стандарт, наличие всяких интересных плюшек помимо обычной аутентификации по токену.
Что касается авторизации — я не уверен, что пихать её в nginx — хорошая идея. Логика авторизации часто зависит от конкретного приложения. Как вариант, можно настроить nginx, чтобы он сам валидировал токен и добавлял в заголовки юзернейм, а логику авторизации все-таки оставить приложению.ToSHiC
23.02.2016 13:55У OAuth2 один минус: нужно каждый раз ходить в сервис авторизации, может стать боллтнеком, ну и latency это добавляет. Но если оба пункта не сильно важны в текущий момент, то это действительно неплохой выбор, пусть и несколько переусложнённый.
Если всё же интересны локально проверяемые токены, то рекомендую почитать про Google Macaroons.Scf
23.02.2016 15:48Токены можно кешировать локально на небольшой интервал времени, это снимает проблему нагрузки, а заодно и latency, если обращаются одни и те же юзеры. Проблема с локально проверяемыми токенами в том, что их нельзя оперативно отозвать, а также в вычислительной сложности или безопасности — смотря как они подписаны.
За статью спасибо, выглядит интересно.ToSHiC
23.02.2016 16:47Конкретно в случае, описанном в этой статье, время жизни токена должно быть меньше минуты (он выдаётся в момент попадания запроса в фронтэнд, должен жить не меньше, чем время обработки самого долгого запроса), так что отзывать нет смысла.
ToSHiC
А от чего именно вы защищаетесь? У вас в этой схеме при компрометации любого компонента появляется возможность генерировать токены в любом количестве, а это не здорово.
Если нужно сократить количество машин, которые нужно особо внимательно охранять, нужно получить возможность проверять валидность авторизационного токена без полного знания секрета. Это можно сделать двумя способами:
1. Всегда ходить в сервис, который выдавал токен, чтобы он его и валидировал. По сути — oauth.
2. Подписывать токен ассиметричной криптографией, например RSA. Ключ при этом нужно выбрать достаточно коротким (я бы сказал, 1024 бит будет достаточно), и ротировать его регулярно. На каждом клиенте при этом будет только публичный ключ, приватный же только внутри сервиса, который токены генерирует. Эллиптические кривые тоже можно использовать, валидация будет работать даже быстрее, но подпись генерируется значительно дольше. Чтобы проверить скорость генерации/проверки подписи разными шифрами можете использовать утилиту openssl speed прямо на своём сервере. На моём самом дешёвом дроплете от DO получились вот такие цифры:
Оба метода идеально горизонтально масштабируются (потому что шарят только ключи), позволяют сделать 2-уровневую защиту. Заодно сервис аутентификации может и куку пользователя менять на токен, что добавит защиты.
svyatogor
Насчет асиметричного шифрования пожалуй соглашусь. А вот с утверждением "в этой схеме при компрометации любого компонента появляется возможность генерировать токены в любом количестве" смею поспорить. Для генерации токенов нужно скомпроментировать один из сервисов имеющих доступ к кэшу redis. Т.е. или auth или nginx.
ToSHiC
nginx у вас живёт в отдельном контейнере от приложения, исполняющего полезную работу? Если да, то как защищено взаимодействие nginx и приложения, и почему в него можно сходить только из nginx?
Безопасность — дело такое, если есть хоть одна дыра, то наличие защиты в других частях уже не очень важно.
svyatogor
Да, nginx в отдельном контейнере, попасть снаружи в контейнер с приложением невозможно просто потому что туда нет рутинга (порты приложения не экспортируются в хост машину).
kyprizel
наоборот (даже по тесту видно)
ToSHiC
Да, это я что-то неправильно запомнил :(