从 reCAPTCHA 或 hCaptcha 切换
切换需要同时修改浏览器组件和商户后端。CaptchaMoe 的当前流程是:后端创建业务许可,浏览器完成挑战,后端兑换结果,然后幂等执行业务。现有服务商的密钥、令牌和接口响应不能直接用于这个流程。
本文面向尚未切换的商户应用;CaptchaMoe 自身仅提供当前契约。原生请求示例见接入第一个表单,字段说明见服务端 API。
确认需要替换的位置
reCAPTCHA 表单通常提交 g-recaptcha-response,hCaptcha 表单提交 h-captcha-response;两者都要求服务端验证收到的令牌。hCaptcha 的验证接口要求表单编码请求。请以各自的reCAPTCHA 验证文档和hCaptcha 开发文档核对当前接入点。
切换到 CaptchaMoe 后:
- 使用新站点的 Site key 和 Secret key。Site key 可以公开;Secret 只保存在后端配置或密钥存储中。
- 加载
https://captcha.moe/widget.js(自己部署时换成你的服务地址),容器使用moe-captcha,提交字段为moe-captcha-response。 - 渲染前调用
POST https://captcha.moe/intents,保存返回的intent_id,再把它写入组件的data-intent。 - 后端调用
POST https://captcha.moe/siteverify。请求体只接受 JSON(Content-Type: application/json),Secret 放在Authorization: Bearer <site secret>请求头里——不要沿用 reCAPTCHA / hCaptcha 的表单编码请求和请求体里的secret字段,否则会得到unsupported-media-type(表单编码请求)或secret-in-body(请求体里带了 secret)。 - 除浏览器提交的 response 外,兑换使用的 intent_id、action、hostname 和 idempotency_key 均从后端保存的业务记录读取。
原服务商的分数、错误码和客户端回调不能作为 CaptchaMoe 的判断条件。CaptchaMoe 返回的 assessment: "passed" 是本次许可通过的结果,不是可直接套用原有分数阈值的人类概率。原来的业务限流、认证、CSRF 防护和二次验证仍由商户负责。
在渲染前创建并保存许可
先为具体业务操作建立后端记录,例如待提交的注册请求。记录应包含认证会话关联、随机 nonce、独立随机幂等键、固定 action 和配置中的目标域名。不要从浏览器请求的隐藏字段或未验证的 Host 头选择这些值。
const CAPTCHA_API = 'https://captcha.moe';
const operation = {
nonce: crypto.randomUUID(),
idempotency_key: crypto.randomUUID(),
action: 'signup',
hostname: 'example.com',
};
// 先持久保存到当前会话的业务记录,再发送网络请求。
await store.savePending(sessionId, operation);
const response = await fetch(`${CAPTCHA_API}/intents`, {
method: 'POST',
redirect: 'error',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${CAPTCHA_SECRET}`,
},
body: JSON.stringify({
nonce: operation.nonce,
action: operation.action,
hostname: operation.hostname,
}),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Verification unavailable');
const permit = await response.json();
if (permit.sitekey !== CAPTCHA_SITEKEY ||
permit.action !== operation.action ||
permit.hostname !== operation.hostname ||
typeof permit.intent_id !== 'string') {
throw new Error('Unexpected verification permit');
}
await store.attachIntent(sessionId, operation.nonce, permit.intent_id);store 表示商户自己的持久存储,sessionId 来自已经建立的后端会话。示例中的配置常量来自后端部署配置。attachIntent 应原子关联当前业务记录;不能覆盖另一个已关联许可。创建响应丢失时,用保存的相同 nonce 和字段重试;不要先生成新 nonce。
向模板填入 Site key、intent_id 和服务地址时,使用模板引擎的 HTML 属性转义,并禁止把个性化许可页面放进共享缓存。
<script src="https://captcha.moe/widget.js" async defer></script>
<form method="post" action="/signup">
<!-- 保留你的 CSRF 字段和业务输入。 -->
<div class="moe-captcha"
data-sitekey="YOUR_PUBLIC_SITE_KEY"
data-intent="SERVER_SAVED_INTENT_UUID"
data-endpoint="https://captcha.moe"></div>
<button type="submit">提交</button>
</form>调整 CSP 时允许所选服务域名的组件脚本和 API 连接;Worker、严格 CSP 和键盘操作的验证步骤见组件配置。多表单页面中的不同业务操作使用各自的许可。
在业务提交端兑换
删除对原服务商验证接口和原响应字段的依赖。客户端完成回调只能更新界面;后端仍须完成以下检查:
const operation = await store.loadForSession(sessionId, operationId);
if (!operation) throw new Error('Business session expired');
const token = submittedForm['moe-captcha-response'];
if (typeof token !== 'string' || !token) throw new Error('Verification required');
// 首次提交原子冻结业务载荷;重试必须与保存的载荷完全一致。
await store.bindSubmissionOnce(operation.id, businessPayload, token);
const response = await fetch(`${CAPTCHA_API}/siteverify`, {
method: 'POST',
redirect: 'error',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${CAPTCHA_SECRET}`,
},
body: JSON.stringify({
response: token,
intent_id: operation.intent_id,
action: operation.action,
hostname: operation.hostname,
idempotency_key: operation.idempotency_key,
}),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Verification failed or unavailable');
const result = await response.json();
if (result.success !== true || result.assessment !== 'passed' ||
result.intent_id !== operation.intent_id ||
result.action !== operation.action || result.hostname !== operation.hostname ||
typeof result.decision_id !== 'string') {
throw new Error('Verification binding mismatch');
}
await store.completeBusinessOnce(operation.id, result.decision_id);operationId 即使来自请求,也只能查找当前会话有权访问的记录。bindSubmissionOnce 和 completeBusinessOnce 是需要由应用实现的事务边界;后者应有唯一约束,确保并发或重复请求不重复注册、发货或执行其他业务动作。示例不是完整的认证系统,也不实现这些存储函数。
也可以改用 SDK:Node 的 createIntent / verify(@captcha-moe/server)或 Python 的 create_intent / verify(captchamoe-server)。这些方法会核对响应绑定,但同样不能代替你的会话和业务事务。包名和安装命令见SDK 与框架(即将发布,发布前从仓库目录安装)。
处理失败与重试
网络错误和超时可能发生在服务端提交之后。首次成功兑换后,十分钟内以相同参数和幂等键重试可取得原收据,不再次扣费。商户业务执行也必须独立幂等。不要把重试改成“令牌相同但幂等键不同”。
401 表示密钥验证失败,402 表示额度或余额不足,409 表示冲突或重复兑换,429/503 表示限流或暂不可用;均不能放行业务。按错误类型修复或用原请求重试,不能把错误统一转换为成功。API 错误说明见服务端 API。
许可或令牌过期后,由后端重新授权新许可并重新渲染组件;仅重置旧组件不会延长有效期。切换服务商时已有页面中的旧令牌无法转换,提示用户重新打开表单。已冻结且结果不明的业务请求应先按原幂等上下文恢复,不能静默关联另一个许可。
可信无感许可是另行启用的功能。无论使用商户认证会话还是已完成挑战作为来源,都必须先建立正确的基本兑换链路;携带 session HMAC 本身不会自动获得豁免。
切换前验收
在自己的测试环境逐项确认:
- 缺失、伪造、过期令牌和只调用客户端回调均不能执行业务。
- 另一个站点、会话、action 或业务记录的令牌不能替代当前许可。
- 相同提交并发到达或网络响应丢失后重试,业务只执行一次,验证额度只消耗一次。
- 同一业务记录改变表单内容、token 或兑换参数会被拒绝。
- 额度不足、API 故障和限流能显示可恢复提示,且不会放行业务。
- 键盘、窄屏、严格 CSP、页面缓存和多表单场景工作正常,日志不包含 Secret、令牌或会话内容。
一次切换应让页面、提交字段和后端验证选择保持一致。完成切换后移除原脚本、后端调用、环境变量和仅为原服务商增加的 CSP 域名;不要留下“任一服务商通过即放行”的隐式分支。当前 CaptchaMoe 接入无需添加旧协议解析或兼容层。