从 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 即使来自请求,也只能查找当前会话有权访问的记录。bindSubmissionOncecompleteBusinessOnce 是需要由应用实现的事务边界;后者应有唯一约束,确保并发或重复请求不重复注册、发货或执行其他业务动作。示例不是完整的认证系统,也不实现这些存储函数。

也可以改用 SDK:Node 的 createIntent / verify@captcha-moe/server)或 Python 的 create_intent / verifycaptchamoe-server)。这些方法会核对响应绑定,但同样不能代替你的会话和业务事务。包名和安装命令见SDK 与框架(即将发布,发布前从仓库目录安装)。

处理失败与重试

网络错误和超时可能发生在服务端提交之后。首次成功兑换后,十分钟内以相同参数和幂等键重试可取得原收据,不再次扣费。商户业务执行也必须独立幂等。不要把重试改成“令牌相同但幂等键不同”。

401 表示密钥验证失败,402 表示额度或余额不足,409 表示冲突或重复兑换,429/503 表示限流或暂不可用;均不能放行业务。按错误类型修复或用原请求重试,不能把错误统一转换为成功。API 错误说明见服务端 API

许可或令牌过期后,由后端重新授权新许可并重新渲染组件;仅重置旧组件不会延长有效期。切换服务商时已有页面中的旧令牌无法转换,提示用户重新打开表单。已冻结且结果不明的业务请求应先按原幂等上下文恢复,不能静默关联另一个许可。

可信无感许可是另行启用的功能。无论使用商户认证会话还是已完成挑战作为来源,都必须先建立正确的基本兑换链路;携带 session HMAC 本身不会自动获得豁免。

切换前验收

在自己的测试环境逐项确认:

  • 缺失、伪造、过期令牌和只调用客户端回调均不能执行业务。
  • 另一个站点、会话、action 或业务记录的令牌不能替代当前许可。
  • 相同提交并发到达或网络响应丢失后重试,业务只执行一次,验证额度只消耗一次。
  • 同一业务记录改变表单内容、token 或兑换参数会被拒绝。
  • 额度不足、API 故障和限流能显示可恢复提示,且不会放行业务。
  • 键盘、窄屏、严格 CSP、页面缓存和多表单场景工作正常,日志不包含 Secret、令牌或会话内容。

一次切换应让页面、提交字段和后端验证选择保持一致。完成切换后移除原脚本、后端调用、环境变量和仅为原服务商增加的 CSP 域名;不要留下“任一服务商通过即放行”的隐式分支。当前 CaptchaMoe 接入无需添加旧协议解析或兼容层。