Cap.js 的自部署 PoW 验证码集成实践指南。文章介绍了工作量证明验证码与传统人机验证的区别,说明其通过提高请求计算成本来限制批量请求。随后结合 NeutralPress 项目,讲解了 Cap.js 后端存储、Challenge 创建、答案验证、Token 校验、Server Action 接入和前端 Hook 封装等实现方式,并总结了它在隐私、可自部署、无打扰体验上的优势,以及无法识别人类、设备性能差异大等局限。
前言
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:验证答案并发 tokenverifyToken:提交表单时检查 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<object>();
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
- 高性能设备或分布式算力仍然可以绕过它(大概没人拿这种高性能集群来打吧?)
- 对特别老的设备来说,体验可能不太好()
A918F0B1831E035338448EAA68177E1B.jpg
但作为开源自部署验证码来说,已经是相当好的选择了,给到夯。
—— 完。
评论