自定義挑戰
為了方便使用者實現符合自身業務需求的挑戰機制,OpenResty Edge 在 25.9
版本中新增了 自定義挑戰
功能。
目前該功能可透過 Edgelang 介面啟用,在後續版本中,我們將在頁面規則動作中提供相應的配置選項。
介面說明
詳見 Edgelang 介面 enable-custom-captcha。
全域性 Lua 模組說明
用於自定義挑戰的全域性 Lua 模組需要符合特定的設計要求,才能與 OpenResty Edge 正常協作。具體要求如下:
- 必須匯出
invoke
、create
、verify
三個介面,其中invoke
和verify
是必需的,create
可按需使用。 - 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
中的 token
和 prev_url
。驗證透過後,prev_url
將作為響應體返回。
返回值為 true
或 false
。當返回 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=TOKEN
和prev_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 模組的特點:
- 定義並匯出了 3 個必需函式:
invoke
、create
、verify
- 在 HTML 頁面中,使用符合設計要求的 HTTP 請求格式呼叫
/.edge-waf/create-captcha
和/.edge-waf/edge-recaptcha
- 將驗證碼資訊儲存在共享記憶體
captcha_store
中
新增共享快取
在 全域性配置 > 全域性 Lua 模組 > 自定義共享記憶體
中,輸入 captcha_store
,然後儲存:
在應用中使用自定義挑戰
首先新增一條啟用自定義挑戰的規則:
然後新增一條返回 Hello
的規則,用於模擬自定義挑戰驗證成功後展示的頁面:
進行測試
觸發挑戰頁面:
完成挑戰後,自動跳轉到受保護的資源頁面: