自定义挑战
为了方便用户实现符合自身业务需求的挑战机制,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
的规则,用于模拟自定义挑战验证成功后展示的页面:
进行测试
触发挑战页面:
完成挑战后,自动跳转到受保护的资源页面: