Cap.js：基于工作量证明的自部署验证码

前往 https://ravelloh.com/posts/cap-js-pow-self-hosted-captcha 查看以获得最佳体验。

前言

https://github.com/tiagozip/cap

Cap.js 是一种基于工作量证明 (PoW) 的验证码系统。相较于常见的验证码系统（如 Cloudflare Turnstile ，reCAPTCHA，hCAPTCHA），其特色是可自建，无隐私问题，无打扰，体积小。

我的博客的登录、注册等接口就使用了 Cap.js，你可以来 看看效果。

其他的验证码大概分为两类（当然两者可能混合使用），一类是要求用户解决某些困难问题的，例如数字运算、选自行车、人行道等。另一类则是收集用户浏览器的信息，来判断浏览器是否属于正常用户。

而工作量证明类验证码则与他们不同，可以说目标就不一样。其他的验证码主要是为人机验证，防止自动化程序批量请求，所以需要从中辨别出真人。工作量证明类验证码则主要是为了速率限制，通过提高请求成本，来防止大量请求。

其原理与挖矿类似，即给客户端一个需大量计算、但服务器端可轻松验证的问题，例如：服务端发一个种子 + 配置的挑战，客户端在本地用 SHA-256 不断哈希，找到使哈希以目标前缀开头的 nonce，提交给服务端。而服务器端验证时，只需要验证用户答案是否满足条件即可。具体的交互流程大概是两次请求，即：

客户端向服务器获取问题

服务器给出问题，并将问题存储

客户端计算哈希，将结果发送到服务器

服务器验证答案，将Token发送到客户端，并将Token存储

随后，客户端即可用 Token 进行提交请求，服务器端验证Token是否已存储，若已存储则此次质询通过。根据设置，可以选择删除这个已验证过的Token（一次性），或者保留固定时间（有效时期内不再验证）。

从上述流程不难发现，PoW验证码完全不需要用户操作，它将本来给用户的验证流程交给用户的设备来完成。但 PoW 仍具有局限性，如：

无法进行人机验证。完全不验证对方是否为真人，只要问题能解决即可。如果对方使用高性能计算集群来快速解决验证码，则仍可以对接口进行高速率请求

问题难度固定，各个设备通过时间存在极大差异。为了限制高性能设备的请求速率，难度不能过低，但难度较高同时也会导致低性能设备需要花费较长时间。（你可以前往 官方BenchMark 来对比不同设备解决问题的时间）

其中，对于无法识别机器请求的问题，我通过 Server Action 已经解决了，后面再写个文章讲讲。

但对于不同用户设备算力存在差异的问题，确实没有什么太好的办法，我的方案是主动在进入页面时就开始计算验证码，让用户把等待的时间用来填表单。但是，对于太老的设备，计算时间可能仍超出用户预期。

关于 Cap.js

Cap.js 则是我用着不错的 PoW，比较的轻量，支持较大程度的自定义。想要将其添加到你现有的项目中，你需要分别创建后端和前端部分：

@cap.js/server：负责创建挑战、兑换答案、校验 token

@cap.js/widget：负责在浏览器端本地计算挑战

安装：

pnpm add @cap.js/server @cap.js/widget

我这个项目里用的是 Redis 来存挑战和 token，你也可以使用内存（Serverless 下不可用）或者数据库等。整体结构是：

服务端初始化一个 Cap 实例

把 challenge 和 token 的读写逻辑接到存储端

暴露三个操作：createChallenge、verifyChallenge、verifyToken

前端加载 widget，在页面打开时就先开始解题

提交登录、注册、评论这些请求时，把 token 带上

实现业务逻辑的时候，先验证请求中的 token 是否有效

实现

后端

后端需完成三个操作： createChallenge 、verifyChallenge 、verifyToken，分别用于创建验证码问题、验证验证码答案并分发token、验证token。三步操作都需要数据存取操作。例如：

// src/lib/server/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/lib/server/captcha.ts
import "server-only";

import Cap from "@cap.js/server";

import { generateCacheKey } from "@/lib/server/cache";
import redis, { ensureRedisConnection } from "@/lib/server/redis";

export const cap = new Cap({
storage: {
challenges: {
store: async (token, challengeData) => {
// 以下为你的存储端写入逻辑
await ensureRedisConnection();
const ttl = Math.floor((challengeData.expires - Date.now()) / 1000);
if (ttl > 0) {
await redis.setex(
generateCacheKey("captcha", "challenge", token),
ttl,
JSON.stringify(challengeData),
);
}
},
read: async (token) => {
// 以下为你的存储端读取逻辑
await ensureRedisConnection();
const data = await redis.get(
generateCacheKey("captcha", "challenge", token),
);
if (!data) return null;

const challengeData = JSON.parse(data);
if (challengeData.expires <= Date.now()) {
await redis.del(generateCacheKey("captcha", "challenge", token));
return null;
}

return { challenge: challengeData, expires: challengeData.expires };
},
delete: async (token) => {
// 以下为你的存储端删除逻辑
await redis.del(generateCacheKey("captcha", "challenge", token));
},
deleteExpired: async () => {
// 以下为你的存储端删除过期项的逻辑
// 不过我用 Redis 的 ttl 实现的自动清理，这里就不用写了
},
},
tokens: {
store: async (tokenKey, expires) => {
// 以下为你的存储端写入逻辑
await ensureRedisConnection();
const ttl = Math.floor((expires - Date.now()) / 1000);
if (ttl > 0) {
await redis.setex(
generateCacheKey("captcha", "token", tokenKey),
ttl,
expires.toString(),
);
}
},
get: async (tokenKey) => {
// 以下为你的存储端读取逻辑
await ensureRedisConnection();
const data = await redis.get(
generateCacheKey("captcha", "token", tokenKey),
);
if (!data) return null;

const expires = parseInt(data, 10);
if (expires <= Date.now()) {
await redis.del(generateCacheKey("captcha", "token", tokenKey));
return null;
}

return expires;
},
delete: async (tokenKey) => {
// 以下为你的存储端删除逻辑
await redis.del(generateCacheKey("captcha", "token", tokenKey));
},
deleteExpired: async () => {
// 以下为你的存储端删除过期项的逻辑
// 不过我用 Redis 的 ttl 实现的自动清理，这里就不用写了
},
},
},
});

其中，challenges存还没被解出的题，tokens存已经解题成功、可以拿去提交业务请求的令牌。

存储方式随意，可以用内存，或者 Redis，或者数据库，甚至 HTTP 数据库。但在 Serverless 下，内存不可用，所以我用的是 Redis ，这样清理也容易多了，带个 TTL 就行，都不用单独写了。

方法

初始化完 cap 之后，后端需要三个操作方法：

createChallenge：生成题目

verifyChallenge：验证答案并发 token

verifyToken：提交表单时检查 token

// src/actions/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/actions/captcha.ts
"use server";

import { cap } from "@/lib/server/captcha";

export async function createChallenge() {
const data = await cap.createChallenge({
challengeCount: 50,
challengeSize: 32,
challengeDifficulty: 5, // 5 似乎耗时有点长，我博客里用的就是5，你可以到 BenchMark 里自己试试
expiresMs: 600000,
});

return {
success: true,
data,
};
}

export async function verifyChallenge({
token,
solutions,
}: {
token: string;
solutions: number[];
}) {
const data = await cap.redeemChallenge({ token, solutions });

return {
success: true,
data,
};
}

// src/lib/server/captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/lib/server/captcha.ts
export async function verifyToken(token: string) {
try {
const isValid = await cap.validateToken(token, {
keepToken: false, // 令牌是否可以重复使用
});
return isValid;
} catch (error) {
return { success: false };
}
}

Challenge 参数大多都在 官方BenchMark 页面里面有，你可以自己试试。keepToken: false决定令牌是否可以被重复使用，不过似乎可重复使用的令牌也起不到放置批量调用的作用了。

用 Server Action 替代 API

仅为拓展用途，Server Action 目前用的还不是太广泛，如果你的项目没用到过就不用看这段

官方的通信方式是直接使用 fetch 来通信， 但在 NeutralPress 里，我基本都在用 Server Action，官方有 window.CAP_CUSTOM_FETCH 方法来自定义 fetcher：

// src/hooks/use-captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/hooks/use-captcha.ts
useEffect(() => {
if (typeof window === "undefined") return;

window.CAP_CUSTOM_FETCH = async function (url, options) {
const result =
url === "/challenge"
? await createChallenge()
: url === "/redeem" &&
options?.body &&
typeof options.body === "string"
? await verifyChallenge(JSON.parse(options.body))
: null;

if (result && "success" in result && result.success && result.data) {
return new Response(JSON.stringify(result.data));
}

return new Response(
JSON.stringify({ error: "Failed to create challenge" }),
{ status: 500 },
);
};

return () => {
delete window.CAP_CUSTOM_FETCH;
};
}, []);

从这套实现可以看出，Cap.js widget 仍然在请求 /challenge 和 /redeem，但真正的网络调用已经被转发给 createChallenge() 和 verifyChallenge() 这两个 Server Action 了。

前端

前端核心逻辑在 useCaptcha 这个 hook 里。它会动态导入 @cap.js/widget，然后创建实例、监听进度、监听成功事件：

// src/hooks/use-captcha.ts
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/hooks/use-captcha.ts
import { useCallback, useEffect, useRef } from "react";

let CapClass: unknown = null;

async function loadCap() {
if (typeof window !== "undefined" && !CapClass) {
const widgetModule = await import("@cap.js/widget");
CapClass = widgetModule.default || widgetModule.Cap;
}
return CapClass;
}

export function useCaptcha(callbacks?: {
onProgress?: (progress: number) => void;
onSolve?: (token: string) => void;
onError?: (error: unknown) => void;
}) {
const capRef = useRef<unknown>(null);

const initializeCaptcha = useCallback(async () => {
const CapClass = await loadCap();
const CapConstructor = CapClass as new () => {
addEventListener: (
type: string,
listener: (event: {
detail: { progress?: number; token?: string };
}) => void,
) => void;
solve: () => Promise<{ success: boolean; token: string }>;
reset: () => void;
};

const capInstance = new CapConstructor();
capRef.current = capInstance;

capInstance.addEventListener("progress", (event) => {
callbacks?.onProgress?.(event.detail.progress || 0);
});

capInstance.addEventListener("solve", (event) => {
callbacks?.onSolve?.(event.detail.token || "");
});
}, [callbacks]);

const solve = useCallback(async () => {
if (!capRef.current) {
await initializeCaptcha();
}

return await (capRef.current as {
solve: () => Promise<{ success: boolean; token: string }>;
}).solve();
}, [initializeCaptcha]);

return { solve };
}

写成 Hook 之后就可以方便的在其他组件里面调用了：

// src/components/ui/CaptchaButton.tsx
// https://github.com/RavelloH/NeutralPress/blob/main/apps/web/src/components/ui/CaptchaButton.tsx
export function CaptchaButton(props: CaptchaButtonProps) {
const { broadcast } = useBroadcastSender ();

const { solve, reset } = useCaptcha({
onSolve: (token) => {
broadcast({ type: "captcha-solved", token });
},
onProgress: (progress) => {
setInternalLoading(progress);
},
onError: (error) => {
broadcast({ type: "captcha-error", error });
},
});

useEffect(() => {
solve();
}, [solve]);

useBroadcast((message: { type: string }) => {
if (message?.type === "captcha-reset") {
reset();
solve();
}
});

return Button({ ...props, loading });
}

你也可以这样把它集成到各种表单提交方式中。为了优化用户体验，最好一挂载就开始验证码，而不是用户点击提交时才开始算，这样能大幅减少用户被阻塞的时间：

useEffect(() => {
solve();
}, [solve]);

同时，CaptchaButton 还会把进度状态显示成按钮 loading，并在算完后通过广播把 token 发给表单组件。详情看 NeutralPress/apps/web/src/components/ui/CaptchaButton.tsx at main · RavelloH/NeutralPress 。

在表单里使用 token

有了 CaptchaButton 之后，业务表单接入起来就很简单了。例如注册页里：

const [token, setToken] = useState("");

useBroadcast((message: { type: string; token: string }) => {
if (message?.type === "captcha-solved") {
setToken(message.token);
}
});

const register = async () => {
if (!token) {
showMessage("安全验证失败，请刷新页面重试");
return;
}

const result = await registerAction({
username,
email,
password,
captcha_token: token,
});
};

前端拿到验证成功后传回的 token 后，将其与业务参数一起提交即可。注意检查验证是否成功：

if (!(await verifyToken(captcha_token)).success)
return response.unauthorized({
message: "安全验证失败，请刷新页面重试",
});

在 NeutralPress 中的应用

NeutralPress 里本来就大量使用 Server Action，登录、注册、邮箱验证、评论、友链申请这些动作，都是提交一个表单 -> 进入 Server Action -> 返回结果，Cap.js 刚好也适合放在这个链路里。

此外，我本来就已经给这些接口做了 limitControl 速率限制，所以现在的防护有三层：

Server Action 的调用有难度（无法简单的使用 http 来请求）

纯请求频率限制

每次关键请求都要先付出一段本地计算成本

大概是我想到的不依靠三方防火墙的最有效的办法了。

总结

优点

可自部署，不依赖第三方验证码平台

对隐私友好，不需要把大量浏览器指纹信息交给外部服务

不打扰用户，0 交互

很适合接在 Server Action 前面，做统一的请求闸门

对批量脚本确实能显著增加成本

缺点

不能识别真人，只能提高自动化请求成本

不同设备的计算耗时差异非常大

仍然需要服务端存储 challenge 和 token

高性能设备或分布式算力仍然可以绕过它（大概没人拿这种高性能集群来打吧？）

对特别老的设备来说，体验可能不太好（）

但作为开源自部署验证码来说，已经是相当好的选择了，给到夯。

—— 完。