Стараясь оставаться в тренде и следуя веяниям моды веб разработки, последнее веб приложение я решил реализовать как набор микросервисов на ruby плюс “толстый” клиент на ember. Одна из первых проблем, вставших перед мной была связана с аутентификацией запросов. Если в классическом, монолитном, приложении все просто, используем куки, сессии, подключаем какой-нибудь devise, то тут все как в первый раз.

Архитектура


За базу я выбрал 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 кешем для хранения подписи, осталось только его допилить чтобы:

  1. вытягивать токен из Authorization заголовка
  2. в случае успешной проверки доставать данные сессии и посылать их в X-Data заголовке
  3. немного причесать ошибки, чтобы отдавался валидный 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)


  1. ToSHiC
    21.02.2016 19:43
    +1

    А от чего именно вы защищаетесь? У вас в этой схеме при компрометации любого компонента появляется возможность генерировать токены в любом количестве, а это не здорово.

    Если нужно сократить количество машин, которые нужно особо внимательно охранять, нужно получить возможность проверять валидность авторизационного токена без полного знания секрета. Это можно сделать двумя способами:
    1. Всегда ходить в сервис, который выдавал токен, чтобы он его и валидировал. По сути — oauth.
    2. Подписывать токен ассиметричной криптографией, например RSA. Ключ при этом нужно выбрать достаточно коротким (я бы сказал, 1024 бит будет достаточно), и ротировать его регулярно. На каждом клиенте при этом будет только публичный ключ, приватный же только внутри сервиса, который токены генерирует. Эллиптические кривые тоже можно использовать, валидация будет работать даже быстрее, но подпись генерируется значительно дольше. Чтобы проверить скорость генерации/проверки подписи разными шифрами можете использовать утилиту openssl speed прямо на своём сервере. На моём самом дешёвом дроплете от DO получились вот такие цифры:

    ~$ openssl speed ecdsap160 rsa1024
    Doing 1024 bit private rsa's for 10s: 43546 1024 bit private RSA's in 9.98s
    Doing 1024 bit public rsa's for 10s: 626236 1024 bit public RSA's in 9.99s
    Doing 160 bit sign ecdsa's for 10s: 123664 160 bit ECDSA signs in 9.99s
    Doing 160 bit verify ecdsa's for 10s: 33559 160 bit ECDSA verify in 9.98s
    OpenSSL 1.0.1f 6 Jan 2014
    built on: Thu Mar 19 15:12:02 UTC 2015
    options:bn(64,64) rc4(16x,int) des(idx,cisc,16,int) aes(partial) blowfish(idx)
    compiler: cc -fPIC -DOPENSSL_PIC -DOPENSSL_THREADS -D_REENTRANT -DDSO_DLFCN -DHAVE_DLFCN_H -m64 -DL_ENDIAN -DTERMIO -g -O2 -fstack-protector --param=ssp-buffer-size=4 -Wformat -Werror=format-security -D_FORTIFY_SOURCE=2 -Wl,-Bsymbolic-functions -Wl,-z,relro -Wa,--noexecstack -Wall -DMD32_REG_T=int -DOPENSSL_IA32_SSE2 -DOPENSSL_BN_ASM_MONT -DOPENSSL_BN_ASM_MONT5 -DOPENSSL_BN_ASM_GF2m -DSHA1_ASM -DSHA256_ASM -DSHA512_ASM -DMD5_ASM -DAES_ASM -DVPAES_ASM -DBSAES_ASM -DWHIRLPOOL_ASM -DGHASH_ASM
                      sign    verify    sign/s verify/s
    rsa 1024 bits 0.000229s 0.000016s   4363.3  62686.3
                                  sign    verify    sign/s verify/s
     160 bit ecdsa (secp160r1)   0.0001s   0.0003s  12378.8   3362.6
    


    Оба метода идеально горизонтально масштабируются (потому что шарят только ключи), позволяют сделать 2-уровневую защиту. Заодно сервис аутентификации может и куку пользователя менять на токен, что добавит защиты.


    1. svyatogor
      21.02.2016 23:19

      Насчет асиметричного шифрования пожалуй соглашусь. А вот с утверждением "в этой схеме при компрометации любого компонента появляется возможность генерировать токены в любом количестве" смею поспорить. Для генерации токенов нужно скомпроментировать один из сервисов имеющих доступ к кэшу redis. Т.е. или auth или nginx.


      1. ToSHiC
        21.02.2016 23:31

        nginx у вас живёт в отдельном контейнере от приложения, исполняющего полезную работу? Если да, то как защищено взаимодействие nginx и приложения, и почему в него можно сходить только из nginx?

        Безопасность — дело такое, если есть хоть одна дыра, то наличие защиты в других частях уже не очень важно.


        1. svyatogor
          21.02.2016 23:39

          Да, nginx в отдельном контейнере, попасть снаружи в контейнер с приложением невозможно просто потому что туда нет рутинга (порты приложения не экспортируются в хост машину).


    1. kyprizel
      22.02.2016 23:59

      валидация будет работать даже быстрее, но подпись генерируется значительно дольше

      наоборот (даже по тесту видно)


      1. ToSHiC
        23.02.2016 13:02

        Да, это я что-то неправильно запомнил :(


  1. Kolyuchkin
    22.02.2016 02:21

    Автор, исправьте ошибку в первом слове заголовка статьи… пожалуйста.


    1. AAbrosov
      22.02.2016 10:32

      собственно во всей статье, 6 раз


      1. Kolyuchkin
        22.02.2016 11:06

        В заголовке особенно «режет глаз».


  1. shuron
    22.02.2016 15:14

    Он может содержать в себе все что угодно, в принципе это может быть и просто client_id и какая-то другая информация о пользователе, но это не очень хорошая идея

    Немоглибы пояснить почему?
    как я понимяю JWT это был-бы иделаьный варинат для микросервисов… А дополнительная валидация в nginx какрыз костыль…


    1. svyatogor
      22.02.2016 18:18

      Идея в том, что аутентификация и проверка токена это не есть непосредственная задача микросервиса. Пускай этим занимается промежуточный сервис. Кроме того конкретному микросервису как правило нужно очень мало информации о клиенте, ID в принципе для большинства задач хватит, но не хочется это все в токене хранить, не всегда это нужно пользователю знать. и на каждый чих пых дергать auth сервис тоже не здорово.


      1. shuron
        23.02.2016 00:58

        Идея в том, что аутентификация и проверка токена это не есть непосредственная задача микросервиса.

        Ну это понятно, но какой-то boilerplate есть всегда темболее что в данном случае это решает какая-нибудь либа.
        Утрировано проблема лишней строчки кода помоему не страшно…
        Далее не всем сервисам нужда аутентикация/авторизация в принципе… но тем которым нужна нужна скорее всего достаточно псецифически (именно авторизация). Так что куда-то делегироваТь эту задачу мене кажется далеко не суперской идеей.
        Кроме того конкретному микросервису как правило нужно очень мало информации о клиенте, ID в принципе для большинства задач хватит, но не хочется это все в токене хранить, не всегда это нужно пользователю знать

        Это не факт есть сервисы которым вообще ничего о пользователе знать не надо а есь те которым, надо знать.
        И год рождения и департамент, роль, групу и т.д. и какраз если положить все это в токен то можно отлично передавать этот токен с каждым реквестом от сервиса к сервису (общий секрет) не дергая никаких центральных сервисов авторизации…
        Какраз в этом то я и вижу фишку…
        И не понятно причем тут то, что видноко клиенту… Можете не показывать ему вовсе содержимое, если не нужно… просто пусть носит ссобой шифрованый токен.

        Может я конечно не совсем верно понял JWT, но помеомеу о идеален именно для это-го


  1. Scf
    23.02.2016 09:15

    Как уже писали выше, стандартное решение для микросервисной архитектуры — OAuth2. Преимущества — изолированный сервис аутентификации, стандарт, наличие всяких интересных плюшек помимо обычной аутентификации по токену.

    Что касается авторизации — я не уверен, что пихать её в nginx — хорошая идея. Логика авторизации часто зависит от конкретного приложения. Как вариант, можно настроить nginx, чтобы он сам валидировал токен и добавлял в заголовки юзернейм, а логику авторизации все-таки оставить приложению.


    1. ToSHiC
      23.02.2016 13:55

      У OAuth2 один минус: нужно каждый раз ходить в сервис авторизации, может стать боллтнеком, ну и latency это добавляет. Но если оба пункта не сильно важны в текущий момент, то это действительно неплохой выбор, пусть и несколько переусложнённый.

      Если всё же интересны локально проверяемые токены, то рекомендую почитать про Google Macaroons.


      1. Scf
        23.02.2016 15:48

        Токены можно кешировать локально на небольшой интервал времени, это снимает проблему нагрузки, а заодно и latency, если обращаются одни и те же юзеры. Проблема с локально проверяемыми токенами в том, что их нельзя оперативно отозвать, а также в вычислительной сложности или безопасности — смотря как они подписаны.

        За статью спасибо, выглядит интересно.


        1. ToSHiC
          23.02.2016 16:47

          Конкретно в случае, описанном в этой статье, время жизни токена должно быть меньше минуты (он выдаётся в момент попадания запроса в фронтэнд, должен жить не меньше, чем время обработки самого долгого запроса), так что отзывать нет смысла.