Hexo 静态博客使用 Cloudflare Turnstile

Cloudflare 使用 Workers 建立 Images 代理 一文中。使用 Workers 搭建了一个 Images 代理,通过设置 Referer 和 URL 签名有效期实现简易的防盗链以及防刷流量。但是这些措施只能做到防君子不防小人,其实很轻易的就可以通过代码自动实现刷图片流量。为了避免这种情况,想着能不能使用 Cloudflare Turnstile 阻挡非人类请求的流量。但是研究了一下发现 Cloudflare Turnstile 需要 服务端代码验证 但是很显然,Hexo 这种纯静态博客没有后端服务,所以思索了一下决定还是使用 Workers 实现一个支持静态网页的 Cloudflare Turnstile 方案。

思路就是将原始博客通过 Cloudflare Pages 托管并绑定一个自定义域名比如 source.example.com 作为静态页面的源站,然后再创建一个 blog 的 Workers (绑定自定义域名 blog.example.com) 作为代理站,在这个 blog 的 Workers 中实现 Cloudflare Turnstile 的服务端验证逻辑,验证通过则注入一个 JWT Token 实现在有效期内就无需再次验证。

  1. 打开 Cloudflare Dashboard 点击左侧的 Turnstile 在右侧点击 添加小组件
  2. 按照实际情况填写信息,域名则添加静态页面的代理域名 (上文中的 blog.example.com 而不是 source.example.com),下方选择 托管
  3. 添加完成后确认已经添加正确的域名,并且生成两个需要用到的数据: 站点密钥密钥 无需马上记下来,后续还可以再次查看。
  4. 配置 Cloudflare Pages 源站点绑定 source.example.com 域名,并确认源站点可以正常访问。
  5. 添加自定义 Workers 使用下方代码,替换为实际的内容。绑定自定义域名 blog.example.com
// 配置常量
const CONFIG = {
BLOG_SOURCE: "https://source.example.com", // 静态页面源站 URL,自行修改
TURNSTILE_URL: "https://challenges.cloudflare.com/turnstile/v0/siteverify", // Turnstile 验证地址
TURNSTILE_SITEKEY: "", // Turnstile 站点密钥,根据实际修改
TURNSTILE_SECRET: "", // Turnstile 密钥,根据实际修改
JWT_SECRET: "", // JWT 密钥,可通过命令生成 openssl rand -base64 48
COOKIE_NAME: "", // 注入的 JWT Token cookie 名字,可自定义
COOKIE_EXPIRE: 3600, // Turnstile 有效期 1 小时,可自定义
};

// 生成 JWT token
async function generateToken(payload) {
const header = { alg: "HS256", typ: "JWT" };
const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, "");
const encodedPayload = btoa(JSON.stringify(payload)).replace(/=/g, "");

const data = `${encodedHeader}.${encodedPayload}`;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(CONFIG.JWT_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);

const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
const encodedSignature = btoa(
String.fromCharCode(...new Uint8Array(signature))
).replace(/=/g, "");

return `${data}.${encodedSignature}`;
}

// 验证 JWT token
async function verifyToken(token) {
try {
const parts = token.split(".");
if (parts.length !== 3) return false;

const [header, payload, signature] = parts;
const data = `${header}.${payload}`;

const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(CONFIG.JWT_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,
encoder.encode(data)
);

if (!isValid) return false;

const decodedPayload = JSON.parse(
atob(payload + "===".substring(0, (4 - (payload.length % 4)) % 4))
);
return decodedPayload.exp > Math.floor(Date.now() / 1000);
} catch (e) {
return false;
}
}

// 验证页面 HTML 模板
function getVerificationHTML() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>安全验证</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 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; min-height: 350px; text-align: center; box-sizing: border-box; }
.title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 24px; }
.description { color: #666; margin-bottom: 24px; font-size: 14px; line-height: 1.5; }
.verify-button { background: #667eea; color: white; border: none; border-radius: 8px; padding: 12px 24px; font-size: 16px; cursor: pointer; margin-top: 20px; width: 100%; }
.verify-button:hover { background: #5a6fd8; }
.verify-button:disabled { background: #ccc; cursor: not-allowed; }
.turnstile-container { display: flex; justify-content: center; align-items: center; margin: 20px 0; min-height: 65px; width: 100%; }
.cf-turnstile { margin: 0 auto; }
</style>
</head>
<body>
<div class="container">
<div class="title">安全验证</div>
<p class="description">请完成以下验证后继续访问</p>
<form method="POST">
<div class="turnstile-container">
<div class="cf-turnstile" data-sitekey="${CONFIG.TURNSTILE_SITEKEY}" data-callback="onSuccess"></div>
</div>
<button type="submit" class="verify-button" id="btn" disabled>验证并进入</button>
</form>
</div>
<script>function onSuccess(){document.getElementById('btn').disabled=false;}</script>
</body>
</html>`;
}

// 验证 Turnstile Token
async function verifyTurnstile(token) {
const response = await fetch(CONFIG.TURNSTILE_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `secret=${encodeURIComponent(
CONFIG.TURNSTILE_SECRET
)}&response=${encodeURIComponent(token)}`,
});
const result = await response.json();
return result.success;
}

// 检查是否已通过验证
async function isVerified(request) {
const cookie = request.headers.get("Cookie") || "";
const match = cookie.match(new RegExp(CONFIG.COOKIE_NAME + "=([^;]+)"));
if (!match) return false;

const token = match[1];
return await verifyToken(token);
}

// 设置验证成功的 Cookie
async function createSuccessResponse(targetPath) {
const payload = {
verified: true,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + CONFIG.COOKIE_EXPIRE,
};

const token = await generateToken(payload);

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

export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);

// 检查是否已验证
if (!(await isVerified(request))) {
if (request.method === "POST") {
const formData = await request.formData();
const token = formData.get("cf-turnstile-response");

if (await verifyTurnstile(token)) {
return await createSuccessResponse(url.pathname);
} else {
return new Response("验证失败", { status: 403 });
}
}

// 显示验证页面
return new Response(getVerificationHTML(), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}

// 代理到源站
const target = CONFIG.BLOG_SOURCE + url.pathname + url.search;
return fetch(target, request);
},
};

至此,就实现了通过访问 blog.example.com 显示一个验证页面的效果。

验证通过后即可访问静态页面,同时可以通过修改代码中的 COOKIE_EXPIRE 自行调整验证间隔时间。通过这种方式,源站点的 URL 不暴露,只通过代理站点的 URL 访问,即可实现定期通过 Cloudflare Turnstile 避免大量机器流量。