Cloudflare Workers 通用 Turnstile 验证模块

最近使用 Cloudflare Workers 实现了一个在线笔记本 使用 Cloudflare KV 进行数据存储,根据 Cloudflare KV 定价文档 免费计划每天是有次数限制的。为了避免互联网上各种扫描器的请求导致消耗限额,就需要过滤一波非人类请求了。

而在不久前,我刚刚使用 Cloudflare Turnstile 给博客增加了一下人工验证,可以如法炮制给这个也加上。但是以后每一个 worker 想还用验证都需要重复添加 Turnstile 的代码,所以就把这个逻辑给抽出来组成一个单独的模块了。

/**
* Cloudflare Workers 通用 Turnstile 验证模块
* 提供完整的 Turnstile 验证、JWT Token 管理和 Cookie 处理功能
*/

// 默认配置
const DEFAULT_CONFIG = {
TURNSTILE_URL: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
TURNSTILE_EXPIRE: 3600,
JWT_ALGORITHM: "HS256",
};

/**
* 生成 JWT Token
* @param {Object} payload - JWT payload
* @param {string} secret - JWT secret key
* @returns {Promise<string>} JWT token
*/
async function generateToken(payload, secret) {
const header = btoa(
JSON.stringify({ alg: DEFAULT_CONFIG.JWT_ALGORITHM, typ: "JWT" })
).replace(/=/g, "");
const encodedPayload = btoa(JSON.stringify(payload)).replace(/=/g, "");
const data = `${header}.${encodedPayload}`;

const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);

const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(data)
);
return `${data}.${btoa(
String.fromCharCode(...new Uint8Array(signature))
).replace(/=/g, "")}`;
}

/**
* 验证 JWT Token
* @param {string} token - JWT token
* @param {string} secret - JWT secret key
* @returns {Promise<boolean>} 验证结果(true 表示有效,false 表示无效)
*/
async function verifyToken(token, secret) {
console.log(`JWT Token 获取: ${token}`);
try {
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) return false;

const data = `${header}.${payload}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);

const expectedSignature = Uint8Array.from(
atob(signature + "===".substring(0, (4 - (signature.length % 4)) % 4)),
(c) => c.charCodeAt(0)
);

const isValid = await crypto.subtle.verify(
"HMAC",
key,
expectedSignature,
new TextEncoder().encode(data)
);

console.log(`JWT Token 有效: ${isValid}`);
if (!isValid) return false;

const decodedPayload = JSON.parse(
atob(payload + "===".substring(0, (4 - (payload.length % 4)) % 4))
);
console.log(`JWT Token 解码: ${JSON.stringify(decodedPayload)}`);

const isExpired = decodedPayload.exp > Math.floor(Date.now() / 1000);
console.log(`JWT Token 过期: ${!isExpired}`);
return isExpired;
} catch (e) {
console.log(`JWT Token 失败: ${e}`);
return false;
}
}

/**
* 验证 Turnstile Response
* @param {string} response - Turnstile response token
* @param {string} remoteip - 客户端 IP
* @param {string} secretKey - Turnstile secret key
* @returns {Promise<boolean>} 验证结果(true 表示有效,false 表示无效)
*/
async function verifyTurnstile(response, remoteip, secretKey) {
console.log(`Turnstile 质询: ${remoteip}`);
const formData = new FormData();
formData.append("secret", secretKey);
formData.append("response", response);
formData.append("remoteip", remoteip);

const result = await (
await fetch(DEFAULT_CONFIG.TURNSTILE_URL, {
method: "POST",
body: formData,
})
).json();

console.log(`Turnstile 结果: ${JSON.stringify(result)}`);
return result.success;
}

/**
* 检查请求是否已通过验证
* @param {Request} request - 请求对象
* @param {string} cookieName - Cookie名 称
* @param {string} jwtSecret - JWT secret key
* @returns {Promise<boolean>} 验证结果(true 表示有效,false 表示无效)
*/
async function isVerified(request, cookieName, jwtSecret) {
const cookie = request.headers.get("Cookie") || "";
const match = cookie.match(new RegExp(`${cookieName}=([^;]+)`));
return match ? await verifyToken(match[1], jwtSecret) : false;
}

/**
* 生成 Turnstile 验证页面 HTML
* @param {Object} options - 配置选项
* @param {string} options.siteKey - Turnstile site key
* @param {string} [options.title] - 页面标题
* @param {string} [options.logo] - Logo文本
* @param {string} [options.description] - 描述文本
* @returns {string} HTML字符串
*/
function getTurnstileHTML({
siteKey,
title = "安全验证",
logo = "🔒",
description = "请完成以下验证后继续使用",
}) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
body{font-family:system-ui;background:linear-gradient(135deg,#667eea,#764ba2);margin:0;padding:0;min-height:100vh;display:flex;align-items:center;justify-content:center}
.container{background:white;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.1);padding:40px;width:400px;text-align:center}
.logo{font-size:24px;font-weight:bold;color:#333;margin-bottom:24px}
.title{color:#333;margin-bottom:16px;font-size:18px}
.description{color:#666;margin-bottom:24px;font-size:14px}
.turnstile-container{display:flex;justify-content:center;margin:20px 0;min-height:65px}
</style>
</head><body><div class="container"><div class="logo">${logo}</div><h2 class="title">${title}</h2><p class="description">${description}</p>
<form method="POST"><div class="turnstile-container"><div class="cf-turnstile" data-sitekey="${siteKey}" data-callback="onSuccess"></div></div></form></div>
<script>function onSuccess(){document.querySelector('form').submit();}</script></body></html>`;
}

/**
* 设置验证成功的响应
* @param {string} targetPath - 重定向目标路径
* @param {string} cookieName - Cookie名称
* @param {string} jwtSecret - JWT secret key
* @param {number} [expireTime] - 过期时间(秒)
* @returns {Promise<Response>} 重定向响应
*/
async function setSuccessResponse(
targetPath,
cookieName,
jwtSecret,
expireTime
) {
const payload = {
verified: true,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + expireTime,
};

const token = await generateToken(payload, jwtSecret);
console.log(`Cookie 设置: ${token}`);

return new Response("", {
status: 302,
headers: {
Location: targetPath,
"Set-Cookie": `${cookieName}=${token}; Path=/; Max-Age=${expireTime}; HttpOnly; SameSite=Lax; Secure`,
},
});
}

/**
* Turnstile 验证中间件
* 使用示例:
* ```javascript
* import { turnstileMiddleware } from './shared/turnstile-auth.js';
*
* const turnstileAuth = turnstileMiddleware({
* cookieName: 'my_app_verified',
* logo: '📝 MyApp',
* skipPaths: ['/api/public']
* });
*
* export default {
* async fetch(request, env) {
* const authResult = await turnstileAuth(request, env);
* if (authResult) return authResult;
*
* // 验证通过,处理业务逻辑
* return new Response('Hello World');
* }
* }
* ```
*
* 环境变量配置:
* - TURNSTILE_SITE_KEY: Turnstile 站点 Key
* - TURNSTILE_SECRET_KEY: Turnstile 站点密钥
* - TURNSTILE_EXPIRE: Turnstile 过期时间(秒),默认 3600(1小时)
* - JWT_SECRET: JWT 签名密钥
*/
export function turnstileMiddleware(options = {}) {
const {
cookieName = "app_verified",
logo = "🔒",
title = "安全验证",
description = "请完成以下验证后继续使用",
skipPaths = [],
skipMethods = ["OPTIONS"],
} = options;

return async function turnstileAuth(request, env) {
const url = new URL(request.url);
const { pathname, search } = url;
const { method } = request;

if (
skipMethods.includes(method) ||
skipPaths.some((path) => pathname.startsWith(path))
) {
return null;
}

if (!(await isVerified(request, cookieName, env.JWT_SECRET))) {
if (method === "POST") {
const formData = await request.formData();
const remoteip = request.headers.get("CF-Connecting-IP");
const response = formData.get("cf-turnstile-response");

if (
await verifyTurnstile(response, remoteip, env.TURNSTILE_SECRET_KEY)
) {
const expireTime =
parseInt(env.TURNSTILE_EXPIRE) || DEFAULT_CONFIG.TURNSTILE_EXPIRE;
return await setSuccessResponse(
pathname + search,
cookieName,
env.JWT_SECRET,
expireTime
);
} else {
return new Response("验证失败", { status: 403 });
}
}

console.log("跳转 Turnstile 页面");
return new Response(
getTurnstileHTML({
siteKey: env.TURNSTILE_SITE_KEY,
title,
logo,
description,
}),
{
headers: { "content-type": "text/html; charset=utf-8" },
}
);
}

return null;
};
}

使用起来也很方便,只需要将以上代码保存找一个合适的位置,在 worker 代码中引用,并且通过在 wrangler.jsonc 中设置 TURNSTILE_SITE_KEY(必须) TURNSTILE_SECRET_KEY(必须) JWT_SECRET(必须) TURNSTILE_EXPIRE(可选) 这几个环境变量,剩下的按照代码中的示例使用即可。