Web 登录验证码规范:从老后台排查到 Redis 一次性校验

记录我排查老后台验证码失效逻辑,并在新版 web_3 中落地后端生成、Redis 存储、后端一次性校验方案的完整过程

Web 登录验证码规范:从老后台排查到 Redis 一次性校验

这篇笔记记录我最近围绕 Web 管理端登录验证码做的一次完整排查和改造。事情的起点很简单:我想确认老服务器上的后台验证码到底是不是“真的在校验”,以及新版 web_3 应该把验证码流程收敛成什么样,才算一套真正完整的登录安全链路。

这次我主要做了三件事:

  • 排查老服务器 yzdwx.yizhoudao.net/admin/ 的验证码真实逻辑
  • 确认老 Web 是否真的启用了 Redis
  • 在本地 web_3 里把管理端验证码改成“后端生成 + Redis 存储 + 后端一次性校验”

最后的结论也很明确:老后台页面上虽然有验证码,但登录流程里没有真正校验它;新版 web_3 已经改成标准的 Redis 验证码闭环。

我为什么要单独整理这篇笔记

验证码这种东西很容易出现一种假安全状态:页面上明明有输入框,也有验证码图片,前端看起来一切都在,但真正决定安全性的不是页面上有没有控件,而是后端登录逻辑到底有没有做验证,以及验证结果有没有被一次性消费。

这次排查让我更确定一件事:登录验证码必须按“生成、存储、提交、校验、销毁”这五步闭环来设计,缺任何一步都不算真正落地。

老服务器后台验证码的真实逻辑

这次我先通过 SSH 登录老服务器,只做了只读排查,没有改线上代码,也没有重启服务。

我定位到的站点信息是:

  • 域名 yzdwx.yizhoudao.net 对应站点目录:/www/wwwroot/yzdwx5.weitaibei.com/public_html
  • 应用入口:/www/wwwroot/yzdwx5.weitaibei.com/public_html/index.php
  • 实际业务代码目录:/www/wwwroot/yzdwx5.weitaibei.com/system/application

接着我去看后台登录页模板,确认页面上确实有验证码输入框和验证码图片。页面会请求:

/captcha

点击刷新时会请求:

/captcha?random=...

这个路由最终来自 ThinkPHP 的验证码包。继续往下追,我确认到:

  • 验证码图片确实是后端生成的
  • 验证码答案会写入 Session
  • 不是写在前端,也不是直接写入 Redis

更关键的是,我继续追到后台登录控制器之后发现,老后台登录方法虽然接收了 $verify 参数,但实际逻辑里只做了这些事:

  • 用户名判空
  • 密码判空
  • 调用 rbaclogin(...)

我没有看到任何真正的验证码校验逻辑,比如:

  • captcha_check($verify)
  • validate(...captcha...)
  • 手动对比验证码答案

所以老服务器后台验证码的真实情况其实是:

  • 页面上有验证码
  • 后端也会生成验证码图片
  • 验证码答案保存在 session
  • 但登录业务流程里没有真正校验它

这意味着老版本后台的验证码大概率属于一种“界面还在,但校验已经失效或被漏掉”的状态。

我是怎么确认这件事的

这次排查不是靠猜,也不是只看前端页面,而是按一条比较稳的链路逐步确认的。

我先确认 SSH 和站点目录:

  • 先通过本机 SSH 别名连到服务器
  • 查 Nginx 配置,确认 yzdwx.yizhoudao.net 实际指向哪个目录
  • 查 PHP-FPM 和 Nginx 运行状态

然后我再往应用层追:

  • 找后台登录模板
  • /captcha 路由来源
  • 找验证码生成逻辑
  • 找验证码校验逻辑

最后我再去看后台登录控制器:

  • login() 方法是否真正调用了验证码校验
  • 结果是没有

这个顺序的好处是不会被页面表现误导。只要登录控制器里没有执行验证码校验,那页面上验证码画得再完整,也只是“视觉存在”,不是“安全生效”。

老 Web 到底有没有用 Redis

这部分我也专门确认了一遍,因为很多项目都会出现一种情况:代码里带着 Redis 封装类,但线上根本没启用。

这次我的结论是:

  • 代码仓里确实有 Redis 工具类
  • 但线上这套老 Web 没查到业务代码实际调用它
  • 服务器本身也没发现 Redis 服务在运行

我当时主要查了几类证据:

  • ThinkPHP 的 session 配置
  • PHP 的 session.save_handler
  • /tmp 下是否存在大量 sess_* 文件
  • 服务器上是否有 redis-server
  • 是否有 redis-cli
  • 是否监听了 6379

最终确认到的事实是:

  • Session 默认驱动不是 Redis
  • PHP 当前会话处理器是 files
  • /tmp 下确实存在大量 sess_* 文件
  • 服务器上没发现 Redis 进程
  • 没发现 6379 监听
  • 也没发现 Redis 二进制

所以更准确的说法不是“老项目完全没有 Redis”,而是:

老项目带过 Redis 封装代码,但当前运行中的老站没有实际启用 Redis,后台验证码也不在 Redis 里。

老版本验证码的问题到底出在哪

如果用一句话概括老后台的问题,我会说:

它的问题不是“验证码生成失败”,而是“验证码没有进入真正的登录校验链路”。

也就是说,旧系统表面上像是有验证码防护,但真正的登录安全流程里并没有用到它。这个问题比“没有验证码”更隐蔽,因为产品、测试甚至开发自己都可能先被页面骗过去。

从规范角度看,老版本至少有两个明显缺口:

  • 验证码答案存进了 Session,但登录接口没有校验
  • 验证码不是一次性消费,即使以后补校验,也容易留下复用问题

我在本地 web_3 改成了什么逻辑

确认完老系统问题之后,我在本地 web_3 管理端把验证码流程改成了完整的后端闭环。

现在的流程是:

  1. 前端请求 GET /api/admin/login/captcha
  2. 后端生成: captchaId 4 位验证码 Base64 PNG 图片
  3. 后端把验证码答案写入 Redis
  4. 前端只展示图片,同时保留 captchaId
  5. 用户登录时,前端提交: username userpwd captchaId captchaCode
  6. 后端先去 Redis 校验验证码
  7. 校验时使用 getAndDelete
  8. 读取成功后立即删除

这个设计意味着一个验证码只有一次尝试机会。

无论最终登录成功还是失败,这个验证码都会失效,不会继续留在系统里被重复提交。

新版验证码链路的核心规范

结合这次改造,我把 Web 登录验证码应该遵守的规范总结成下面这几条。

规范 1:验证码必须由后端生成

验证码图片、验证码答案和 captchaId 都必须由后端统一生成。前端只负责展示,不参与答案生成,也不保存正确答案。

这样可以避免前端暴露正确结果,保证校验基准只存在于服务端。

规范 2:验证码答案必须保存在短期存储中

这次我选的是 Redis。它很适合这种短时、一次性、低价值但安全敏感的数据。

相比 session/file,Redis 更适合后续扩展:

  • 可以设置 TTL
  • 可以做原子删除
  • 可以支持多机共享
  • 可以承接登录风控类临时状态

规范 3:登录接口必须在鉴权前先校验验证码

验证码校验不能放在一个“看起来有,但不影响主流程”的边缘位置。它必须在用户名密码真正进入登录鉴权前完成。

也就是说,顺序应该是:

先校验验证码
再校验账号密码
最后建立登录态

如果验证码失败,后面的账号密码逻辑就不应该继续执行。

规范 4:验证码必须一次性消费

这次我专门用了 getAndDelete 这种读取即删除的方式,目的是确保验证码永远只能用一次。

这个约束非常重要,因为如果验证码校验通过后不立刻销毁,就可能出现:

  • 同一验证码被重复利用
  • 自动化脚本反复撞同一个验证码
  • 失败重试时复用旧验证码

一次性消费是验证码机制真正生效的关键之一。

规范 5:成功和失败都要失效

很多人只会在“登录成功”时删验证码,但这其实还不够。

更稳的做法是:

  • 校验成功后立即删除
  • 校验失败也要删除

因为验证码本来就是一次挑战,不应该允许用户拿着同一个 captchaId 连续试很多次。

这次改成 Redis 后,我认为最实际的好处

这次改造给我的直接感受,不是“技术更高级了”,而是整个登录安全链路终于闭合了。

主要好处有这些:

逻辑闭环完整

老版本是“有验证码页面,但登录不校验”。

现在变成了:

  • 后端生成
  • 后端存储
  • 前端展示
  • 后端校验
  • 一次性消费

这才是一条完整的安全链路。

不再依赖本地 session 文件

老站的验证码答案实际落在 session/file/tmp sess_* 上。

这套方案虽然在单机环境里能跑,但它天然更偏运行时实现,不够适合后续扩展。Redis 对这类短期临时数据更合适,也更容易观察和控制。

一次性验证码更自然

Redis 很适合实现这种需求:

  • TTL 过期
  • getAndDelete
  • 成功失败都删除

这些语义在验证码场景里非常自然,不需要绕很多额外逻辑。

多机部署更友好

如果以后不是单机,而是多台 Web 共同处理请求,那么 session/file 很容易出现跨机器不一致问题。

Redis 作为共享存储,可以让所有 Web 节点读到同一份验证码状态,这对后续扩容非常关键。

后续风控能力更容易接上

验证码逻辑进入 Redis 之后,很多安全相关能力也更容易继续往上加,比如:

  • 短信验证码
  • 登录失败次数限制
  • 风控计数
  • 临时 token
  • AI/RAG 相关短期缓存

从工程上看,这相当于把“登录临时态”统一收进了一个更适合治理的位置。

这次我顺手做的配套工作

为了让新版 web_3 本地链路真正跑通,这次我还顺手处理了几项配套问题:

  • 本地临时启动了一个 Redis,监听 127.0.0.1:6379
  • 修了 backend-java 启动时的 YAML 配置问题
  • 清理了后端端口占用
  • 把管理端前端改成支持局域网访问,监听 0.0.0.0:9511
  • 调整了登录页移动端和窄屏适配

这些改动虽然不是验证码主逻辑,但它们决定了整个方案能不能从“代码写好了”真正走到“本地完整跑通”。

我最后得出的规范结论

这次排查和改造之后,我对 Web 登录验证码的规范要求基本固定成下面这套:

  1. 验证码必须由后端生成
  2. 验证码答案必须只保存在后端
  3. 前端只展示图片和提交 captchaId
  4. 登录接口必须先校验验证码,再校验账号密码
  5. 验证码必须一次性消费
  6. 成功失败都必须失效
  7. 短期验证码优先放 Redis,而不是依赖本地 session 文件

如果少了其中任何一条,我都不会认为这套验证码是真正“规范落地”的。

小结

一句话总结这次工作:

老服务器后台验证码是 ThinkPHP 后端生成、保存到 Session,但后台登录代码里没有真正校验它;老服务器当前也没有实际运行 Redis。新版 web_3 则已经改成标准的 Redis 验证码流程:后端生成、Redis 保存、前端展示、后端校验、一次性消费、成功失败都删除。

对我来说,这次最重要的收获不是“把验证码改成 Redis”这么简单,而是把验证码这件事从“页面上看起来有”推进到了“后端链路真正闭环”。只有做到这一点,验证码才不是装饰,而是登录安全的一部分。