Cap 是一种现代、轻量级、开源的验证码替代方案,使用 SHA-256 工作量证明。


客户端
首先添加从 CDN 导入 Cap 小部件库:
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.28"></script>or
<script src="https://unpkg.com/@cap.js/widget@0.1.28"></script>在某些场景也可以使用pnpm add "@cap.js/widget",cdn 不是必须的
接下来,将组件添加到 HTML 中。
<cap-widget id="cap" data-cap-api-endpoint="<your cap endpoint>"></cap-widget>然后,在 JavaScript 中,监听 solve 事件以在生成时捕获令牌:
const widget = document.querySelector("#cap");
widget.addEventListener("solve", function (e) {
const token = e.detail.token;
// Handle the token as needed
});如果使用vue可以这样用
<cap-widget
id="cap"
:data-cap-api-endpoint="capApiEndpoint"
@solve="handleSolve"
></cap-widget>const capApiEndpoint = import.meta.env.VITE_SERVER + "/cap/";
const capToken = ref(null);
const handleSolve = (event) => {
capToken.value = event?.detail?.token ?? event?.token ?? null;
};环境变量VITE_SERVER对应<your cap endpoint>之后服务端会提及
服务端
官方推荐使用Standalone server,但是这需要 docker 我觉得太麻烦了,这里基于@cap.js/server自主实现服务端
@cap.js/server 是 Cap 的服务器端库,用于创建和验证挑战。使用您喜欢的包管理器安装它:
pnpm i "@cap.js/server"开始
使用 Cap 的最佳方式是使用连接到数据库的存储挂钩
官方用的SQlite我还是不喜欢,这里用Redis实现
import Cap from "@cap.js/server";
import Redis from "ioredis";
const redis = new Redis({
host: redisHost,
port: redisPort,
db: redisDb,
// password: "your_password", // no password for now
lazyConnect: true,
});
const cap = new Cap({
storage: {
challenges: {
// 保存挑战信息
store: async (token, challengeData) => {
const key = `cap:challenge:${token}`;
const ttlSeconds = Math.floor(
(challengeData.expires - Date.now()) / 1000
);
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
await redis.del(key);
return;
}
await redis.set(key, JSON.stringify(challengeData), "EX", ttlSeconds);
},
// 读取挑战信息
read: async (token) => {
const key = `cap:challenge:${token}`;
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
},
// 删除挑战
delete: async (token) => {
await redis.del(`cap:challenge:${token}`);
},
// 删除过期挑战(Redis 会自动清理)
deleteExpired: async () => {},
},
tokens: {
store: async (key, expires) => {
const ttlSeconds = Math.floor((expires - Date.now()) / 1000);
const redisKey = `cap:token:${key}`;
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
await redis.del(redisKey);
return;
}
await redis.set(redisKey, String(expires), "EX", ttlSeconds);
},
get: async (key) => {
const data = await redis.get(`cap:token:${key}`);
if (!data) return null;
const expires = Number(data);
return Number.isFinite(expires) ? expires : null;
},
delete: async (key) => {
await redis.del(`cap:token:${key}`);
},
deleteExpired: async () => {},
},
},
});
export default cap;如果你的Redis也加入了环境变量并且懒得自己启动可以参考下面这个版本
import Cap from "@cap.js/server";
import Redis from "ioredis";
import { spawn } from "child_process";
import { createConnection } from "net";
const redisHost = process.env.REDIS_HOST ?? "127.0.0.1";
const redisPort = toNumber(process.env.REDIS_PORT, 6379);
const redisDb = toNumber(process.env.REDIS_DB, 0);
const redisServerCommand = process.env.REDIS_SERVER_PATH ?? "redis-server";
const redisBootTimeoutMs = toNumber(process.env.REDIS_BOOT_TIMEOUT_MS, 5000);
const redisBootPollIntervalMs = Math.max(
toNumber(process.env.REDIS_BOOT_POLL_INTERVAL_MS, 200),
50
);
const redisStartArgs = process.env.REDIS_SERVER_ARGS
? process.env.REDIS_SERVER_ARGS.split(" ").filter(Boolean)
: [];
let managedRedisProcess = null;
let ensureRedisPromise = null;
let terminationHookRegistered = false;
function toNumber(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function isRedisReady(host, port) {
return new Promise((resolve) => {
const socket = createConnection({ host, port });
const finalize = (result) => {
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.once("connect", () => finalize(true));
socket.once("error", () => finalize(false));
socket.setTimeout(1000, () => finalize(false));
});
}
function registerTerminationHook() {
if (terminationHookRegistered) {
return;
}
terminationHookRegistered = true;
process.on("exit", () => {
if (managedRedisProcess && !managedRedisProcess.killed) {
managedRedisProcess.kill();
}
});
}
function startRedisProcess() {
return new Promise((resolve, reject) => {
try {
const child = spawn(redisServerCommand, redisStartArgs, {
stdio: "ignore",
windowsHide: true,
});
managedRedisProcess = child;
registerTerminationHook();
const cleanup = () => {
child.removeListener("error", handleError);
child.removeListener("spawn", handleSpawn);
};
const handleError = (error) => {
cleanup();
managedRedisProcess = null;
reject(
new Error(
`Failed to start redis-server: ${error?.message ?? String(error)}`
)
);
};
const handleSpawn = () => {
cleanup();
child.on("exit", (code, signal) => {
managedRedisProcess = null;
if (code !== 0) {
console.warn(
`[cap] redis-server exited unexpectedly (code: ${code}${
signal ? `, signal: ${signal}` : ""
})`
);
}
});
resolve();
};
child.once("error", handleError);
child.once("spawn", handleSpawn);
child.unref();
} catch (error) {
reject(
new Error(
`Unexpected error while starting redis-server: ${
error?.message ?? String(error)
}`
)
);
}
});
}
async function ensureRedisRunning() {
if (ensureRedisPromise) {
return ensureRedisPromise;
}
ensureRedisPromise = (async () => {
if (await isRedisReady(redisHost, redisPort)) {
return;
}
await startRedisProcess();
const deadline = Date.now() + redisBootTimeoutMs;
while (Date.now() < deadline) {
if (await isRedisReady(redisHost, redisPort)) {
return;
}
await delay(redisBootPollIntervalMs);
}
throw new Error(
`Redis did not become ready within ${redisBootTimeoutMs}ms`
);
})();
try {
await ensureRedisPromise;
} finally {
ensureRedisPromise = null;
}
}
const redis = new Redis({
host: redisHost,
port: redisPort,
db: redisDb,
// password: "your_password", // no password for now
lazyConnect: true,
});
(async () => {
try {
await ensureRedisRunning();
} catch (error) {
// Log failure but let ioredis continue retrying
console.error("[cap] Redis auto-start failed:", error);
} finally {
try {
if (redis.status === "wait" || redis.status === "end") {
await redis.connect();
}
} catch (connectError) {
console.error("[cap] Redis connection failed:", connectError);
}
}
})();
const cap = new Cap({
storage: {
challenges: {
// 保存挑战信息
store: async (token, challengeData) => {
const key = `cap:challenge:${token}`;
const ttlSeconds = Math.floor(
(challengeData.expires - Date.now()) / 1000
);
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
await redis.del(key);
return;
}
await redis.set(key, JSON.stringify(challengeData), "EX", ttlSeconds);
},
// 读取挑战信息
read: async (token) => {
const key = `cap:challenge:${token}`;
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
},
// 删除挑战
delete: async (token) => {
await redis.del(`cap:challenge:${token}`);
},
// 删除过期挑战(Redis 会自动清理)
deleteExpired: async () => {},
},
tokens: {
store: async (key, expires) => {
const ttlSeconds = Math.floor((expires - Date.now()) / 1000);
const redisKey = `cap:token:${key}`;
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
await redis.del(redisKey);
return;
}
await redis.set(redisKey, String(expires), "EX", ttlSeconds);
},
get: async (key) => {
const data = await redis.get(`cap:token:${key}`);
if (!data) return null;
const expires = Number(data);
return Number.isFinite(expires) ? expires : null;
},
delete: async (key) => {
await redis.del(`cap:token:${key}`);
},
deleteExpired: async () => {},
},
},
});
export default cap;Codex 上大分
实际上只需要能完成Cap需要的方法用什么方法都可以,甚至你可以直接使用内存中的变量new Cap({ ... })
{
// used for json keyval storage. storage hooks are recommended instead
"tokens_store_path": ".data/tokensList.json",
// disables all filesystem operations, usually used along editing the state. storage hooks are recommended instead
"noFSState": false,
"disableAutoCleanup": false,
"storage": {
"challenges": {
"store": "async (token, challengeData) => {}",
"read": "async (token) => {}",
"delete": "async (token) => {}",
"deleteExpired": "async () => {}"
},
"tokens": {
"store": "async (tokenKey, expires) => {}",
"get": "async (tokenKey) => {}",
"delete": "async (tokenKey) => {}",
"deleteExpired": "async () => {}"
}
},
"state": {
"challengesList": {},
"tokensList": {}
}
}之后,需要创建一个前端访问的路由,也就是<your cap endpoint>的部分
这里放我的express示例,官方文档还提供了Elysia,Fastify的示例可以参考喵
import express from "express";
import cap from "../services/capServer.js";
const router = express.Router();
router.use(express.json());
// 提供验证码图片接口
router.post("/challenge", async (req, res) => {
res.json(await cap.createChallenge());
});
// 验证接口
router.post("/redeem", async (req, res) => {
const { token, solutions } = req.body;
if (!token || !solutions) {
return res.status(400).json({ success: false });
}
res.json(await cap.redeemChallenge({ token, solutions }));
});
export default router;import cap from "../services/capServer.js";
需要替换为之前实现cap的具体文件
我在app.js使用
app.use("/cap", cap);将它挂载到了/cap路由,所以我的<your cap endpoint>为import.meta.env.VITE_SERVER + "/cap/",你需要根据实际情况更改喵
验证
到这里已经前端已经可以获取并实现人机验证了,但仍然需要在服务端校验 captoken 的有效性
比如在恰当的地方
if (!capToken) {
return res.status(400).json({ error: "未完成人机验证" });
} else {
// 验证 capToken
const { success } = await cap.validateToken(capToken);
// console.log(`captoken==>${capToken}, validate result=>${success}`);
if (!success) return res.status(400).json({ error: "未完成人机验证" });
}capToken需要前端在处理你的请求时再发送回后端
只有完成
cap.validateToken(capToken)返回true才算真正完成人机验证!
