Практика OpenResty: авторизация, JWT, кэш, Redis

Рейтинг: 56.5% · 9 голосов
Самый подробный курс по Nginx - от первого конфига до production API-gateway. Архитектура и event loop, контексты и location, виртуальные хосты, статика, reverse proxy и upstream-балансировка, кэширование, HTTPS и TLS (post-quantum), HTTP/2 и HTTP/3 QUIC, безопасность и rate limiting, realip, JSON-логи и сквозная трассировка, метрики, тюнинг и траблшутинг. Плюс OpenResty (Lua, фазы, JWT, динамика) и Angie (нативные Prometheus, ACME, dynamic upstream). На основе эталонного gateway и лучших практик. Для новичков и SRE/DevOps. Актуально на 2026 (nginx 1.30/1.31, Angie, ingress-nginx EOL -> Gateway API).
Ответить
Аватара пользователя
Igor_NgINX
Сообщения: 44
Зарегистрирован: 11 май 2026, 05:31

Практика OpenResty: авторизация, JWT, кэш, Redis

Сообщение Igor_NgINX »

Оглавление курса (44)
  1. Что такое Nginx и почему он захватил веб
  2. Установка Nginx, Angie и OpenResty: пакеты, версии, Docker
  3. Первый конфиг с нуля: минимальный сервер за 5 минут
  4. Архитектура процессов: master, worker и event loop
  5. Анатомия конфигурации: контексты, наследование и модульность
  6. Виртуальные хосты: server и server_name
  7. Директива location: матчинг и приоритеты по шагам
  8. Раздача статики: root, alias, try_files, sendfile
  9. Переменные, map и блок if
  10. rewrite, return и канонизация URL
  11. Работа с заголовками: add_header и proxy_set_header
  12. Модель доверия прокси-заголовков: realip и защита от спуфинга
  13. Сжатие: gzip, brotli и zstd
  14. Reverse proxy: proxy_pass и проброс запроса
  15. upstream и балансировка нагрузки
  16. Таймауты, повторы и устойчивость прокси
  17. FastCGI и PHP: nginx + PHP-FPM (и uwsgi/scgi)
  18. WebSocket, gRPC и стриминг через Nginx
  19. Кэширование ответов: proxy_cache и микрокэш
  20. HTTPS и TLS: сертификаты, протоколы, шифры (2026)
  21. Let's Encrypt и ACME: certbot, acme.sh, нативный ACME Angie
  22. HTTP/2 и HTTP/3 (QUIC) в 2026
  23. Безопасность: заголовки, скрытие версии, ограничения
  24. Rate limiting и защита от перегрузки
  25. Контроль доступа: allow/deny, auth_basic, auth_request и mTLS
  26. Логирование: access_log, форматы и структурированный JSON
  27. Сквозная трассировка запросов и OpenTelemetry
  28. Метрики и мониторинг Nginx
  29. Производительность и тюнинг под нагрузку
  30. Траблшутинг: 502, 504, 403, 404 и debug-лог
  31. OpenResty: Nginx как платформа на LuaJIT
  32. Фазы обработки запроса и Lua-хуки
  33. Lua API: ngx.*, shared dict, cosocket и lua-resty-core
  34. Практика OpenResty: авторизация, JWT, кэш, Redis (вы здесь)
  35. Angie: современный форк Nginx 2026
  36. Динамическая конфигурация и service discovery
  37. Nginx как API-gateway и Kubernetes Gateway API
  38. Динамические модули, njs и WAF
  39. Stream-модуль: проксирование TCP и UDP
  40. Деплой, перезагрузка и конфиг как код
  41. Сценарий: статика, SPA и кэширование за CDN
  42. Сценарий: микросервисный gateway по образцу ngx-trace-gateway
  43. Капстоун: собираем production-gateway с нуля
  44. Чек-лист безопасности, CVE 2026 и аудит конфигурации
К этому моменту ты уже знаешь фазы OpenResty, cosocket-API и то, что Lua живёт внутри nginx-воркера. Пора собрать из этого боевой шлюз. Типичная боль такая: за nginx стоит десяток сервисов, каждый запрос надо проверить на валидный токен, не пустить мимо лимитов, выбрать правильный апстрим в зависимости от клиента и при этом не уронить latency запросами в Redis и к серверу авторизации на КАЖДЫЙ хит. Если делать в лоб - на каждый запрос синхронный поход в БД за маршрутом, на каждый запрос валидация JWT с подтягиванием ключей по сети, на каждый запрос INCR в Redis. Под нагрузкой это разваливается: воркеры висят в ожидании сети, p99 улетает в космос, а сервер авторизации ложится первым.

В этом уроке мы разберём, как на Lua делается то, ради чего люди и берут OpenResty вместо ванильного nginx: динамическая маршрутизация по данным из Redis прямо в balancer_by_lua, авторизация по JWT на access-фазе (с обязательным аудитом безопасности - тут легко прострелить себе ногу), многоуровневый кэш JWKS и решений, и аккуратная работа с Redis через пул соединений. Это и есть тот самый openresty nginx lua api gateway, который держит десятки тысяч rps на одной ноде.

Авторизация на access-фазе: где и как валидировать JWT

Первое правило: токен проверяется на фазе access, ДО того как запрос ушёл в proxy_pass. Это естественная точка отсечения - если токен невалиден, мы возвращаем 401 и не тратим бэкенд. Фаза access вызывается после rewrite и до content, и в ней доступны cosockets, то есть можно ходить в сеть.

Самый частый паттерн openresty авторизация - директива access_by_lua_block в нужной локации. Достаём токен, парсим, проверяем подпись и claims:

Код: Выделить всё

location /api/ {
    access_by_lua_block {
        local auth = ngx.var.http_authorization
        if not auth then
            return ngx.exit(ngx.HTTP_UNAUTHORIZED)
        end
        local _, _, token = string.find(auth, "Bearer%s+(.+)")
        if not token then
            return ngx.exit(ngx.HTTP_UNAUTHORIZED)
        end
        -- дальше проверка токена (см. ниже)
        ngx.ctx.token = token
    }
    proxy_pass http://backend;
}
Обрати внимание на ngx.ctx - это таблица, живущая ровно один запрос, через неё удобно протащить распарсенные claims в content-фазу или в лог. Не путай с ngx.shared (общий между воркерами) и с переменными уровня модуля (живут вечно, шарить через них запрос - грубая ошибка, словишь утечку данных одного клиента в запрос другого).

Теперь самое важное - КАК проверять подпись, потому что именно здесь в 2026 чаще всего и пробивают шлюзы.

Изображение

openresty jwt без дыр: аудит alg-confusion и почему голый lua-resty-jwt опасен

Классическая библиотека для openresty jwt - lua-resty-jwt. Она работает, но имеет историю уязвимостей, и пользоваться ей наивно нельзя. Две беды.

Беда первая - алгоритм none. Спецификация JWT разрешает alg: "none" для неподписанных токенов. Если библиотека не отвергает его явно, атакующий снимает подпись, ставит в заголовок alg:none и получает доверенный токен с любыми claims. Хуже того, наивный фильтр строки "none" обходится регистром: nOnE, NONE, None пройдут, если ты не приводишь к нижнему регистру.

Беда вторая, опаснее - alg-confusion (key confusion). Сервер ждёт RS256 (асимметрика, проверка ПУБЛИЧНЫМ ключом). Атакующий берёт твой публичный RSA-ключ (он публичный, его легко достать) и подписывает токен алгоритмом HS256, используя текст этого публичного ключа как HMAC-секрет. Если код просто говорит "проверь этим ключом", не фиксируя алгоритм, библиотека увидит alg:HS256, возьмёт публичный ключ как секрет HMAC - и подпись сойдётся. У lua-resty-jwt по умолчанию alg_whitelist равен nil, то есть НИКАКОЙ фильтрации алгоритма нет. Это включённый по умолчанию режим выстрела в ногу.

Правило железное: НИКОГДА не доверяй полю alg из заголовка токена. Алгоритм задаёшь ТЫ на стороне сервера, исходя из типа ключа, и любой токен с другим alg отвергаешь. На lua-resty-jwt это выглядит так:

Код: Выделить всё

local jwt = require "resty.jwt"
local jwt_validators = require "resty.jwt-validators"

-- фиксируем разрешённый алгоритм заранее, не из токена
local claim_spec = {
    alg = jwt_validators.equals("RS256"),
    exp = jwt_validators.is_not_expired(),
    iss = jwt_validators.equals("https://auth.cyberlake.ru/"),
    aud = jwt_validators.equals("api.cyberlake.ru"),
}

local public_key = ngx.shared.jwks_cache:get("rsa_pub")
local verified = jwt:verify(public_key, token, claim_spec)

if not verified.verified then
    ngx.log(ngx.WARN, "jwt rejected: ", verified.reason)
    return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.ctx.claims = verified.payload
Здесь validator alg = equals("RS256") отсекает и none, и подмену на HS256: токен с другим alg просто не пройдёт claim_spec. Плюс мы валидируем exp, iss и aud - без проверки aud токен, выписанный для другого сервиса того же провайдера, пролезет к тебе. Это тоже частая дыра.

Но вот честный совет на 2026: если у тебя полноценный OAuth2/OIDC (Keycloak, Authentik, Auth0, Zitadel), не собирай велосипед на голом lua-resty-jwt. Де-факто стандарт - lua-resty-openidc. Это реализация OIDC Relying Party и OAuth2 Resource Server: он сам тянет и кэширует JWKS по jwks_uri из discovery-документа, проверяет подпись правильным ключом по kid, умеет token introspection для непрозрачных токенов, ведёт сессии. Тебе не надо вручную рулить ротацией ключей.

Код: Выделить всё

location /api/ {
    access_by_lua_block {
        local openidc = require "resty.openidc"
        local opts = {
            discovery = "https://auth.cyberlake.ru/.well-known/openid-configuration",
            -- для Resource Server: проверяем access-токен, без редиректов
            accept_none_alg = false,
            accept_unsupported_alg = false,
        }
        local res, err = openidc.bearer_jwt_verify(opts)
        if err or not res then
            ngx.log(ngx.WARN, "oidc verify failed: ", err)
            return ngx.exit(ngx.HTTP_UNAUTHORIZED)
        end
        ngx.req.set_header("X-User-Sub", res.sub)
        ngx.ctx.claims = res
    }
    proxy_pass http://backend;
}
accept_none_alg = false тут не косметика - это явный отказ от none-алгоритма. lua-resty-openidc внутри хранит JWKS в lua_shared_dict, так что сетевой поход к auth-серверу за ключами случается раз в период, а не на каждый запрос. Это и есть кэш JWKS, про который дальше поговорим подробнее.

nginx lua redis и пул соединений: динамическая маршрутизация без reload

Теперь nginx lua redis. Зачем Redis в шлюзе: фиче-флаги, карта маршрутов (какой клиент на какой апстрим), блок-листы, счётчики. Главное правило cosocket - соединение ОБЯЗАТЕЛЬНО возвращать в пул через set_keepalive, иначе на каждый запрос будет новый TCP-хендшейк и Redis захлебнётся в коннектах. И помни: пул per-worker, соединение нельзя сохранять в переменную уровня модуля между запросами - бери из пула в начале и возвращай в конце.

Код: Выделить всё

local function get_route(client_id)
    local redis = require "resty.redis"
    local red = redis:new()
    red:set_timeouts(200, 200, 200)   -- connect, send, read - в мс

    local ok, err = red:connect("127.0.0.1", 6379)
    if not ok then
        ngx.log(ngx.ERR, "redis connect: ", err)
        return nil
    end

    local upstream, rerr = red:get("route:" .. client_id)
    if not upstream then
        ngx.log(ngx.ERR, "redis get: ", rerr)
        -- ВАЖНО: при ошибке НЕ зови set_keepalive, закрой соединение
        red:close()
        return nil
    end

    -- вернуть в пул: idle 10s, размер пула 100
    red:set_keepalive(10000, 100)

    if upstream == ngx.null then return nil end
    return upstream
end
Тонкость: set_keepalive можно звать ТОЛЬКО на чистом соединении без незавершённых ответов. Если был сетевой сбой посреди команды - вызывай close, иначе в пул попадёт битый сокет и следующий запрос получит мусор. И второе - сам по себе поход в Redis на каждый запрос дёшев, но не бесплатен; маршруты меняются редко, поэтому их надо кэшировать в воркере, к чему мы и придём.

А теперь динамическая маршрутизация. Выбор апстрима из Redis делается в balancer_by_lua - это специальная фаза, где Lua сама назначает пир. Апстрим объявляется пустым с директивой balancer, реальный адрес ставит ngx.balancer.set_current_peer:

Код: Выделить всё

upstream dynamic_backend {
    server 0.0.0.1;   -- плейсхолдер, реальный адрес задаёт Lua
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"
        local addr = ngx.ctx.target_addr
        local host, port = addr:match("([^:]+):(%d+)")
        local ok, err = balancer.set_current_peer(host, tonumber(port))
        if not ok then
            ngx.log(ngx.ERR, "set_current_peer: ", err)
            return ngx.exit(502)
        end
    }
}
В balancer-фазе нельзя ходить cosocket-ами в Redis - она вызывается на горячем пути выбора пира. Поэтому маршрут резолвишь раньше, на access-фазе, и кладёшь адрес в ngx.ctx.target_addr. balancer_by_lua может вызываться несколько раз для одного запроса (ретраи), и в нём же через balancer.set_more_tries удобно задать повторные попытки на другой пир.

openresty rate limit, A/B и трансформация: гибкость Lua поверх limit_req

Базовый limit_req из прошлых уроков прекрасен, но жёсткий: ключ и скорость зашиты в конфиг. Когда нужно openresty rate limit с лимитом, зависящим от тарифа клиента из JWT, или с разными политиками на разные ручки - берут lua-resty-limit-traffic. Важное предупреждение на 2026: эта библиотека официально помечена как highly experimental, её API может меняться между версиями. В прод бери осознанно и пинуй версию.

Код: Выделить всё

local limit_req = require "resty.limit.req"
-- rate=200 r/s, burst=100; счётчик в shared dict
local lim = limit_req.new("my_limit_zone", 200, 100)
local key = ngx.ctx.claims and ngx.ctx.claims.sub or ngx.var.binary_remote_addr

local delay, err = lim:incoming(key, true)
if not delay then
    if err == "rejected" then
        return ngx.exit(429)
    end
    ngx.log(ngx.ERR, "limit err: ", err)
else
    if delay > 0 then ngx.sleep(delay) end   -- сглаживаем всплеск
end
Преимущество перед limit_req - ключ считается в рантайме как угодно: по sub из токена, по тарифу, по комбинации. limit.traffic.combine позволяет наложить несколько лимитов сразу (по IP И по пользователю).

Трансформация и A/B на Lua тоже тривиальны. Хочешь канареечный роутинг 10% трафика на новую версию:

Код: Выделить всё

access_by_lua_block {
    local bucket = ngx.crc32_short(ngx.var.binary_remote_addr) % 100
    if bucket < 10 then
        ngx.ctx.target_addr = "10.0.0.50:8080"   -- canary
        ngx.req.set_header("X-Variant", "canary")
    else
        ngx.ctx.target_addr = "10.0.0.10:8080"   -- stable
        ngx.req.set_header("X-Variant", "stable")
    end
}
crc32 от IP даёт стабильное распределение: один клиент всегда в одной группе. Ответ так же трансформируется через header_filter_by_lua (заголовки) и body_filter_by_lua (тело), но тело резать осторожно - оно приходит чанками.

Многоуровневый кэш lua-resty-mlcache: L1/L2/L3 и защита от dog-pile

Вот сердце производительного шлюза. JWKS, маршруты, фиче-флаги - данные, которые читаются на каждом запросе и меняются редко. Тащить их в Redis или к auth-серверу каждый раз - расточительство. Решение - lua-resty-mlcache, трёхуровневый кэш.
  • L1 - lua-resty-lrucache, живёт в памяти Lua VM конкретного воркера. Доступ без сериализации, нанонсекунды. Но у каждого воркера свой L1.
  • L2 - lua_shared_dict, общий для всех воркеров одной ноды. Данные сериализуются, но один воркер прогрел - остальные читают из общей памяти.
  • L3 - сам callback (поход в Redis/HTTP/БД). Вызывается только при промахе L1 и L2.
Магия в L3: mlcache оборачивает callback в single-flight через lua-resty-lock. Это лекарство от dog-pile (он же thundering herd): когда популярный ключ протух и тысяча запросов одновременно промахнулась, без защиты все тысяча ломанутся в Redis/auth одновременно и положат его. С mlcache callback под промах выполнит РОВНО ОДИН воркер-запрос, остальные подождут на блокировке и получат уже прогретое значение.

Код: Выделить всё

local mlcache = require "resty.mlcache"

local cache = mlcache.new("jwks_cache", "mlcache_shm", {
    lru_size = 1000,        -- размер L1
    ttl      = 3600,        -- свежесть 1 час
    neg_ttl  = 30,          -- кэшировать и "не найдено" 30с (против перебора)
    resty_lock_opts = { timeout = 0.5 },
})

-- callback (L3) дёргается лишь при промахе L1+L2, и то single-flight
local function fetch_jwks(kid)
    local http = require "resty.http"
    local httpc = http.new()
    httpc:set_timeout(1000)
    local res = httpc:request_uri("https://auth.cyberlake.ru/jwks", { method = "GET" })
    httpc:set_keepalive()    -- вернуть в пул, как и Redis
    if not res or res.status ~= 200 then return nil, "jwks fetch failed" end
    return res.body
end

local jwks, err = cache:get("current_kid", nil, fetch_jwks, "current_kid")
Тонкий момент - инвалидация L1. Когда ключ ротировался, ты делаешь cache:delete, но это чистит L2 (shared) и L1 ТОЛЬКО текущего воркера. У остальных воркеров L1 ещё держит старое значение. mlcache решает это IPC-механизмом: cache:update в каждом воркере (обычно по таймеру) ловит события инвалидации и чистит свой L1. Без вызова update инвалидация между воркерами не разъедется, и ты будешь час видеть старый ключ - классические грабли.

Здесь же прямая связь с micro-caching: тот же приём (короткий ttl 1-5 секунд на ответ бэкенда) гасит шквал одинаковых запросов, а single-flight гарантирует, что прогрев делает один, а не толпа. Используя lua-resty-http в callback, ты строишь полноценный кэширующий слой на Lua там, где proxy_cache не хватает гибкости (например ключ кэша зависит от распарсенного claim).

Грабли, которые ловят всех
  • Доверие alg из токена. Главная дыра. Всегда фиксируй алгоритм сервером, отвергай none в любом регистре, проверяй aud и iss, а не только подпись.
  • Соединение Redis/HTTP не возвращено в пул. Забыл set_keepalive - получил новый коннект на каждый запрос. Звал set_keepalive на битом соединении - отравил пул. После сетевой ошибки всегда close.
  • Переменная уровня модуля для данных запроса. Положил claims или соединение в local на верхнем уровне файла - и данные одного клиента утекли в запрос другого. Для запроса только ngx.ctx, для общих данных ngx.shared.
  • mlcache без cache:update. L1 у воркеров разъезжается после инвалидации, видишь устаревшие значения. Заведи таймер на update.
  • Cosocket в balancer_by_lua. Там нельзя ходить в сеть. Резолви маршрут на access-фазе.
  • lua-resty-limit-traffic как стабильная зависимость. Она experimental, пинуй версию и тестируй при обновлении.
Мини-лаба: JWT/OIDC-gateway с кэшем JWKS

Собери руками шлюз, который проверяет токен и кэширует ключи.
  • Подними OpenResty (1.31.1.1) и Redis в docker-compose. Положи в http-блок: lua_shared_dict mlcache_shm 64m; lua_shared_dict mlcache_lock 8m;
  • Через luarocks/opm поставь lua-resty-jwt и lua-resty-mlcache (или lua-resty-openidc, если есть Keycloak).
  • В отдельном .lua-модуле создай mlcache.new и callback fetch_jwks, который ходит lua-resty-http на твой JWKS endpoint (или мок-файл) и возвращает ключ. Поставь ttl=3600, neg_ttl=30.
  • В location /api/ повесь access_by_lua_file: достань Bearer, через cache:get("kid", ...) возьми ключ, валидируй jwt:verify с claim_spec, где alg = equals("RS256"), exp = is_not_expired, aud = equals(...). При провале - ngx.exit(401).
  • Заведи таймером ngx.timer.every(1, ...) вызов cache:update, чтобы L1 синхронизировался.
  • Проверь: валидный токен - 200; токен с alg:none - 401; токен, подписанный HS256 твоим публичным ключом - 401 (alg-confusion отбит). Положи log-line с временем callback и убедись, что на втором и далее запросе fetch_jwks НЕ вызывается (кэш работает).
  • Бонус: положи в Redis route:VIP -> 10.0.0.50:8080, резолви маршрут на access-фазе по claim и роуть через balancer_by_lua.
Контрольные вопросы
  • Почему нельзя доверять полю alg из заголовка JWT и как технически работает атака alg-confusion с RS256/HS256?
  • Что делает single-flight в lua-resty-mlcache и какую конкретную проблему при массовом промахе кэша он решает?
  • Почему set_keepalive нельзя звать на соединении после сетевой ошибки и что будет с пулом, если это сделать?
  • Почему маршрут из Redis резолвят на access-фазе, а не прямо в balancer_by_lua?
Итог

Боевой openresty nginx lua api gateway держится на четырёх китах: авторизация на access-фазе с жёсткой фиксацией alg (или lua-resty-openidc для настоящего OIDC), Redis через пул с обязательным set_keepalive, динамическая маршрутизация через balancer_by_lua с резолвом на access, и трёхуровневый mlcache с single-flight, который гасит dog-pile и снимает нагрузку с auth-сервера и БД. Голый lua-resty-jwt без фиксации алгоритма - это дыра по умолчанию; lua-resty-limit-traffic - мощно, но experimental. Сделаешь правильно - одна нода тянет десятки тысяч rps, p99 ровный, а сервер авторизации даже не замечает нагрузки.
👍2 ❤️2 🔥1 😄 🤔
Аватара пользователя
kafka8
Сообщения: 1
Зарегистрирован: 27 май 2026, 05:45

Re: Практика OpenResty: авторизация, JWT, кэш, Redis

Сообщение kafka8 »

Вот про alg-confusion прям глаза открыло. У нас на проде висел lua-resty-jwt без alg_whitelist, пошёл проверять прям сейчас. Спасибо что разжевали с HS256 и публичным ключом.
👍 ❤️ 🔥 😄 🤔
Аватара пользователя
sddfs
Сообщения: 2
Зарегистрирован: 17 май 2026, 12:54

Re: Практика OpenResty: авторизация, JWT, кэш, Redis

Сообщение sddfs »

Вопрос по mlcache: если cache:update гонять таймером раз в секунду на каждом воркере - это не дорого при 16 воркерах? Или там events дёшевые когда инвалидаций нет?
👍1 ❤️ 🔥2 😄 🤔1
Ответить
← Предыдущая глава
Lua API: ngx.*, shared dict, cosocket и lua-resty-core
Следующая глава →
Angie: современный форк Nginx 2026

Все главы курса «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Поделиться темой: ✈ Telegram VK
Похожие запросы: OpenResty: nginx на Lua

Вернуться в «Nginx профессионально: от первого конфига до API-gateway на OpenResty и Angie»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 2 гостя