OpenResty 边缘鉴权实战:签名校验与限流

OpenResty 边缘鉴权指的是把签名校验、限流和封禁逻辑放到 Nginx 的 Lua 阶段执行,请求还没进后端就被判定,业务代码一行不用改。适合已经有 OpenResty 或 lua-nginx-module 的网关团队:客户端是自有 App 或小程序,需要防抓包重放和参数篡改,而 CDN 侧只能按 IP、UA 做粗粒度拦截。下面给出一套可直接落地的配置,包含 HMAC 签名校验、时间戳窗口、nonce 防重放、共享内存滑动窗口限流和动态封禁。

适用场景

满足以下任一条件,边缘鉴权比在应用层做更划算:

  • 对外提供 API,调用方是自有客户端,需要证明请求确实由持有密钥的一方发出;
  • 后端有多个服务,不希望每个服务重复实现一遍鉴权中间件;
  • 需要对单一来源做分钟级限流,且要求改规则时只 reload 不重启业务;
  • 出现刷量或盗链时,需要在秒级封禁一批 IP,而不是等 WAF 规则下发。

不适合的场景也要说清楚:需要标准 OAuth 2.0 授权码流程的开放平台,应该交给 APISIX、Kong 这类网关;纯静态站点的鉴权放在 CDN 的签名 URL 上更省事。

前置条件

  • OpenResty 1.21.4 以上,或 Nginx 加 lua-nginx-module;lua-resty-hmac 与 lua-resty-string 随 OpenResty 自带,无需额外安装;
  • 客户端与服务端共享同一个 secret,且客户端能实现 HMAC-SHA256;
  • 服务器时间必须 NTP 同步,偏差控制在 ±30 秒以内,否则时间戳窗口会误杀正常请求;
  • 有 nginx.conf 的修改权限,可执行 reload。

原理说明

Nginx 处理请求分为 rewrite、access、content 三个主要阶段,access_by_lua 位于权限判定阶段,此时请求体通常尚未读取,做拒绝最省资源。共享内存 lua_shared_dict 由所有 worker 进程共同访问,incr 是原子操作,因此 nonce 判重和计数器必须放在这里,用 Lua 全局变量在多 worker 下会各算各的。

签名串必须覆盖请求方法、路径、时间戳、nonce 四项。少了路径,攻击者可以把 A 接口的签名挪到 B 接口用;少了时间戳,签名永久有效;少了 nonce,同一秒内的重复请求无法区分。nonce 字典的 TTL 设为时间窗的两倍,既能挡住窗口内的重放,又不会无限占内存。

还有一个容易踩坑的点:请求先经过 CDN 再到边缘节点时,签名里的路径必须是客户端请求的原始 URI。部分 CDN 会做路径归一化,把连续斜杠、../ 合并掉,两边算出的签名串就不一致,表现为客户端自测通过、线上全量 401。解决办法是统一在 CDN 侧关闭路径改写,或签名串直接取客户端原始请求行。

操作步骤

第一步:安装 OpenResty

sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://openresty.org/package/centos/openresty.repo
sudo yum install -y openresty openresty-resty
/usr/local/openresty/bin/openresty -V
# Debian/Ubuntu 用官方 apt 源
# wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -

第二步:声明共享内存与 Lua 路径

三个字典分别承载 nonce、限流计数和封禁名单,容量按业务量估算:单个 nonce 键约 100 字节,16m 约可容纳 15 万个;限流键按 IP 计,32m 足够 20 万 IP 的分钟级窗口。

http {
    lua_shared_dict auth_nonce 16m;
    lua_shared_dict api_rate   32m;
    lua_shared_dict ban_list    8m;
    lua_package_path "/usr/local/openresty/nginx/conf/lua/?.lua;;";

    server {
        listen 80;
        server_name api.example.com;

        location / {
            access_by_lua_file /usr/local/openresty/nginx/conf/lua/sign_check.lua;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_pass http://127.0.0.1:8080;
        }
    }
}

第三步:编写签名校验脚本

secret 从环境变量读取,不要写死在配置文件里。WINDOW 是允许的时间偏差,120 是单 IP 每分钟的请求上限,按实际接口耗时调整。

-- /usr/local/openresty/nginx/conf/lua/sign_check.lua
local hmac = require "resty.hmac"
local str  = require "resty.string"

local SECRET  = os.getenv("EDGE_HMAC_SECRET") or "change-me"
local WINDOW  = 300
local nonce_d = ngx.shared.auth_nonce
local rate_d  = ngx.shared.api_rate
local ban_d   = ngx.shared.ban_list

local ip = ngx.var.remote_addr
if ban_d:get(ip) then
    ngx.log(ngx.WARN, "banned ip=", ip)
    return ngx.exit(403)
end

local h = ngx.req.get_headers()
local ts, nonce, sign = h["X-Ts"], h["X-Nonce"], h["X-Sign"]
if not (ts and nonce and sign) then
    return ngx.exit(401)
end

local now = ngx.time()
if math.abs(now - (tonumber(ts) or 0)) > WINDOW then
    ngx.log(ngx.WARN, "ts skew ip=", ip, " ts=", ts)
    return ngx.exit(401)
end

if nonce_d:get(nonce) then
    ngx.log(ngx.WARN, "replay ip=", ip, " nonce=", nonce)
    return ngx.exit(401)
end

local raw = ngx.var.request_method .. "\n" .. ngx.var.uri .. "\n" .. ts .. "\n" .. nonce
local mac = hmac:new(SECRET, hmac.ALGOS.SHA256)
if not mac then
    return ngx.exit(500)
end
mac:update(raw)
local expect = str.to_hex(mac:final())

if expect ~= sign then
    ngx.log(ngx.WARN, "bad sign ip=", ip, " uri=", ngx.var.uri)
    return ngx.exit(401)
end
nonce_d:set(nonce, true, WINDOW * 2)

local key = "r:" .. ip .. ":" .. math.floor(now / 60)
local n, err = rate_d:incr(key, 1, 0, 60)
if not n then
    ngx.log(ngx.ERR, "rate dict err=", err)
    return ngx.exit(500)
end
if n > 120 then
    ban_d:set(ip, true, 300)
    ngx.log(ngx.WARN, "rate limit hit, ban 300s ip=", ip)
    return ngx.exit(429)
end

限流键里拼上 math.floor(now / 60) 是为了让窗口随分钟滚动,避免固定窗口在整点前后出现双倍放量。要按接口分别限流,把 ngx.var.uri 拼进 key 即可,但键数量会随接口数增长,字典容量需要同步放大。触发 429 后立刻封禁 300 秒属于激进策略:面向真实用户的接口建议只返回 429 不封禁,把封禁留给明显的扫描特征。

第四步:开放受限的封禁接口

封禁入口只允许内网段访问,生产环境建议再加一层 mTLS 或跳板机白名单。

location /_edge/ban {
    allow 10.0.0.0/8;
    deny all;
    content_by_lua_block {
        local ban_d = ngx.shared.ban_list
        local ip  = ngx.var.arg_ip
        local ttl = tonumber(ngx.var.arg_ttl) or 3600
        local m   = ngx.req.get_method()
        if not ip then
            ngx.status = 400
            return ngx.say("missing ip")
        end
        if m == "POST" then
            ban_d:set(ip, true, ttl)
            ngx.say("banned ", ip, " ", ttl, "s")
        elseif m == "DELETE" then
            ban_d:delete(ip)
            ngx.say("unbanned ", ip)
        else
            ngx.status = 405
            ngx.say("method not allowed")
        end
    }
}

第五步:补充日志字段

log_format edge '$remote_addr [$time_local] "$request" $status '
                'rt=$request_time urt=$upstream_response_time '
                'nonce=$http_x_nonce ua="$http_user_agent"';
access_log /var/log/nginx/edge.log edge;

sudo /usr/local/openresty/bin/openresty -t && sudo /usr/local/openresty/bin/openresty -s reload

配置验证

先用 Python 生成一组合法签名打通链路,再逐项破坏验证拒绝逻辑。

import hashlib, hmac, time, uuid, requests

SECRET = b"change-me"
ts, nonce = str(int(time.time())), uuid.uuid4().hex
raw = "\n".join(["GET", "/v1/orders", ts, nonce])
sign = hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()

r = requests.get("https://api.example.com/v1/orders",
                 headers={"X-Ts": ts, "X-Nonce": nonce, "X-Sign": sign}, timeout=5)
print(r.status_code)   # 期望 200

# 重放同一组请求头,期望 401
r2 = requests.get("https://api.example.com/v1/orders",
                  headers={"X-Ts": ts, "X-Nonce": nonce, "X-Sign": sign}, timeout=5)
print(r2.status_code)  # 期望 401
  • 把 X-Ts 改成 10 分钟前,期望 401,error.log 出现 ts skew;
  • 把路径改为 /v1/users 但沿用旧签名,期望 401,日志出现 bad sign;
  • 循环发 150 次不同 nonce 的合法请求,第 121 次起返回 429,随后同 IP 全部 403;
  • 执行 openresty -t 无报错,tail -f /var/log/nginx/error.log 里不应出现 lua entry thread aborted。

常见问题

Q1:压测时 QPS 上不去,但 CPU 占用很低,是什么原因?

几乎都是 Lua 里调用了阻塞 API。os.execute、io.open、同步版 socket 库都会卡住整个 worker 的事件循环,表现就是并发上不去、CPU 空闲。鉴权阶段只做纯内存运算,需要落库或调用风控接口时用 ngx.timer.at 异步处理,HTTP 调用改用 resty.http 的 cosocket 版本。

Q2:多台边缘节点封禁不同步,或者字典写满后重放判断失效怎么办?

lua_shared_dict 是单机内存,多节点之间不共享,横向扩容后必须用 Redis 或其他外部存储同步封禁名单。字典写满时旧键按 LRU 淘汰,nonce 被提前淘汰就会出现「同一请求重放也能通过」。用下面的接口暴露剩余空间,低于 20% 就告警扩容:

location /_edge/stat {
    allow 10.0.0.0/8;
    deny all;
    content_by_lua_block {
        local d = ngx.shared.auth_nonce
        ngx.say("free=", d:free_space(), " cap=", d:capacity())
    }
}

Q3:POST 请求体被篡改能拦住吗?

默认不能,上面的签名只覆盖方法、路径、时间戳和 nonce。要覆盖请求体,需要在 access 阶段前开启 lua_need_request_body on; 并对 body 做 SHA256 摘要拼进签名串。代价是请求体先落内存或临时文件,超过 client_body_buffer_size 的大文件上传会写磁盘,因此只建议在 JSON 小请求上启用。

总结

边缘鉴权的核心是把「这个请求是否合法」的判断提前到 Nginx,用共享内存承载跨 worker 的状态。落地时有三个硬性约束:签名串必须覆盖方法、路径、时间戳、nonce 四项;secret 走环境变量不走配置文件;Lua 代码里不能出现任何阻塞调用。把这三点守住,单机 OpenResty 在常规四核机器上处理万级 QPS 的鉴权判断没有压力。多节点部署时再把 nonce 与封禁名单外挂到 Redis,就能保持行为一致。最后提醒一句:边缘鉴权解决的是「请求是否来自可信客户端」,它不能替代传输加密,也不能替代接口级的权限校验,三者要同时存在。