【Openresty】Lua脚本实现对外开放接口认证授权

277 阅读7分钟

在项目的开发过程中,我们经常会碰到要把自身业务系统的接口开放给项目中的其他平台进行对接使用的状况。这时,我们通常会搭建 API 网关(比如 Kong、Spring Gateway 等),将其作为其他平台流量的唯一入口,对开放出去的接口进行统一的路由处理(包括身份认证与授权、流量控制、安全防护等方面)。

不过,对于中小型项目来说,如果只是对外开放几个接口,部署 Kong、Spring Gateway 可能会显得太过复杂。此时,我们可以利用 Openresty 的 Lua 脚本实现第三方平台的访问认证授权、安全防护等功能。这种方法简单且易于部署,不会给业务服务带来额外的影响。

整体实现流程如下所示

(一)整体流程分为三个阶段

第一阶段:准备与凭证下发

此阶段发生在业务对接之前,第三方平台需从网关管理者处获取访问凭证。

  1. 申请凭证:第三方平台向网关方申请接入。

  2. 注册与配置:网关管理员在网关(OpenResty)的 Lua 脚本或配置数据库中,为该第三方平台创建唯一标识和密钥,并为其分配可访问的 API 接口权限。

    注册信息包含:

    • client_id:客户端唯一标识。

    • client_secret:用于生成签名的密钥,务必保密。

    • API 权限:明确该客户端可访问的 API 接口。

  3. 下发凭证:网关方将 client_id 和 client_secret 安全地分发给第三方平台。 

第二阶段:请求与网关校验

每当第三方平台需要调用业务接口时,都会执行以下流程。网关(OpenResty)会拦截所有请求,并进行一系列严格校验。

  1. 发起请求

第三方平台向目标业务接口发起 HTTP 请求,且请求头(Header)中必须携带以下三个字段:

Authorization:包含客户端标识(client_id)和请求签名(Signature)。

X-Nonce:一个唯一的随机字符串,用于防止请求重放。

X-Timestamp:发起请求时的时间戳(通常是 Unix 时间戳)。

  1. 网关拦截与校验(核心流程)

网关接收到请求后,会按顺序执行如下检查,任何一步失败都会立即终止流程,并返回相应的错误 HTTP 状态码。

步骤 1:检查头部完整性

判断:检查请求头中是否完整包含 Authorization、X - Nonce、X - Timestamp 字段。

结果:若不包含立即返回 HTTP 400 Bad Request 错误(errorMsg:Authorization header required)。

步骤 2:检查时间戳有效性

判断:检查 X-Timestamp 中的时间戳与网关当前时间是否在允许的时间窗口内(± 5 分钟)。

结果:若超出 5 分钟 立即返回 HTTP 400 Bad Request 错误(errorMsg: Expired timestamp)。

步骤 3:检查随机数重放

判断:检查 X-Nonce 的值在最近 1 分钟内是否已经被使用过(防止重放攻击)。

结果:若已使用过 立即返回 HTTP 400 Bad Request 错误(errorMsg: Duplicate nonce detected)。

步骤 4:解析并校验客户端身份

操作:从 Authorization 头中解析出 client_id。

判断:检查该 client_id 是否在网关中有注册记录(是否命中配置)。

结果:若为无效的 client_id立即返回 HTTP 401 Unauthorized 错误(error: Invalid client credentials)。

步骤 5:验证请求签名

操作:网关使用解析出的 client_id 查到对应的 client_secret,然后使用相同的签名算法(如 HMAC - SHA256)对收到的请求内容(通常包含 URL、参数、时间戳、随机数等)重新计算一次签名。

判断:将计算出的签名与 Authorization 头中收到的签名进行比对,检查是否一致。

结果:若签名不匹配立即返回 HTTP 401 Unauthorized 错误(error: Signature error)。

步骤 6:校验 API 访问权限

判断:检查该 client_id 对应的权限配置是否包含当前所请求的 API 接口。

结果:若无权限立即返回 HTTP 403 Forbidden 错误(error: Access denied to this API)。

第三阶段:业务处理与响应

  1. 转发请求:唯有通过上述所有校验,网关才会将请求转发至后端的业务服务。

  2. 业务处理:业务服务执行具体的业务逻辑,例如处理数据、查询数据库等。

  3. 返回结果:业务服务将处理结果反馈给网关,网关最终把业务结果的状态码及数据回传给第三方平台。 

(二)Authorizaiton签名说明

Header格式

Authorization: Credential={client_id},Signature={computed_signature}

参数说明:

  • Credential={client_id},客户端请求凭证,由管理员颁发。

  • Signature={computed_signature},根据签名规则计算生成的签名。

签名规则

签名字符串拼接SignatureString=host+uri+timestamp+nonce

使用HMAC_SHA256计算签名 HMac mac=new Hmac({client_secret},"HmacSHA256");

computed_signature=mac.digestHex(SignatureString);

举例

host:127.0.0.1
uri:/open/api/test/v1/get
X-Timestamp1755483804782
X-Nonce:211774a672213ff367889de98685a868

拼接字符串为:
SignatureString="127.0.0.1|/open/api/test/v1/get|1755483804782|211774a672213ff367889de98685a868"

(三)网关Lua脚本

Nginx配置

http {
    include       mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] $host "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /dev/stdout  main;
    error_log  /dev/stdout  warn;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;
    client_body_buffer_size 256k; #默认8k|16k
    client_max_body_size 500m; #默认1m

    # 共享内存存储
    lua_shared_dict clients 5m;  # 存储客户端密钥
    lua_shared_dict nonces  5m;   # 防重放攻击

    #gzip  on;
    lua_package_path "/usr/local/openresty/nginx/conf/signature/?.lua;";
    
    location ^~ /open/api/test/v1/ {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods GET,POST;

        proxy_pass http://127.0.0.1:7777;
        access_by_lua_file /usr/local/openresty/nginx/conf/signature/hmac-auth.lua;
    }
}

hmac-auth.lua的脚本内容如下

local ngx = ngx
local string = string
local table = table
local cjson = require "cjson"
local hmac = require "hmac"
local resty_sha256 = require "resty.sha256"
local resty_string = require "resty.string"

-- 客户端信息
local CLIENTS = {
    ["example"] = {
        secret = "adbf5a778175ee757c34d0eba4e932bc",
        allowed_paths = {
            "^/open/api/test/v1/.*",
        }
    }
}

-- 验证权限
local function check_permission(client_id, request_uri)
    local client = CLIENTS[client_id]
    if not client then
        return false
    end

    for _, pattern in ipairs(client.allowed_paths) do
        if string.match(request_uri, pattern) then
            return true
        end
    end
    return false
end

-- 解析Authorization头
local function parse_auth_header(auth_header)
    local pattern = "Credential=([^,]+),Signature=([^,]+)"
    local client_id, signature = string.match(auth_header, pattern)
    return client_id, signature
end

-- 验证签名
local function verify_signature(client_id, received_sig)
    -- 获取其他必要参数
    local host = ngx.req.get_headers()["Host"]
    local timestamp = ngx.req.get_headers()["X-Timestamp"]
    local nonce = ngx.req.get_headers()["X-Nonce"]
    local request_uri = ngx.var.uri

    -- 参数检查
    if not (host and timestamp and nonce) then
        return false, ngx.HTTP_BAD_REQUEST, "Missing required headers"
    end

    -- 验证时间戳(5分钟有效期)
    local now = ngx.time() * 1000;
    if math.abs(now - tonumber(timestamp)) > 300000 then
        return false, ngx.HTTP_BAD_REQUEST, "Expired timestamp"
    end

    -- 防重放攻击
    local nonces = ngx.shared.nonces
    if nonces:get(nonce) then
        return false, ngx.HTTP_BAD_REQUEST, "Duplicate nonce detected"
    end
    nonces:set(nonce, true, 60)  -- 保留1分钟

    -- 获取客户端密钥
    local client = CLIENTS[client_id]
    if not client or not client.secret then
        return false, ngx.HTTP_BAD_REQUEST, "Invalid client credentials"
    end

    -- 计算签名
    local signing_string = table.concat({
        host,
        request_uri,
        timestamp,
        nonce
    }, "|")

    local hmac_sha256 = hmac:new(client.secret, hmac.ALGOS.SHA256)
    if not hmac_sha256 then
        return false, ngx.HTTP_INTERNAL_SERVER_ERROR, "Failed to initialize ALG"
    end

    hmac_sha256:update(signing_string)
    local computed_sig = resty_string.to_hex(hmac_sha256:final())

    -- 验证签名
    if computed_sig ~= received_sig then
        return false, ngx.HTTP_BAD_REQUEST, "Signature error"
    end

    -- 检查API权限
    if not check_permission(client_id, request_uri) then
        return false, ngx.HTTP_FORBIDDEN, "Access denied to this API"
    end

    return true, ngx.HTTP_OK, "OK"
end


-- 主验证逻辑
local auth_header = ngx.req.get_headers()["Authorization"]
if not auth_header then
    ngx.header["WWW-Authenticate"] = 'Auth realm="API"'
    ngx.status = ngx.HTTP_UNAUTHORIZED
    ngx.header.content_type = "application/json; charset=utf-8";
    ngx.say('{"error": "Authorization header required"}')
    ngx.exit(ngx.HTTP_UNAUTHORIZED)
end

local client_id, signature = parse_auth_header(auth_header)
if not (client_id and signature) then
    ngx.status = ngx.HTTP_BAD_REQUEST
    ngx.header.content_type = "application/json; charset=utf-8";
    ngx.say('{"error": "Malformed Authorization header"}')
    ngx.exit(ngx.HTTP_BAD_REQUEST)
end

local ok, status, err = verify_signature(client_id, signature)
if not ok then
    ngx.status = status
    ngx.header.content_type = "application/json; charset=utf-8";
    ngx.say(cjson.encode({ msg = err, timestamp = ngx.now() * 1000 }))
    ngx.exit(ngx.status);
end

(四)JAVA代码请求示例

引用Hutoll工具包

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.19</version>
</dependency>

示例代码

package com.example.hmac;

import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

import java.util.UUID;

public class TestHmac {

    private static final String CLIENT_ID = "example";
    private static final String CLIENT_SECRET = "adbf5a778175ee757c34d0eba4e932bc";
    private static final String API_HOST = "127.0.0.1";

    public static void main(String[] args) {

        String apiPath = "/open/api/test/v1/get";

        // 1. 生成必要参数
        long timestamp = System.currentTimeMillis(); // 秒级时间戳
        String nonce = IdUtil.fastSimpleUUID();

        // 2. 构建签名字符串
        String signingString = StrUtil.format("{}|{}|{}|{}", API_HOST, apiPath, timestamp, nonce);

        // 3. 计算HMAC-SHA256签名(Hutool实现)
        HMac hmac = new HMac(HmacAlgorithm.HmacSHA256, CLIENT_SECRET.getBytes());
        String signature = hmac.digestHex(signingString);

        // 2. 构建请求
        JSONObject requestBody = new JSONObject();
        requestBody.put("data", "test");
        requestBody.put("seq", "1123");
        requestBody.put("timestamp",timestamp);

        HttpRequest request = HttpRequest.post("https://" + API_HOST + apiPath)
            .header("Host", API_HOST)
            .header("X-Timestamp", String.valueOf(timestamp))
            .header("X-Nonce", nonce)
            .header("Authorization",
                    StrUtil.format("Credential={},Signature={}", CLIENT_ID, signature))
            .body(requestBody.toJSONString())
            .timeout(10000);
        System.out.println(request.toString());

        // 3. 输出结果
        HttpResponse response = request.execute();
        System.out.println("状态码: " + response.getStatus());
        System.out.println("响应体: " + response.body());
    }
}