本文重点使用已经上线的实例
https://cap-pow.wuw.li/。需要先说明:这个实例采用的是onexru/cap-pow-php-server的接口形态并配有定制前端脚本;它与当前 Cap 官方 Standalone(Docker + Valkey + 站点密钥)不是同一套部署和调用方式,不能把两套示例直接混用。
传统验证码经常要求选图片、拖动滑块,既打断操作,也可能引入第三方跟踪。Cap/Cap-Pow 的思路是让浏览器完成一小段工作量证明(Proof of Work,PoW):服务端生成挑战,浏览器计算答案,服务端核验后签发一次性 token,业务后端再消费这个 token。
本文分为三部分:
- 直接使用已经部署好的
cap-pow.wuw.li; - 自建与该实例相同接口的 PHP 服务;
- 了解当前官方 Cap Standalone 的 Docker 方案及其差异。
一、先理解完整验证链路
以现有实例为例,流程不是“前端显示一个绿色勾就算通过”,而是:
- 浏览器向
POST /challenge获取挑战; - 浏览器计算 SHA-256 PoW;
- 浏览器将挑战 token 和答案提交到
POST /redeem; - 服务端返回验证 token;
- 页面把验证 token 与表单数据一起交给业务后端;
- 业务后端调用
POST /api/validate; - 只有响应中的
success为true,业务后端才继续处理登录、注册、留言或表单提交。
cap-pow.wuw.li 首页列出的三个接口正是 /challenge、/redeem 和 /api/validate。实际页面脚本也实现了上述流程。
安全边界一定要记住:前端回调只能拿到 token,真正决定“放行”的步骤必须在业务后端。 如果只在 JavaScript 中判断,攻击者可以绕开页面直接请求业务接口。
二、直接使用已部署实例
1. 引入现有实例的脚本和样式
页面提供了可跨域加载的 JS 与 CSS:
<link rel="stylesheet" href="https://cap-pow.wuw.li/cap-pow.css">
<script src="https://cap-pow.wuw.li/cap-pow.js"></script>
当前脚本为零依赖实现,内部固定使用 https://cap-pow.wuw.li 作为 API 地址;计算依赖浏览器的 TextEncoder、crypto.subtle.digest() 和 fetch()。
为了避免只复制首页中带省略号的演示代码,可使用下面这份与现有 CSS/JS 类名匹配的完整组件:
<div class="cap-wrap">
<div class="captcha">
<div
class="cap-ct"
id="cap-ct"
role="button"
tabindex="0"
aria-label="点击进行人机验证"
onclick="CapPow.go()"
>
<div class="cap-cb">
<div class="cap-check">
<svg viewBox="0 0 24 24" aria-hidden="true">
<polyline points="4,12 9,17 20,6"></polyline>
</svg>
</div>
<svg class="cap-ring" viewBox="0 0 32 32" aria-hidden="true">
<circle class="cap-ring-bg" cx="16" cy="16" r="14"></circle>
<circle class="cap-ring-fg" cx="16" cy="16" r="14"></circle>
</svg>
</div>
<div class="cap-lw">
<span class="cap-label active">验证你是人类</span>
</div>
</div>
<a class="cap-credits" href="https://github.com/tiagozip/cap" target="_blank" rel="noopener">Cap</a>
</div>
</div>
脚本会给可点击元素补充 Enter/空格键支持。成功状态约 30 秒后复位;失败状态约 4 秒后复位。这只是组件显示状态的时间,不等于后端 token 的有效期。
2. 接收 token
推荐在表单里准备一个隐藏字段,并通过回调写入:
<form id="protected-form" method="post" action="/your-submit-endpoint">
<!-- 你的其他字段 -->
<input type="hidden" name="cap_token" id="cap-token">
<!-- 把上一节的 Cap-Pow 组件放在这里 -->
<button type="submit">提交</button>
</form>
<script>
CapPow.onDone = function (token) {
document.getElementById('cap-token').value = token;
};
CapPow.onFail = function (message) {
document.getElementById('cap-token').value = '';
console.warn('Cap-Pow 验证失败:', message);
};
</script>
也可以监听脚本发出的 cap-solve 自定义事件:
document.getElementById('cap-ct').addEventListener('cap-solve', function (event) {
console.log(event.detail.token);
});
注意:当前 CapPow.onDone 和 CapPow.onFail 是全局单一回调。如果同一页放多个受保护表单,需要自行按元素管理状态,不能假设每个组件都拥有独立回调。
3. 在业务后端消费 token
现有实例首页给出了 PHP 核验思路。下面增加了超时、网络失败和 JSON 格式检查:
<?php
$capToken = $_POST['cap_token'] ?? '';
if ($capToken === '') {
http_response_code(400);
exit('请先完成人机验证');
}
$ch = curl_init('https://cap-pow.wuw.li/api/validate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['token' => $capToken]),
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
$result = is_string($raw) ? json_decode($raw, true) : null;
if ($error !== '' || $status !== 200 || !is_array($result) || ($result['success'] ?? false) !== true) {
http_response_code(403);
exit('人机验证失败或已过期,请重试');
}
// 只有到这里,才继续执行真正的登录、注册、留言或提交逻辑。
核验时应采用 fail closed:验证服务超时、返回非 200、JSON 无法解析或 success 不为 true 时一律拒绝,而不是跳过验证码。
仓库源码显示,PHP 版本的验证 token 默认有效期为 20 分钟,并在成功验证后立即删除。因此 token 是一次性的:重复提交、浏览器后退后再次提交、前端重试复用旧 token 都会失败。本文实测现有实例也符合这一行为:第一次核验成功,第二次使用同一 token 返回失败。
4. 接口调试
只测试挑战获取可执行:
curl -sS 'https://cap-pow.wuw.li/challenge' \
-X POST \
-H 'Content-Type: application/json' \
-d '{}'
返回结构类似:
{
"challenge": { "c": 8, "s": 64, "d": 3 },
"token": "……",
"expires": 1784419034000
}
这里的数值是核实时现有实例返回的配置,不应当视为项目永久默认值。上游 PHP 仓库默认值是 c=64、s=128、d=4,部署者可以修改。
不要用假的 token 判断整个流程是否可用;完整成功路径必须先完成 challenge 与 redeem,最后再 validate。
三、在 Halo 中怎么用
场景 A:把组件放进 Halo 文章做演示
如果只是写一篇演示文章,可以把组件 HTML 放入支持原始 HTML 的内容位置,并确保页面最终加载 CSS 与 JS。具体脚本放置方式取决于正在使用的 Halo 编辑器、主题及其安全策略,应以页面最终源码和浏览器控制台为准。
但这只是一段演示 UI,不会自动保护 Halo 的评论、登录、注册或插件表单。
场景 B:真正保护 Halo 表单
要保护 Halo 中的某个操作,至少要同时完成两端改造:
- 前端:在对应主题模板或插件 UI 中渲染组件,把 token 随请求提交;
- 服务端:在执行真实业务之前调用
/api/validate,验证失败就拒绝请求。
只编辑文章内容无法给 Halo 服务端新增 token 核验逻辑。评论、登录或注册等核心流程应通过合适的 Halo 插件/扩展点实现;不要仅靠隐藏提交按钮或前端 JavaScript。
如果你修改主题,应避免直接改已安装主题的成品文件后长期运行,因为主题更新可能覆盖改动。更稳妥的做法是维护自己的主题派生版本,或把功能封装成插件。
场景 C:Halo 只是发布这篇教程
Halo 控制台支持“保存但不发布”和未发布预览。因此本文可先作为草稿导入/粘贴,完成代码块、主题样式和移动端预览检查后再决定是否发布。
四、自建与现有实例同源的 PHP 版本
对应仓库为:
https://github.com/onexru/cap-pow-php-server
1. 已核实的环境要求
仓库 README 明确列出:
- 推荐 Nginx;
- 推荐 PHP 8.0+;
- 使用 SQLite。
源码还表明运行环境需要:
- PHP 的 PDO SQLite 驱动;
- Web/PHP 运行用户对数据库目录具有写权限;
random_bytes()、SHA-256 等 PHP 标准能力。
仓库只有 cap.php、challenge.php、redeem.php、validate.php、演示首页等文件,没有官方 Dockerfile 或 docker-compose 文件。因此不要把互联网上未经核对的 PHP 镜像编排当作该仓库的官方 Docker 安装方式。
2. 安装步骤
下载源码后,将文件部署到站点目录。仓库提供的 Nginx 规则为:
location / {
try_files $uri $uri/ $uri.php?$query_string;
if ($request_filename ~* .*\.php$) {
return 403;
}
}
这条规则的目的,是把 /challenge 映射到 challenge.php,同时阻止用户直接访问以 .php 结尾的 URL。它需要合并进你现有站点配置,不能不看上下文就覆盖完整的 Nginx server 块;PHP-FPM 的实际转发配置仍应沿用服务器现有、已经可用的配置。
默认数据库路径在 cap.php 中为:
private $db_Driver = '.data/cap.db';
首次运行时程序会尝试创建目录、SQLite 数据库、challenges 表、tokens 表和索引。部署完成后应检查:
php -m | grep -Ei 'pdo|sqlite'
并确认 PHP-FPM 用户能够写入 .data/。数据库文件不应被 Web 服务器作为静态文件下载;生产环境更稳妥的做法是把数据库放到站点公开目录之外,再通过构造配置指定绝对路径。
3. 难度与超时配置
仓库默认配置:
$config = [
'db_Driver' => '.data/cap.db',
'c' => 64,
's' => 128,
'd' => 4,
];
$cap = new Cap($config);
含义按项目命名分别为运算次数 c、每次运算输入长度 s 和难度 d。生成挑战时还能设置挑战参数及过期秒数:
$cap = new Cap();
$challenge = [
'c' => 32,
's' => 64,
'd' => 4,
];
$expires = 60;
$cap->createChallenge($challenge, $expires);
难度越高,客户端计算时间通常越长。不要直接照抄某个实例的值上线,应在手机、低性能电脑和主流浏览器上测试等待时间与失败率。
4. 自建后的前端地址
PHP 仓库 README 使用官方 Cap Widget:
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<cap-widget
id="cap"
data-cap-api-endpoint="https://你的-Cap-Pow-地址/"
></cap-widget>
不过 cap-pow.wuw.li 当前公开的是自己的 cap-pow.js/cap-pow.css,并非 README 里的原始组件。若想复刻现有实例外观,需要同时部署或改写对应的前端资源,并把脚本中的 API 常量改为自己的域名。不要把当前实例脚本原样放到自己的域名后就认为已经自托管——其公开 JS 内部仍固定指向 https://cap-pow.wuw.li。
5. PHP 分支的安全注意
仓库 cap.php 文件头明确写着该版本“不带防重放攻击”,并推荐需要该能力时使用作者的 One-Pow 分支。与此同时,当前仓库代码确实会把 challenge 和最终验证 token 设为一次性并在消费后删除;这里应忠实保留项目作者的风险声明,不把它宣传成可替代所有反滥用措施的完整方案。
生产环境还应在反向代理或应用层补充:
- 对 challenge、redeem、validate 做合理限流;
- 只允许 HTTPS;
- 限制允许调用挑战接口的来源;
- 监控异常请求和失败率;
- 定期清理过期数据(当前源码只在访问对应 token 时删除过期记录,未见全表定时清理逻辑);
- 保护 SQLite 文件与备份。
最后一条“过期数据清理”来自源码检查:建表并写入 expires,但公开仓库中没有独立清理任务。因此高流量部署前应自行评估数据库增长,而不是假设项目已提供后台清理器。
五、Docker:当前官方 Cap Standalone 是另一条路线
如果你的目标不是一比一复刻 PHP 实例,而是部署当前 Cap 官方推荐版本,官方文档推荐 Docker。它运行于 Bun,使用 Redis/Valkey,带管理面板、多个 site key、统计、instrumentation challenge,以及兼容 reCAPTCHA 风格的服务端核验 API。
官方当前提供的 Compose 核心配置如下:
services:
cap:
image: tiago2/cap:latest
container_name: cap
ports:
- "3000:3000"
environment:
ADMIN_KEY: your_secret_password
REDIS_URL: redis://valkey:6379
depends_on:
valkey:
condition: service_healthy
restart: unless-stopped
valkey:
image: valkey/valkey:9-alpine
container_name: cap-valkey
volumes:
- valkey-data:/data
command: valkey-server --save 60 1 --loglevel warning --maxmemory-policy noeviction
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
valkey-data:
启动:
docker compose up -d
然后访问 http://服务器地址:3000,用 ADMIN_KEY 登录并创建 site key。官方建议 ADMIN_KEY 至少 32 个字符。
官方 Standalone 的前端 endpoint 包含 site key:
<script type="module" src="https://cdn.jsdelivr.net/npm/cap-widget"></script>
<form>
<cap-widget
required
data-cap-api-endpoint="https://cap.example.com/你的-site-key/"
></cap-widget>
<button type="submit">提交</button>
</form>
组件位于表单中时,会自动注入默认名为 cap-token 的隐藏字段。业务后端应调用:
curl 'https://cap.example.com/你的-site-key/siteverify' \
-X POST \
-H 'Content-Type: application/json' \
-d '{"secret":"你的-site-secret","response":"浏览器返回的-token"}'
成功响应为:
{ "success": true }
请区分三种密钥/值:
ADMIN_KEY:登录 Standalone 管理面板;- site key:公开给浏览器,用在 endpoint 中;
- site secret:只保存在业务后端,用于
/siteverify。
它们与 PHP 实例的 /api/validate 无密钥调用方式不同。
Standalone 常用环境变量
官方文档中与常见部署最相关的选项包括:
CORS_ORIGIN:默认*,可用逗号分隔多个来源;REDIS_URL:Redis/Valkey 连接地址;REDIS_PREFIX:共享 Redis 时给键增加命名空间;RATELIMIT_IP_HEADER:反向代理后指定真实 IP 请求头,Cloudflare 可用cf-connecting-ip,多数代理环境常用x-forwarded-for;ENABLE_ASSETS_SERVER=true:由实例提供 widget/WASM 静态资源;WIDGET_VERSION、WASM_VERSION:固定资源版本。官方不建议生产环境使用可能引入破坏性变更的latest;SHOW_ERRORS=true:取消错误信息脱敏,仅适合有明确需求的调试环境;DISABLE_ERROR_LOGGING=true:关闭错误日志。
如果为 IP 数据库绑定宿主目录到 /usr/src/app/data,官方文档说明容器以 UID 1000 运行,该目录必须对 UID 1000 可写。无法调整所有权时,可改用 Docker named volume 或不做宿主目录绑定。
六、常见问题
1. 点击后提示“网络错误”或“提交失败”
依次检查:
- 页面是否为 HTTPS;
cap-pow.js与cap-pow.css是否加载成功;- 浏览器开发者工具 Network 中
/challenge、/redeem是否返回 200; - CSP 是否阻止外部脚本、样式、
connect-src或 WebAssembly; - 浏览器是否支持
crypto.subtle; - 自建服务的跨域配置是否允许当前站点。
现有 cap-pow.wuw.li 的 JS、CSS 和 API 响应目前都带有 Access-Control-Allow-Origin: *,但自建实例不能据此假设自己的 Nginx/PHP 配置也会自动具有相同响应头。
2. 前端已显示“你是人类”,后端仍失败
常见原因:
- 提交的字段名与后端读取字段名不一致;
- token 已被消费一次;
- token 过期;
- 页面刷新或组件重置后仍提交旧 token;
- 后端调用了错误的验证接口;
- 混用了 PHP 实例的
/api/validate与 Standalone 的/<site-key>/siteverify。
3. 页面能显示组件,但业务接口仍可被绕过
说明只做了前端,没有在业务后端强制核验 token。前端组件不是访问控制边界。
4. SQLite 报错 unable to open database file
重点检查数据库父目录是否存在、PHP-FPM 用户是否有写权限、路径是否按运行目录解析。建议改为明确的绝对路径,并确保数据库不在可下载的公开静态目录内。
5. Nginx 访问 /challenge 返回 404
检查仓库提供的 try_files $uri $uri/ $uri.php?$query_string; 是否合并到正确的 server/location,以及 PHP-FPM 转发规则是否实际生效。不要为了修复单个路由直接覆盖整份站点配置。
6. 手机验证很慢
PoW 会消耗客户端 CPU。先降低挑战参数并进行真机测试;不要只在高性能桌面电脑上评估。官方 Standalone 还提供按 site key 配置的挑战方式,但那属于 Standalone 路线,不能直接套入 PHP 分支。
7. 能否完全离线、不依赖第三方 CDN?
- 当前实例方案:如果页面直接加载
cap-pow.wuw.li的 JS/CSS 并调用它的 API,就仍然依赖第三方实例; - PHP 自建:应把前端文件一并托管到自己的域名并确认 API 地址已改成自己的实例;
- 官方 Standalone:可开启 asset server,自行提供 widget 与 WASM,并固定版本。
“上游项目隐私友好”不等于“调用任意第三方实例时没有数据出站”。访问第三方验证域名时,至少会发生网络请求,部署者应结合隐私政策、可用性和日志策略自行评估。
七、上线检查清单
- 明确选择 PHP 分支或官方 Standalone,没有混用接口;
- 页面端能完成 challenge → redeem;
- 业务后端强制执行 validate/siteverify;
- 验证服务故障时默认拒绝,而不是绕过;
- token 验证成功后不重复使用;
- 全站 HTTPS;
- CORS 只放行所需站点(自建时);
- 反向代理传递并正确解析客户端 IP;
- challenge/redeem 等接口有限流;
- 移动设备和低性能设备完成过验证耗时测试;
- SQLite 或 Valkey 数据有持久化与备份策略;
- 前端资源固定版本或纳入自己的发布流程;
- Halo 主题/插件升级后重新做完整流程回归测试。
参考资料
- 已部署实例与接入示例:https://cap-pow.wuw.li/
- 已部署实例 JavaScript:https://cap-pow.wuw.li/cap-pow.js
- 已部署实例 CSS:https://cap-pow.wuw.li/cap-pow.css
- Cap-Pow PHP Server 仓库:https://github.com/onexru/cap-pow-php-server
- PHP Server README:https://github.com/onexru/cap-pow-php-server/blob/main/README.md
- PHP 核心实现
cap.php:https://github.com/onexru/cap-pow-php-server/blob/main/cap.php - Cap 官方仓库:https://github.com/tiagozip/cap
- Cap Standalone 官方文档:https://trycap.dev/guide/standalone/
- Cap Standalone 配置选项:https://trycap.dev/guide/standalone/options
- Cap Widget 官方文档:https://trycap.dev/guide/widget
- Cap 官方 Compose 文件:https://github.com/tiagozip/cap/blob/main/standalone/docker-compose.yml
- Halo 文章管理文档:https://docs.halo.run/user-guide/posts
版本提示:Cap 上游仍在持续更新,正式部署前应重新核对镜像标签、Widget 版本、环境变量和接口文档;生产环境不应无审查地追随
latest。