把一个不依赖框架、不依赖 Session/Cookie/ 数据库的图形验证码,接入任意 Typecho 主题或任意 PHP 项目。

这套方案解决什么问题

常见的验证码做法是「出码时写 Session,提交时读 Session」。它在三种场景下会崩:

场景:崩法:
session.save_path 不可写Session 写不进去,校验永远失败
站点挂了CDN / 反代图片响应的 Set-Cookie 被剥离,出码与提交不在同一会话
多台后端负载均衡请求落到不同机器,另一台读不到码

表现为同一个症状: 验证码没填错,却永远提示不正确, 而且查不出原因。
本方案换了个思路: 不在服务端存任何状态 。出码时把「过期时间 + 答案哈希的 HMAC 签名」随表单一起发给浏览器;提交时用同一个密钥重算签名比对

  • 答案只以哈希形态出现在签名里,客户端读不到也伪造不了
  • 不写 Session 、不设 Cookie 、不连数据库 → 上面三种场景全部免疫
  • 「一次性」通过一个文件标记实现(同一张码只放行一次),防重放

文件与依赖

captcha.php 单文件,含出码/ GD 绘制/签名/校验/防重放/出图限流/ HTTP 端点
依赖只有:PHP 7.4+、GD 扩展、hash 扩展(PHP 默认自带,不用额外确认),拷到项目里就能用。放在哪都行——主题目录、站点根目录、includes/ 下均可。

先改一个配置项

打开 captcha.php ,第一段就是配置:

const CAPTCHA_SECRET = '';    // ← 改成你自己的随机串
const CAPTCHA_TTL    = 600;   // 有效期(秒)
const CAPTCHA_LEN    = 4;     // 位数
const CAPTCHA_CHARS  = '23456789abcdefghjkmnpqrstuvwxyz';  // 已排除 0/1/l/i/o 等易混字符
const CAPTCHA_STORE  = '';    // 防重放记录目录,留空用系统临时目录

CAPTCHA_SECRET 必须设 。留空时会按请求域名派生一个密钥——能用,但换域名(比如给站点加裸域)后旧令牌全部失效,用户会碰到"刷新一下才能填"。生成随机串:

php -r "echo bin2hex(random_bytes(16)), PHP_EOL;"

接入方式

Typecho 主题

第 1 步:放文件

把 captcha.php 放到主题目录,比如 usr/themes/你的主题/captcha.php 。

第 2 步:在 functions.php 里注册路由

Typecho 主题的 functions.php 里定义 themeInit($archive) ,每个页面请求都会走到。加一段:

function themeInit($archive)
{
    if ($archive->request->get('action') === 'my-captcha') {
        require_once __DIR__ . '/captcha.php';
        captcha_endpoint();   // 内部会输出并 exit
    }
}

action 名字自己取(这里用 my-captcha ),全程一致即可。之后:

  • /index.php?action=my-captcha → 输出 PNG
  • /index.php?action=my-captcha&json=1 → 输出 {"code":200,"data":{"img":"data:...","e":1730000000,"s":"64位签名"}}

开了伪静态也一样可用(action 形式不依赖伪静态)。

第 3 步:在模板里出码并渲染表单

以评论表单为例( comments.php 或你自己的表单模板):

<?php
require_once __DIR__ . '/../captcha.php';   // 路径按实际位置调整
$cap   = captcha_issue();
$capApi = rtrim((string)parse_url(Helper::options()->siteUrl, PHP_URL_PATH), '/')
          . '/index.php?action=my-captcha&json=1';   // 站点根相对,子目录安装也正确
?>

<div class="flex items-center gap-3">
    <img id="cap-img" src="<?php echo captcha_data_uri($cap['png']); ?>"
         data-cap-base="<?php echo htmlspecialchars($capApi, ENT_QUOTES, 'UTF-8'); ?>"
         data-cap-ts="<?php echo time(); ?>"
         alt="验证码" title="看不清?点击更换"
         class="h-10 w-[120px] rounded-lg border border-gray-200 cursor-pointer select-none">
    <input type="text" name="captcha" id="cap-input" maxlength="4" autocomplete="off" required
           placeholder="验证码" class="h-10 w-[110px] py-0 px-3 leading-10 text-sm rounded-lg border">
    <button type="button" id="cap-refresh" class="text-xs text-gray-500">换一张</button>
</div>
<input type="hidden" name="captcha_e" id="cap-e" value="<?php echo (int)$cap['expire']; ?>">
<input type="hidden" name="captcha_s" id="cap-s" value="<?php echo htmlspecialchars($cap['sig'], ENT_QUOTES, 'UTF-8'); ?>">

三个 name 是固定约定(服务端按它们读): captcha、captcha_e、captcha_s 。
两个必看的坑:

  1. ID 必须全局唯一。同一页面如果还有评论框也用了 #cap-img ,浏览器只会认第一个,另一个永远不刷新、令牌永远是旧的 → 校验必失败。多个表单就加前缀区分。
  2. 输入框和图都写 h-10 ( Tailwind 默认 border-box ,含边框)。只写 py-2 的话输入框会因默认行高比图高出一截,底边不齐——这是移动端最常见的"错位"反馈。

第 4 步:JS 换码(原生,无依赖)

<script>
(function () {
    var img = document.getElementById('cap-img');
    if (!img) { return; }
    var input = document.getElementById('cap-input');
    var eEl   = document.getElementById('cap-e');
    var sEl   = document.getElementById('cap-s');
    var base  = img.getAttribute('data-cap-base') || '';

    function refresh() {
        if (!base) { return; }
        if (input) { input.value = ''; }
        fetch(base, { credentials: 'same-origin' })
            .then(function (r) { return r.json(); })
            .then(function (j) {
                var d = j && j.data;
                if (!d || !d.img) { return; }
                img.src = d.img;
                img.setAttribute('data-cap-ts', String(Math.floor(Date.now() / 1000)));
                if (eEl) { eEl.value = d.e; }
                if (sEl) { sEl.value = d.s; }
            })
            .catch(function () {});
    }

    img.addEventListener('click', refresh);
    var btn = document.getElementById('cap-refresh');
    if (btn) { btn.addEventListener('click', refresh); }

    // 页面停留过久内联的那张已不新鲜,超过 60 秒顺手换一张
    var ts = parseInt(img.getAttribute('data-cap-ts') || '0', 10);
    if (ts > 0 && Math.abs(Math.floor(Date.now() / 1000) - ts) > 60) { refresh(); }
})();
</script>

第 5 步:提交前校验 + 提交后换新

form.addEventListener('submit', function (e) {
    e.preventDefault();
    if (input && input.value.trim() === '') {
        alert('请输入验证码');       // 换成你自己的提示组件
        input.focus();
        return;
    }
    // ... fetch 提交 ...
    // 成功或失败都要换新码(一次性,用过即作废)
});

// 提交完成后的回调里:
form.reset();
eEl.value = '';        // 先清空隐藏域!reset() 会把它们还原成页面内联时的旧令牌
sEl.value = '';
refresh();             // 再换新码

漏了清空隐藏域这一步,用户连点两次提交就会拿到"验证码不正确"——因为第一张码已被消费。这是接入后最常被误报为 bug 的现象。

纯 PHP 项目(无需框架)

  1. 把 captcha.php 放到 Web 可访问目录,比如 public/captcha.php
  2. 前端 <img src="/captcha.php"> ,换码 fetch /captcha.php?json=1
  3. 模板出码 / 服务端校验都是 require 后调函数,与上面完全相同

不需要写任何路由代码——文件末尾这段会在被直接访问时自动进入端点逻辑,被include时只提供函数、不产生输出:

if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
    captcha_endpoint();
}

Nginx / Apache 都不用配。唯一注意 CAPTCHA_STORE 指向的目录要有写权限(默认系统临时目录,一般都有)。

服务端校验(框架无关)

在你的 POST 处理函数里, 放在写库 / 发邮件 / 调第三方之前:

require_once __DIR__ . '/captcha.php';

// 1) 取输入:自己过滤数组型提交。PHP 8 里 (string)[] 会得 "Array" 并抛 Notice
$input  = $_POST['captcha'] ?? '';
if (is_array($input)) { $input = ''; }
$input  = trim((string)$input);

if ($input === '') {
    exit(json_encode(['code' => 400, 'msg' => '请输入验证码'], JSON_UNESCAPED_UNICODE));
}

// 2) 校验
if (!captcha_verify($input, $_POST['captcha_e'] ?? '', $_POST['captcha_s'] ?? '')) {
    exit(json_encode(['code' => 403, 'msg' => '验证码不正确,请点击图片换一张后重试'], JSON_UNESCAPED_UNICODE));
}

// 3) 通过,继续业务逻辑

为什么数组型输入要挡: 不挡的话 captcha[]=x 这种提交会走到 (string) 转换,PHP 8下得到字符串 "Array" 并抛 Notice——功能上仍然校验失败(不会绕过),但日志里会多一条告警,且是纯粹的无用噪音。
失败要计费: 验证码只有 4 位 × 30 个字符 ≈ 81 万种组合,暴力枚举成本不高。上面代码里没写限流是因为 captcha.php 的出图端已带「同 IP 每分钟 60 张」;提交端建议再加一层,例如同 IP 每分钟最多 5 次失败:

// 伪代码:用你项目里现成的限流/缓存机制;没有就照 captcha_rate_ok() 的文件锁写法抄一个
if (!my_rate_limit_ok('captcha-fail:' . $_SERVER['REMOTE_ADDR'], 5, 60)) {
    http_response_code(429);
    exit(json_encode(['code' => 429, 'msg' => '验证码错误次数过多,请稍后再试']));
}

换成 Node/Python/Go 后端

验证逻辑可以跨语言复刻,两边共用同一个 CAPTCHA_SECRET 即可。算法就三行:

sign = HMAC_SHA256(
    key    = CAPTCHA_SECRET,
    msg    = <过期时间戳(秒)> + "|" + sha256_hex(小写化的验证码)
)
  • 出码:PHP 端保留(GD 画图最省事),Node 端只做校验
  • 校验: hash_equals 等价于各语言的常量时间比较函数(Node:crypto.timingSafeEqual ,Python: hmac.compare_digest )
  • 时间窗:expire 需满足 now <= expire <= now + TTL + 300(后 300 秒是时钟偏差余量)
  • 大小写不敏感(比对前 lower())
  • 防重放:按 sha256(sig) 前 32 位做 key,存 Redis 并设 TTL + 300 秒过期 SETNX 成功才算首次使用

上线前检查清单

CAPTCHA_SECRET 已改成随机串(不是留空)
服务器已装 GD:php -m | grep -i gd 。没装时 captcha_endpoint() 会返回 500/纯文本,不会白屏——但验证码不可用,考虑装 GD 或换算术验证码
CAPTCHA_STORE 目录可写(或留空用系统临时目录);多站点共用临时目录也不会串号(key 是 HMAC 派生)
同一页面多个表单时,元素 ID 已加前缀区分
提交成功后:清空 captcha_e / captcha_s → 换新码
提交失败也换新码
提交端已加失败限流
全站 HTTPS(否则令牌和答案哈希虽仍安全,但流量可被嗅探)

配套源码