写在开始

  虽然小破站流量不大,没几个人看,但架不住还是有些机器人来发表一些骚扰评论,这不,最近就经历了一起大型的机器人“入侵”事件,刷了大量的垃圾评论,真是令人头大。虽然有评论过滤插件,但还是架不住这些人机到处爬数据乱丢垃圾。
垃圾评论
  很多时候在浏览网页的时候,我们都会发现有各式各样的人机验证,其中最著名的当然就是谷歌的reCAPTCHA。
Google reCAPTCHA
但是,reCAPTCHA这玩意儿非常反人类,他秉持着“要上网,先做题”的理念,出的题目还巨抽象,九宫格验证码,你选了半天根本就选不对,严重影响用户的浏览体验。就像下面这个选红绿灯的验证,旁边那个红绿灯的杆你选还是不选呢?纠结!还有很多类似的,比如选什么斑马线啊,什么微笑的狗啊...用户要想达到上网的目的,就必须先耗费大量时间做题。
reCAPTCHA选择红绿灯
  与此同时,我发现很多网站都用了Cloudflare的Turnstile验证(如下),相信大家都多多少少碰到过,这个玩意儿就方便很多,只需要按一下就能通过,甚至有些在进入的时候会自动判断是否是人机,不需要用户操作。思来想去这玩意儿还是更适合我这个小破站,故选择使用Cloudflare Turnstile技术来升级小破站的人机检测机制。
Cloudflare Turnstile

为Typecho博客添加Cloudflare Turnstile人机验证

  Cloudflare Turnstile的配置分为两种,第一种是使用Cloudflare的CDN,网站流量经过Cloudflare的,就是在Cloudflare解析DNS时,选择了小黄云,进行Cloudflare加速的,这种情况可以直接在Cloudflare设置自己遭受攻击,开启防护,Cloudflare就会把你的流量先经过Turnstile进行验证。这种好处是无需写代码,简单方便,但由于Cloudflare的服务器在国外,流量经过Cloudflare相当于套了一层国外的CDN,非但起不到加速的作用,反而适得其反,所以很多网站并不会选择流量经过Cloudflare,而是国内的CDN产品。第二种就是关于流量不经过Cloudflare的方式,这种配置下需要自己写代码,集成Cloudflare Turnstile的小组件,但是代码也不是很复杂。这种方式下的思路是通过请求Cloudflare Turnstile服务器来判断用户是否为人机,是用户则放行,是人机则阻止。官方提供了一个详细的参考文档来介绍相关的技术细节,感兴趣的朋友可以研读一下,下面我会完整介绍如何在流量不经过Cloudflare情况下使用Typecho集成Cloudflare Turnstile。
集成Cloudflare Turnstile的大致流程

流量不经过Cloudflare时配置Cloudflare Turnstile

  当用户访问网站时,系统首先检查会话状态(Session状态)。如果用户已通过验证(前端带着Session Id进请求服务器,服务器判断Session中是否有验证成功的字段),则直接放行;否则,页面会展示 Cloudflare Turnstile 组件,要求用户完成人机验证。用户完成验证后,服务器会校验提交的令牌:若验证成功,则设置会话(在该Session下设置验证成功的字段)并跳转至目标页面;若失败,则重新显示验证组件,直至用户成功通过验证。

graph TD
    A[用户访问] --> B{检查会话}
    B -->|已验证| C[放行访问]
    B -->|未验证| D[展示Turnstile组件]
    D --> E[用户完成验证]
    E --> F[服务器校验令牌]
    F -->|成功| G[设置会话并跳转]
    F -->|失败| D

1. 获取API凭证

  登录Cloudflare控制台 → Turnstile → 添加小组件,小组件模式选择托管即可,预先许可可选可不选,创建后记录SITE_KEY与SECRET_KEY

SITE_KEY = "XXXXXXXXXXXXXXX"
SECRET_KEY = "XXXXXXXXXXXXXXX"

2. 修改Typecho入口文件

  编辑index.php,将内容替换为下面的代码:

<?php
// 启动Session
if (session_status() === PHP_SESSION_NONE) {
    session_set_cookie_params(172800);
    session_start();
}

// 定义不需要验证的路径(如后台管理页面)
$excludedPaths = ['/admin/', '/install.php', '/usr/uploads', '/index.php/autobackup', '/index.php/zemail']; // 根据需要添加排除路径

// 检查当前路径是否需要验证
$needVerification = true;
foreach ($excludedPaths as $path) {
    if (strpos($_SERVER['REQUEST_URI'], $path) === 0) {
        $needVerification = false;
        break;
    }
}

// 定义搜索引擎的 User-Agent
$searchEngineBots = [
    'Googlebot', 'Bingbot', 'Baiduspider', 'YandexBot', 
    'DuckDuckBot', 'Slurp', 'facebot', 'ia_archiver',
    'AhrefsBot', 'SemrushBot', 'Applebot', 'Twitterbot'
];

// 获取当前请求的 User-Agent
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';

// 检查是否是搜索引擎的蜘蛛
$isSearchEngineBot = false;
foreach ($searchEngineBots as $bot) {
    if (stripos($userAgent, $bot) !== false) {
        $isSearchEngineBot = true;
        break;
    }
}

// 如果是搜索引擎的蜘蛛,跳过验证
if ($isSearchEngineBot) {
    $needVerification = false;
}

// 处理Turnstile验证
if ($needVerification && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cf-turnstile-response'])) {
    $secret = '替换为你的SECRET_KEY';
    $response = $_POST['cf-turnstile-response'];
    $ip = $_SERVER['REMOTE_ADDR'];
    
    // 向Cloudflare验证
    $url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
    $data = ['secret' => $secret, 'response' => $response, 'remoteip' => $ip];
    
    $options = [
        'http' => [
            'header'  => "Content-Type: application/x-www-form-urlencoded\r\n",
            'method'  => 'POST',
            'content' => http_build_query($data)
        ]
    ];
    
    $context = stream_context_create($options);
    $result = json_decode(file_get_contents($url, false, $context), true);
    
    if ($result['success']) {
        // 验证通过,设置会话状态和过期时间(48小时)
        $_SESSION['turnstile_verified'] = true;
        $_SESSION['turnstile_expire']   = time() + 172800; // 48小时后过期
        header('Location: ' . $_SERVER['REQUEST_URI']); // 重定向回当前页面
        exit;
    }
}

// 检查是否需要验证(48小时内免验证)
if ($needVerification && (
    !isset($_SESSION['turnstile_verified']) || 
    (isset($_SESSION['turnstile_expire']) && time() > $_SESSION['turnstile_expire'])
)) {
    // 显示验证页面
    ?>
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>人机验证</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
        <style>
            body {
                background-color: #f0f2f5;
                display: flex;
                justify-content: center;
                align-items: center;
                min-height: 100vh;
                margin: 0;
                font-family: Arial, sans-serif;
                text-align: center;
            }
            .verify-container {
                width: 90%;
                max-width: 400px;
                padding: 0;
            }
            .verify-container h1 {
                margin: 10px 0;
                font-size: 28px;
                color: #333;
            }
            .verify-container h2 {
                margin: 10px 0;
                font-size: 20px;
                color: #555;
            }
            .verify-container p {
                font-size: 16px;
                color: #666;
                margin-bottom: 20px;
            }
            .error {
                color: #e74c3c;
                margin-bottom: 15px;
                font-size: 16px;
            }
        </style>
    </head>
    <body>
        <div class="verify-container">
            <h1>maisblog.cn</h1>
            <h2>请完成以下操作,验证您是真人</h2>
            <?php if (!empty($error)) echo "<div class='error'>$error</div>"; ?>
            <form id="verify-form" method="POST">
                <div style="margin-bottom: 20px;">
                    <div class="cf-turnstile" 
                         data-sitekey="替换为你的SITE_KEY" 
                         data-callback="onSuccess"
                         data-theme="light">
                    </div>
                </div>
                <input type="hidden" name="cf-turnstile-response" id="cf-turnstile-response">
            </form>
            <p style="margin-top: 20px; font-size: 12px; color: #999;">
                继续之前,maisblog.cn需要先检查您的连接的安全性
            </p>
        </div>

        <script>
            function onSuccess(token) {
                document.getElementById("cf-turnstile-response").value = token;
                document.getElementById("verify-form").submit();
            }
        </script>
    </body>
    </html>
    <?php
    exit;
}

/**
 * Typecho Blog Platform
 *
 * @copyright  Copyright (c) 2008 Typecho team (http://www.typecho.org)
 * @license    GNU General Public License 2.0
 * @version    $Id: index.php 1153 2009-07-02 10:53:22Z magike.net $
 */

/** 载入配置支持 */
if (!defined('__TYPECHO_ROOT_DIR__') && !@include_once 'config.inc.php') {
    file_exists('./install.php') ? header('Location: install.php') : print('Missing Config File');
    exit;
}

/** 初始化组件 */
\Widget\Init::alloc();

/** 注册一个初始化插件 */
\Typecho\Plugin::factory('index.php')->begin();

/** 开始路由分发 */
\Typecho\Router::dispatch();

/** 注册一个结束插件 */
\Typecho\Plugin::factory('index.php')->end();
?>

技术细节拆解

1. Session 处理

  • 在脚本开始时,检查是否已经启动 Session,如果未启动,则设置 Session 过期时间为 48 小时(172800 秒) 并启动:

    if (session_status() === PHP_SESSION_NONE) {
        session_set_cookie_params(172800);
        session_start();
    }

    因为session技术是基于cookie来传递session ID的,如果未设置cookie的有效期,则浏览器关闭后会自动释放cookie,下次启动浏览器又需要验证。

    2. 排除特定路径的验证

  • 定义 $excludedPaths 数组,存放不需要进行验证的路径(如 /admin/, install.php/usr/uploads),用户上传的东西不需要验证,要注意如果你有执行博客某个网页才能完成的需求,一定要放行,比如说自动备份插件与发送邮件插件,就一定要放行'/index.php/autobackup', '/index.php/zemail'
  • 通过遍历 $_SERVER['REQUEST_URI'] 来检查当前请求路径是否在排除列表中:

    foreach ($excludedPaths as $path) {
        if (strpos($_SERVER['REQUEST_URI'], $path) === 0) {
            $needVerification = false;
            break;
        }
    }

3. 搜索引擎爬虫检测

  • 维护一个 $searchEngineBots 数组,存放常见的搜索引擎爬虫 User-Agent

    $searchEngineBots = [
        'Googlebot', 'Bingbot', 'Baiduspider', 'YandexBot', 
        'DuckDuckBot', 'Slurp', 'facebot', 'ia_archiver',
        'AhrefsBot', 'SemrushBot', 'Applebot', 'Twitterbot'
    ];
  • 通过 stripos 检查 $_SERVER['HTTP_USER_AGENT'],判断是否属于搜索引擎:

    foreach ($searchEngineBots as $bot) {
        if (stripos($userAgent, $bot) !== false) {
            $isSearchEngineBot = true;
            break;
        }
    }
  • 如果是搜索引擎爬虫,则跳过验证:

    if ($isSearchEngineBot) {
        $needVerification = false;
    }

    这个检测搜索引擎爬虫有一定的不足,光靠user-agent并无法准确判断哪些是搜索引擎的请求,需要加上常见搜索引擎IP段之类的,但我们并不能明确知道哪些段是爬虫IP,所以有什么办法大家可以想一想。

4. Cloudflare Turnstile 验证

  • 仅在 POST 请求中,并且包含 cf-turnstile-response 时,才进行 Cloudflare Turnstile 验证:

    if ($needVerification && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cf-turnstile-response'])) {
  • 通过 file_get_contents 发送 POST 请求到 https://challenges.cloudflare.com/turnstile/v0/siteverify 进行验证:

    $data = ['secret' => $secret, 'response' => $response, 'remoteip' => $ip];
    $options = [
        'http' => [
            'header'  => "Content-Type: application/x-www-form-urlencoded\r\n",
            'method'  => 'POST',
            'content' => http_build_query($data)
        ]
    ];
    $context = stream_context_create($options);
    $result = json_decode(file_get_contents($url, false, $context), true);
  • 如果验证成功,则在 Session 中存储验证状态,并设置 48 小时的有效期:

    if ($result['success']) {
        $_SESSION['turnstile_verified'] = true;
        $_SESSION['turnstile_expire']   = time() + 172800;
        header('Location: ' . $_SERVER['REQUEST_URI']);
        exit;
    }

5. 前端验证页面

  • 如果 Session 过期或 Turnstile 未通过验证,则显示 Turnstile 认证页面:

    if ($needVerification && (
        !isset($_SESSION['turnstile_verified']) || 
        (isset($_SESSION['turnstile_expire']) && time() > $_SESSION['turnstile_expire'])
    )) {
  • 页面内嵌 Turnstile 验证组件:

    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
    <div class="cf-turnstile" data-sitekey="替换为你的SITE_KEY" data-callback="onSuccess" data-theme="light"></div>
  • 通过 JavaScript 监听 Turnstile 认证结果,并自动提交表单:

    function onSuccess(token) {
        document.getElementById("cf-turnstile-response").value = token;
        document.getElementById("verify-form").submit();
    }

6. Typecho 博客系统的初始化

  • 验证通过后放行,初始化 Typecho 组件,注册 Typecho 插件,执行路由分发:

    \Widget\Init::alloc();
    \Typecho\Plugin::factory('index.php')->begin();
    \Typecho\Router::dispatch();
    \Typecho\Plugin::factory('index.php')->end();

写在结尾

  折腾了这么久网站,跟Cloudflare打交道的次数也越来越多,发现Cloudflare真的浑身是宝啊!DNS、CDN、SSL、Cloudflare Workers、Cloudflare Pages、内网穿透、人机验证、防火墙...关键这里面好多东西都可以免费白嫖,平民的大善人公司,为他点赞!

最后修改:2025 年 03 月 14 日
如果觉得我的文章对你有用,请随意赞赏