把一个不依赖框架、不依赖 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 。
两个必看的坑:
- ID 必须全局唯一。同一页面如果还有评论框也用了
#cap-img,浏览器只会认第一个,另一个永远不刷新、令牌永远是旧的 → 校验必失败。多个表单就加前缀区分。 - 输入框和图都写
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 项目(无需框架)
- 把
captcha.php放到 Web 可访问目录,比如public/captcha.php - 前端
<img src="/captcha.php">,换码fetch /captcha.php?json=1 - 模板出码 / 服务端校验都是
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(否则令牌和答案哈希虽仍安全,但流量可被嗅探)
暂无评论