自定义挑战

为了方便用户实现符合自身业务需求的挑战机制,OpenResty Edge 在 25.9 版本中新增了 自定义挑战 功能。

目前该功能可通过 Edgelang 接口启用,在后续版本中,我们将在页面规则动作中提供相应的配置选项。

接口说明

详见 Edgelang 接口 enable-custom-captcha

全局 Lua 模块说明

用于自定义挑战的全局 Lua 模块需要符合特定的设计要求,才能与 OpenResty Edge 正常协作。具体要求如下:

  1. 必须导出 invokecreateverify 三个接口,其中 invokeverify 是必需的,create 可按需使用。
  2. HTML 页面中调用的接口需要使用指定的 URL 及方法,详见后续的接口说明。

invoke 接口说明

此接口用于在触发挑战时返回 HTML 页面。

function _M.invoke(params)
    -- return HTML page
end

参数 params 包含:

  • token:加密数据,需要渲染到 HTML 页面中,传递给后续的挑战接口调用
  • time:触发挑战的时间戳,可用于在后续接口中拒绝已过期的挑战。单位为秒,精度为毫秒
  • prev_url:客户端原本请求的 URL,挑战通过后通常需要跳转回此 URL。示例:/hello.html
  • clearance_time:验证结果的有效时间,单位为秒。示例:60

返回值为最终响应的 HTML 页面内容。

create 接口说明

此接口用于创建挑战内容,或在刷新时重新生成挑战内容。 如果您的挑战不需要支持刷新功能,可以在 invoke 阶段直接渲染挑战内容,无需再触发调用此接口。

function _M.create(params, uri_args)
    -- return challenge content
end

参数 params 包含:

  • time:触发挑战的时间戳,可用于拒绝已过期的挑战。单位为秒,精度为毫秒
  • clearance_time:验证结果的有效时间,单位为秒。示例:60

参数 uri_args 必须包含 invoke 接口参数 params 中的 token

返回值为生成的挑战内容。

此接口响应后,需要由您预先定义的 HTML 页面进行处理。

您需要使用固定的 URI 来调用 create 接口:GET /.edge-waf/create-captcha?token=TOKEN

  • 请求 URI:/.edge-waf/create-captcha
  • 请求方法:GET
  • URL 参数:token=TOKEN

verify 接口说明

此接口用于验证挑战结果。

function _M.verify(params, post_args)
    local ok, err = result_verify(params, post_args)
    if not ok then
        return false
    end

    return true
end

参数 params 包含:

  • time:触发挑战的时间戳,可用于拒绝已过期的挑战。单位为秒,精度为毫秒
  • clearance_time:验证结果的有效时间,单位为秒。示例:60

参数 post_args 必须包含 invoke 接口参数 params 中的 tokenprev_url。验证通过后,prev_url 将作为响应体返回。

返回值为 truefalse。当返回 false 时,请求将响应 403 状态码。您也可以在此接口中根据需要提前响应其他状态码。

此接口响应后,需要由您预先定义的 HTML 页面进行处理。

您需要使用特定的请求格式来调用 verify 接口:

POST /.edge-waf/edge-recaptcha
Content-Type: application/x-www-form-urlencoded

token=TOKEN&prev_url=PREV_URL
  • 请求 URI:/.edge-waf/edge-recaptcha
  • 请求方法:POST
  • 请求头:Content-Type: application/x-www-form-urlencoded
  • 请求体:必须包含参数 token=TOKENprev_url=PREV_URL

实际应用示例

下面演示如何自定义一个验证码挑战。

添加全局 Lua 模块

全局配置 > 全局 Lua 模块 > 自定义共享内存 中,创建名为 example-custom-captcha 的全局 Lua 模块:

源码如下:

local resty_captcha = require "resty.captcha"
local ck = require "resty.cookie"
local resty_random = require "resty.random"
local resty_string = require "resty.string"
local font_path = ngx.config.prefix() .. "font/Vera.ttf"

local captcha_dict = ngx.shared.captcha_store
local str_lower = string.lower
local str_fmt = string.format

local _M = {}

function _M.invoke(params)
    local html_template = [[
<!doctype html>
<html>
<head>
    <title>Response</title>
</head>
<body>
    <img id="captcha_image" src="/.edge-waf/create-captcha?token=%s" alt="captcha" class="captcha_image">
    <form id="edge-recaptcha-form">
        <input type="text" id="edge-input-captcha" name="captcha" class="outline-input text-short" style=" border: 1px solid;">
        <input type="hidden" id="edge-input-prev_url" name="prev_url" value="%s">
        <input type="hidden" id="edge-input-token" name="token" value="%s">
        <input type="submit" style="border: 1px solid; padding: 0px 18px;">
        <p id="captcha_error" style="color: red; margin-top: 15px; line-height: 1.5;">
    <form>
    <script type="text/javascript">
        var URL = '/.edge-waf/edge-recaptcha';
        var form = document.getElementById('edge-recaptcha-form');
        form.addEventListener('submit', function (e) {
            e.preventDefault();
            var xhr = new XMLHttpRequest();
            var captcha = document.getElementById('edge-input-captcha').value;
            var prevUrl = document.getElementById('edge-input-prev_url').value;
            var token = document.getElementById('edge-input-token').value;
            if (window.XMLHttpRequest) {
                xhr = new XMLHttpRequest();
            } else if (window.ActiveXObject) {
                xhr = new ActiveXObject('Microsoft.XMLHTTP');
            }
            xhr.onreadystatechange = function () {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        window.location.replace(xhr.responseText);
                    } else if (xhr.status > 400 && xhr.status < 500) {
                        document.getElementById('captcha_error').innerText = 'Verification failed, please refresh and try again.';
                    } else if (xhr.status >= 500) {
                        var reqId = xhr.getResponseHeader('req-id');
                        document.getElementById('captcha_error').innerText = 'Verification failed, please refresh and try again. Status code: ' + xhr.status + ', Request ID: ' + reqId;
                    }
                }
            };
            xhr.open('POST', URL, true);
            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
            xhr.send('captcha=' + captcha + '&prev_url=' + prevUrl + '&token=' + token);
        });
    </script>
</body>
</html>
    ]]
    return str_fmt(html_template, params.token, params.prev_url, params.token)
end

local function generate_session_id()
    local bytes = resty_random.bytes(16)
    return resty_string.to_hex(bytes)
end

function _M.create(params, uri_args)
    local cap = resty_captcha.new()
    cap:font(font_path)
    cap:length(4)
    cap:scribble()
    cap:generate()

    local session_id = generate_session_id()
    local captcha_text = cap:getStr()

    local ttl = params.clearance_time or 60
    captcha_dict:set(session_id, str_lower(captcha_text), ttl)

    local cookie = ck:new()
    cookie:set({
        key = "captcha_session",
        value = session_id,
        path = "/",
        httponly = true,
        max_age = ttl
    })

    return cap:jpegStr(70)
end

function _M.verify(params, post_args)
    local cookie = ck:new()
    local session_id = cookie:get("captcha_session")

    if not session_id then
        ngx.log(ngx.ERR, "missing captcha session")
        ngx.exit(403)
    end

    local stored_captcha = captcha_dict:get(session_id)
    if not stored_captcha then
        ngx.log(ngx.ERR, "captcha session expired or invalid")
        ngx.exit(403)
    end

    local user_input = str_lower(post_args["captcha"] or "")

    captcha_dict:delete(session_id)

    return user_input == stored_captcha
end

return _M

此 Lua 模块的特点:

  1. 定义并导出了 3 个必需函数:invokecreateverify
  2. 在 HTML 页面中,使用符合设计要求的 HTTP 请求格式调用 /.edge-waf/create-captcha/.edge-waf/edge-recaptcha
  3. 将验证码信息存储在共享内存 captcha_store

添加共享缓存

全局配置 > 全局 Lua 模块 > 自定义共享内存 中,输入 captcha_store,然后保存:

在应用中使用自定义挑战

首先添加一条启用自定义挑战的规则:

然后添加一条返回 Hello 的规则,用于模拟自定义挑战验证成功后展示的页面:

进行测试

触发挑战页面:

完成挑战后,自动跳转到受保护的资源页面: