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

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-03 18:03 ·0 浏览 ·6 回复
本文转载自 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 和上限的落地。