В предыдущем посте я писал о выборе API шлюза и как KrakenD позволил нам склеивать ответы от различных API. В итоге мы отказались от KrakenD в пользу OpenResty и я расскажу почему.

KrakenD – это современный API-шлюз, написанный на Go, с кучей фич. Работает классно, быстро. Имеет интеграцию с инструментами Open Telemetry и много чем еще, но… в Community Edition не включена работа с шаблонами в URL сервисов. И это печально. Нужно повторять описания каждой точки раз за разом (а это минимум 10 строк в нашем случае). Что-то вроде:

{
  "endpoint": "/api/ideas",
  "method": "GET",
  "backend": [
    {
      "url_pattern": "/api/ideas",
      "host": [ "{{ .services.ideas }}" ]
    }
  ],
  {{ include "auth.json" }}
},

На данный момент у нас около десятка сервисов по несколько точек в каждом и поддержка уже сулит сущий кошмар. Мы стояли перед выбором продолжать увеличивать масштаб трагедии или пересмотреть подход пока все не зашло слишком далеко.

По сути, что нам нужно от решения:

  • Поддерживать аутентификацию JWT. Мы получаем токены в Authorization заголовке и Cookies от клиента в каждом запросе, хотим проверять их подпись и перепаковывать некоторые поля в заголовки запросов для сервисов ниже по течению.

  • Иметь гибкие инструменты маршрутизации. У нас десятки точек в различных сервисах.

  • Быть бесплатным и с открытым кодом. Пока это хобби-проект нам совсем не хочется платить деньги за лицензии, планы и прочие коммерческие блага.

Как оказалось, выбор не сильно велик. Я посмотрел несколько популярных решений и не нашел ни одного, которое бы покрывало все три требования, кроме…

Вы уже догадались. OpenResty. По правде сказать, я восхищен решением и одновременно удивлен тем, что мысль посмотреть в сторону NGINX + Lua так долго шла в мою голову. Наверное, я – жираф. Как они сами пишут, OpenResty – это “a dynamic web platform based on NGINX and LuaJIT”. На сколько мне удалось понять из статей и разговоров с DevOps спецами, это очень популярное решение, очень производительное и гибкое. Самое главное это то, что оно предоставляет поддержку Lua везде где можно – в конфигах, в отдельных модулях. LuaJIT по скорости, конечно, уступает коду на C, но за счет JIT компиляции модули на Lua находятся где-то очень и очень близко.

В качестве концепта я потратил пару часов на написание своей верификации и перепаковки JWT с нуля. Подсмотрел пару приемов, взял библиотечку для работы с OpenIDC и вот что получилось:

# Authentication
#
# All endpoints are authenticated by default.
# JWT token is taken from Authentication header Bearer, signature is verified and subject (sub).
# If Authentication header is missing, a cookie with the same name is attempted.
# is sent further as an X-Employee-ID header.
#
# To skip authentication, you can do the following:
#
# location /api/public-endpoint {
#     access_by_lua_block { }
# }

access_by_lua_block {
    -- Check that we have authorization header
    local auth_header = ngx.req.get_headers()["Authorization"]
    if not auth_header then
        -- Use authorization header from cookie "AccessToken"
        local cookie_value = ngx.var["cookie_accesstoken"]

        if not cookie_value then
            ngx.exit(ngx.HTTP_UNAUTHORIZED)
        else
            ngx.req.set_header("Authorization", "Bearer " .. cookie_value)
        end
    end

    -- Verify auth token and raise an error if invalid
    local openidc = require("resty.openidc")
    local opts = { discovery = { jwks_uri = "http://auth:5000/api/.well-known/jwks" } }
    local json, err = openidc.bearer_jwt_verify(opts)
    if err then
        ngx.status = ngx.HTTP_UNAUTHORIZED
        ngx.say(err)
        return
    end

    ngx.req.set_header("x-employee-id", json.sub)
}

Этот небольшой кусочек берет токен из заголовка Authorization, а если там его нет, то из Cookie AccessToken. Затем забирает JWKS свзяку ключей из определенного URL (и кэширует их конечно) и проверяет подпись JWT ключом из связки. Если все хорошо, то в заголовок X-Employee-ID вставляется содержимое JWT sub и запрос отправляется дальше. А если токен не пришел, был битым или неправильно подписанным, выдается 403 UNAUTHORIZED.

Вот и все. Работает надежно.