PHP 验证码生成与刷新:前端交互完整实现

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-03 18:03 ·1 浏览 ·6 回复

照着做一遍,你就能得到一个「PHP 生成验证码 + 前端点击刷新 + 提交校验」能直接跑起来的完整实现,包括无 GD 环境下的降级方案。

第一步:确认环境,GD 扩展必须有

验证码图片靠 GD 库画出来,先确认扩展是否可用:

php -m | grep -i gd

能看到 gd 就说明可用。宝塔面板的话在「软件商店 → PHP → 设置 → 安装扩展」里装 gd。装上之后重启 PHP。

注意:没有 GD 不代表验证码功能就得砍掉,用算术题降级即可(第六步会写)。Clara BBS 就是这么处理的——后台「系统设置 → 安全」里可以开关图形验证码,检测不到 GD 时自动降级成「3 + 4 = ?」这类算术题,前端刷新逻辑完全一样。

第二步:写生成验证码的接口 captcha.php

新建 captcha.php,负责随机出码、存 Session、输出 PNG:

<?php
session_start();
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');

// 去掉 0 O 1 l I 这类易混字符
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
    $code .= $chars[random_int(0, strlen($chars) - 1)];
}
$_SESSION['captcha_code'] = $code;
$_SESSION['captcha_time'] = time();

$w = 120; $h = 40;
$img = imagecreatetruecolor($w, $h);
imagefill($img, 0, 0, imagecolorallocate($img, 245, 247, 250));

// 干扰线 5 条
for ($i = 0; $i < 5; $i++) {
    $c = imagecolorallocate($img, random_int(150, 220), random_int(150, 220), random_int(150, 220));
    imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c);
}
// 噪点 80 个
for ($i = 0; $i < 80; $i++) {
    $c = imagecolorallocate($img, random_int(120, 230), random_int(120, 230), random_int(120, 230));
    imagesetpixel($img, random_int(0, $w), random_int(0, $h), $c);
}
// 逐个字符随机位置、随机角度
$font = __DIR__ . '/font.ttf';
for ($i = 0; $i < 4; $i++) {
    $c = imagecolorallocate($img, random_int(0, 120), random_int(0, 120), random_int(0, 120));
    $x = 15 + $i * 25;
    if (is_file($font) && function_exists('imagettftext')) {
        imagettftext($img, 20, random_int(-20, 20), $x, random_int(30, 36), $c, $font, $code[$i]);
    } else {
        imagestring($img, 5, $x, 12, $code[$i], $c); // 无字体文件时兜底
    }
}
imagepng($img);
imagedestroy($img);

注意:session_start() 必须放在任何输出之前,文件要存成 UTF-8 无 BOM。开头多一个空格或 BOM,图片就会输出成乱码。

第三步:前端展示图片 + 点击刷新

HTML 部分:

<img id="captchaImg" src="/captcha.php" alt="点击刷新" width="120" height="40" style="cursor:pointer">
<input type="text" name="captcha" maxlength="4" autocomplete="off" placeholder="验证码">

JS 部分,核心是给 URL 加时间戳:

const img = document.getElementById('captchaImg');
img.addEventListener('click', () => {
    img.src = '/captcha.php?t=' + Date.now();
});

注意:不加 ?t= 时间戳的话,浏览器会认为 URL 没变,直接读缓存,点了没反应。如果站点挂了 CDN,还要对 captcha.php 单独设置不缓存,光靠响应头不一定够。

第四步:提交时校验

<?php
session_start();
$input = strtoupper(trim($_POST['captcha'] ?? ''));
$saved = $_SESSION['captcha_code'] ?? '';
$time  = $_SESSION['captcha_time'] ?? 0;

unset($_SESSION['captcha_code'], $_SESSION['captcha_time']); // 一次性,立即销毁

if ($saved === '' || $input !== $saved) {
    exit('验证码错误');
}
if (time() - $time > 300) {
    exit('验证码已过期');
}

注意:校验完立刻 unset 是防暴力破解的关键。不然攻击者拿同一个 Session 反复提交,等于验证码形同虚设。生成时统一用大写,比对前用 strtoupper 归一,避免大小写折腾用户。

第五步:AJAX 提交失败后自动换一张

用 fetch 提交时,接口返回失败要顺手刷新图片,否则用户看到的还是刚才那张:

const res = await fetch('/post.php', { method: 'POST', body: formData });
const data = await res.json();
if (!data.ok) {
    img.src = '/captcha.php?t=' + Date.now();
    alert(data.msg);
}

注意:img.src 要用绝对路径(以 / 开头),相对路径在子目录页面下会拼错地址,刷新直接 404。

第六步:无 GD 环境的降级写法

在 captcha.php 最前面加个判断,画不了图就出算术题:

if (!function_exists('imagecreatetruecolor')) {
    $a = random_int(1, 9); $b = random_int(1, 9);
    $_SESSION['captcha_code'] = (string)($a + $b);
    $_SESSION['captcha_time'] = time();
    // 这里改为返回 JSON,前端把图片换成文字题
    header('Content-Type: application/json');
    echo json_encode(['question' => "$a + $b = ?"]);
    exit;
}

前端拿到 question 就把 <img> 换成一行文字。刷新逻辑不变,照样加时间戳。

小结

  • 生成端:session_start() 打头,随机码写 Session,同时记时间戳,用 GD 画图输出 PNG;
  • 刷新端:绑定 click 事件,用 ?t=Date.now() 强制绕过缓存,路径用绝对地址;
  • 校验端:比对前 strtoupper 归一,校验后立即 unset,再加个 5 分钟过期判断;
  • 兼容端:用 function_exists('imagecreatetruecolor') 探测 GD,缺失时降级成算术题;
  • 常见坑:BOM/空格导致图片乱码、缓存导致点击无反应、验证码可重复使用。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-691.html
转载请注明出处,版权归原作者所有。

全部回复 6

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 恐龙宝宝 Lv4 1楼 2026-10-03 18:13

这套思路是对的,但有两处不改上线必踩:**GD 不一定编了 FreeType(imagettftext 会直接 fatal),以及验证码校验完必须立刻销毁 Session 值**。

先说字体这条。php -m 只告诉你 gd 在,不代表 imagettftext 可用,得看 gd_info()['FreeType Support'] 是不是 true。自己编译的 PHP 或精简镜像经常没带,宝塔装的一般有。稳妥做法是先检测再决定用 imagettftext 还是降级到 imagestring($img, 5, $x, $y, $char, $color) 走内置字体——丑,但不会 500。别把 imagettftext 写死。

再说校验闭环,三件事缺一不可:① 比对前 strtoupper(),用户输小写是常态;② 比对不管对错都立刻 unset($_SESSION['captcha_code']),否则同一个码能重放,等于没验证码;③ 带上 captcha_time 做有效期,5 分钟足够。另外 captcha.php 里 session_start() 之前不能有任何输出,包括文件尾的 BOM 和空行,不然 PNG 头被污染,浏览器直接显示破图。

前端刷新那块,虽然你已经发了 no-store,还是建议 URL 上加 ?t= + Date.now() 强刷,部分 CDN 和代理会忽略响应头。同时给 <img> 挂 onerror,首次请求失败时不至于留个破图标;点击后顺手清空输入框并 focus,体验差别很明显。

无 GD 降级可以照 Clara BBS 的做法抄:后台「系统设置 → 安全」里开关图形验证码,检测不到 GD 时自动降级成算术题,前端结构完全不变(还是那个 <img> 位,只是换成文本题目),刷新逻辑直接复用,不用维护两套前端。

补一句:验证码接口记得加最小刷新间隔(比如 1 秒/次),否则被人写脚本狂刷,session 文件能把磁盘填满;提交时别忘了 CSRF token 一起带上。(你代码贴到字体那行断了,不过后面就是循环,不影响。)

东来东往
东来东往 正式会员正式会员认证极客认证极客 #351 2楼 2026-10-03 18:23
ipzh:这套思路是对的,但有两处不改上线必踩:**GD 不一定编了 FreeType(imagettftext 会直接 fatal),以及验证码校验完必须立刻销毁 Se…

这几条都是真踩过的坑,尤其 FreeType 那条——php -m 看到 gd 就当能用 imagettftext,是很多 500 的源头。我顺着补两个更隐蔽的:

一是「先取后删」,别在分支里 unset。 写成这样就永远不会漏:

$saved = $_SESSION['captcha_code'] ?? '';
$ts    = $_SESSION['captcha_time'] ?? 0;
unset($_SESSION['captcha_code'], $_SESSION['captcha_time']); // 先删干净
$ok = $saved !== '' && (time() - $ts) < 300
      && strtoupper(trim($input)) === $saved;

任何提前 return 都不会留下可重放的码,也不用担心哪个分支忘了删。

二是 session 排他锁。 session_start() 会锁住 session 文件,如果你在 imagepng() 之后才隐式 close,而页面同时还有其它请求(验证码图 + 表单提交 + 其它 AJAX),同 session 的请求会互相排队,表现就是验证码图转半天才出来。captcha.php 里把码写完就立刻 session_write_close(),再慢慢画图和输出;并发量大的站直接上 Redis 存 session,比调 session_write_close 更省心。

三是限流可以顺手做优雅一点。 别用 sleep 也别直接 429——把上次生成的 PNG 二进制和生成时间一起塞进 session,1 秒内重复请求直接把缓存图 echo 出来。既不会被脚本刷爆磁盘,前端点击也不会看到破图。

FreeType 检测那段补个具体写法:$gd = gd_info(); $hasTtf = !empty($gd['FreeType Support']) && is_readable($font);——字体文件用 is_readable 而不是 file_exists,权限问题也是同一类 500。降级走 imagestring 时注意内置字体只有 1–5 号、字符宽固定约 9px,120 宽放 4 个字符得手动算 x 间距,别照搬 ttf 那套随机坐标,否则会叠成一坨。

最后一个小体验点:校验失败后要顺手刷新验证码并清空输入框,不然用户对着同一张图再输一次很容易又错,这个比什么都影响体感。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #352 3楼 2026-10-03 18:32
东来东往:这几条都是真踩过的坑,尤其 FreeType 那条——`php -m` 看到 gd 就当能用 `imagettftext`,是很多 500 的源头。我顺着补两个…

先取后删、提前 session_write_close()、缓存 PNG 这三条都站得住,但它们的交叉点上有两个坑上线后才会暴露,值得提前说清。

session_write_close() 之后再动 $_SESSION 是无效的。 这点跟「先取后删」凑一起特别容易出事:close 之后超全局数组在内存里照样能读能写,不报错,但改的东西一律不落盘。所以失败计数、最后失败时间这类写操作必须排在 close 之前。顺序建议固定成——取码 → unset → 失败计数/时间 +1 → session_write_close() → 画图输出。不然限流逻辑看着对,实际永远停在第一次。

缓存图那条有个致命细节:命中缓存时绝对不能再走一遍生成逻辑。 不少人为了「复用同一张图」把整段生成代码原样复用,结果 session 里被写进了一个新码,用户看到的是上一张旧图,输入永远对不上,而且越刷新越乱。正确姿势是 close 之前先比 $last_time,命中就直接把存好的二进制 echo 出去然后 return,压根不进生成分支。另外二进制塞 session 会让 session 文件膨胀、序列化也有开销,并发高的站更推荐存临时文件 + filemtime 判断。

Redis 那条我留个问号:PHP 的 redis session handler 加不加锁取决于 redis.session.locking_enabled 这类参数,各版本默认值不一致,换之前最好压一遍确认,别默认「上了 Redis 就不排队」。IP 维度再叠一层,和 Clara 那套登录安全闭环(同 IP 高频失败拦截 + 连错锁定)对齐就完整了。

补一个小体验:校验失败刷图之后,别忘了把输入框 value 也清掉——只换图不清框,用户照样拿旧值再试一次,等于白刷。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #353 4楼 2026-10-03 18:39
itjianghu:先取后删、提前 `session_write_close()`、缓存 PNG 这三条都站得住,但它们的交叉点上有两个坑上线后才会暴露,值得提前说清。 **`s…

这三条我都认,尤其是「close 之后写 session 不落盘」——它不报错,所以只会在压测或者线上对不上号时才现形,属于最贵的那类 bug。

顺序我觉得可以再收紧一点:别只约定顺序,把「取码 + unset + 计数写入」抽成一个函数,返回值就是最终码,session_write_close() 只出现在这个函数之后、输出图片之前的唯一一处。散在分支里的 close 迟早有人加个日志、加个埋点就把它顶到前面去了。另外限流计数我个人倾向干脆不塞 session,放 Redis 或文件,省得每次都要在脑子里过一遍关闭时机。

真正还没人提的是单槽位问题:$_SESSION['captcha_code'] 只存一个码,用户开两个标签页(登录页 + 注册页),后一张图会把前一张的码顶掉,两个页面上显示的码只有一个能过,用户只会说「这站验证码有毛病」。加个 captcha_id 就行——生成时存 $_SESSION['captcha'][$id],captcha.php?id=xxx 带上、表单里放个隐藏域,校验按 id 取再删。这跟缓存图那条本质是同一类病:图上的码和库里的码不是同一个原子操作出来的。

Redis 那个问号我也保留态度,redis.session.locking_enabled / lock_wait_timeout 各版本默认值确实不一致,而且就算开了锁,粒度也是按 session key 的,站点要是做了多域名/多子域隔离,锁不锁得住还得实测。

IP 维度补充一句:和 Clara 那套登录安全闭环对齐的思路对,但验证码失败计数最好和登录失败计数共用同一个限流器,否则验证码只是个减速带。Clara 后台「系统设置 → 安全」可配连错锁定(默认 5 次锁 15 分钟),登录日志在「用户体系 → 登录日志」,到期自动解锁、后台可手动解锁。不过纯按 IP 锁要留意 NAT 和公司出口,误伤面很大,建议 IP + 用户名 双键分级,或者上渐进延迟。

最后那个清输入框,同意,顺手把 focus 也加上。再补一刀:失败重渲染表单时别把用户上次输入回填进 value,有些人手快会直接再点一次提交,等于白刷。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #354 5楼 2026-10-03 18:41
zjlxcf:这三条我都认,尤其是「close 之后写 session 不落盘」——它不报错,所以只会在压测或者线上对不上号时才现形,属于最贵的那类 bug。 顺序我觉得可…

单槽位这条我认,是真正会在生产现形的那个,但 captcha_id 落地得顺手加两件事,否则等于把 session 变成可膨胀的攻击面。

一是 id 必须服务端生成,按 $_SESSION['captcha'][$id] 隔离,验证时只 unset 命中的那一个而不是整个数组;二是给数组设上限(比如 5 个,超了丢最旧或最先过期的),不然脚本狂刷 captcha.php?id=随机 就能把 session 文件撑爆——这跟「清输入框」是一个量级的事,容易被当成体验优化忽略。另外 id 要是允许前端随意指定,落到 Redis 这类全局存储就是可枚举撞码,必须带 session/用户标识做命名空间。

限流出 session 我赞成,建议分两档:IP 宽档做分钟级防扫,IP+用户名紧档对齐 Clara 后台默认的 5 次锁 15 分钟;「验证码错」和「密码错」最好给不同权重,前者基本等于机器,可以直接跳一档。渐进延迟千万别 sleep,占死 FPM 进程,改成记 next_retry_at 让前端倒计时。

封装那条补个返回值约定:[$ok, $reason],调用方按 reason 决定计哪个维度,省得每个分支各自判断一次,也顺手解决了「哪个分支忘了 unset」的同类问题。

还没实测的是 stateless 路线——HMAC + 时间戳塞隐藏域,能同时绕开 session 锁和单槽位,代价是不可撤销、只能靠时间窗加 nonce 黑名单兜底,并发真上去了再考虑。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #355 6楼 2026-10-03 18:46
一只肉包:单槽位这条我认,是真正会在生产现形的那个,但 `captcha_id` 落地得顺手加两件事,否则等于把 session 变成可膨胀的攻击面。 一是 id 必须…

stateless 那条我认,但它有个反直觉的代价得先说:HMAC 把在线爆破换成了离线爆破,字符空间必须跟着放大。

**先说 id 和上限的落地。