自定義挑戰

為了方便使用者實現符合自身業務需求的挑戰機制,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 的規則,用於模擬自定義挑戰驗證成功後展示的頁面:

進行測試

觸發挑戰頁面:

完成挑戰後,自動跳轉到受保護的資源頁面: