先交代一个真实场景:我手上维护的两个老系统,一个已经跑了九年,前端页面还是后端模板渲染,交互全靠 jQuery;另一个更保守,连构建工具都没有,静态资源直接裸挂在服务器上。这类项目在很多人眼里已经和大厂的新安全方案“绝缘”了。但最近我把天爱验证码(TAC)成功接进了其中一个系统,前后花了两天,踩了一堆不大不小的坑。这篇不是什么官方文档,而是一个在旧代码堆里摸爬滚打的人,把接入 TAC 验证码的完整思路、实操过程、翻车记录整理出来。
先给没听过 TAC 的朋友定位一下:TAC 是一套开源、可私有化部署的验证码解决方案,支持滑块、点选、行为校验等常见形态,核心设计是“前端发起验证,生成令牌;后端拿着令牌做二次校验”。它的集成方式和传统项目最契合的一点,是前后端模块可以拆开部署,前端只需要一段脚本,后端只需要一个校验函数,不需要强行引入前端工程化体系。
这篇文章适合三类人:一是手上有老系统、想给登录或表单加上防护但是不敢大改的开发者;二是从“验证码就是一张图”的认知过渡到“验证码要前后端配合”的中级开发;三是想评估开源验证码组件是否适合自己项目的人。我会把接入过程中最容易出问题的跨域、会话、回调、部署路径这些细节,挨个讲清楚,并提供可以直接参考的接入模板。
1. 接入前的思路拆解与方案选型
1.1 TAC 验证码解决的到底是什么问题
先说两个常识判断。第一,验证码的本质不是“展示一张难认的图”,而是“设立一道对用户透明、对机器不透明的门槛”。第二,传统图形验证码在今天已经很难完成任务,因为爬虫可以用光学字符识别或打码平台兜底,成本极低,效果却比人工识别还好。
TAC 这类方案想解决的问题,本质上是在传统验证码的基础上增加两层东西:一是把“识别文字”的单一挑战,变成“行为轨迹 + 视觉识别 + 服务端随机性”的复合挑战;二是把校验逻辑从“前端随便跳”收回到“后端二次校验”。前者提升破解成本,后者堵住校验绕过的漏洞。
落到传统项目上,这个思路其实很有价值。很多老系统最大的安全缺口并不在复杂的业务逻辑,而在登录、注册、抢购、表单提交这些“高频低障碍”入口。你不需要给整个系统增加复杂组件,只要在这些入口上加一层验证码闭环,就能挡住大概率的基础自动化攻击。
1.2 传统项目对验证码接入有哪些硬约束
我接触的传统项目有几条共性约束,这里列一下是为了让后来的朋友先自我排查:
- 无前端工程化:没有 npm、没有打包器、没有组件化体系,页面是纯静态资源或者由后端模板输出。
- 存量代码不可大动:公共头部、公共底部大概率牵一发动全身,改一轮要回归大半年。
- 浏览器兼容要求苛刻:有些场景甚至要求兼容老式浏览器,所以新框架很难直接落上去。
- 后端技术栈五花八门:Java、PHP、Node、.NET 都有,不能指望验证码组件只适配一种语言。
- 安全策略相对保守:生产环境往往在内网,外网访问路径经过多层代理。
在这些约束下,主流验证码服务虽然开箱即用,但往往要求引入大型脚本、开放域名白名单、依赖第三方接口——这对传统项目来说,改造面太大,风险不好控制。TAC 这种开源私有化部署方案的天然优势就体现出来了:部署位置在你自己的服务器上,前端脚本体积可控,后端校验逻辑可以改造成适配老代码的样子。
1.3 选型之前先回答三个必答题
我在动手前,强制先回答三个问题,并写到了设计文档里:
部署形态:本地私有化部署,还是使用公共云端服务?我的建议是,传统项目优先本地部署。原因很简单,验证码校验属于安全链路的关键环节,如果依赖外部服务,一旦对方抖动或者网络波动,你的登录功能会跟着一起抖。
交互形态:选滑块、点选还是行为校验?这本质上是“用户体验”与“脚本破解成本”的权衡。滑块对用户最友好,但基于轨迹模拟的机器攻击也能破解;点选让用户多一步操作,却能让大部分简单脚本直接失效;行为校验最隐形,不过对老浏览器和弱网络环境有时不够稳定。
集成边界:验证码触发点放在哪里?是在登录前就校验,还是登录按钮点击后才校验?请求要不要携带用户身份?验证失败之后是原地重试还是重放整个流程?这些问题必须提前定义清楚,否则联调阶段会来回改接口。
这三道题想明白,基本能确定 80% 的接入方案,剩下的就是填坑。
2. 选型与准备:接入 TAC 之前的完整检查清单
2.1 部署形态与系统边界
TAC 的典型部署形态是“前端组件静态资源 + 后端验证服务”两个模块。前端模块负责渲染验证码界面、收集用户行为数据;后端模块负责生成验证码题目、存储验证状态、提供校验接口。两者可以部署在同一域名下,也可以分开部署,但要考虑跨域。
我强烈建议在传统项目中把两个模块放到同域部署。原因是这类项目普遍没有统一的跨域解决方案,线上环境多半是 Nginx 转发或者直接裸跑。如果前端资源在 A 域名、后端校验在 B 域名,光是配置跨域就够你折腾半天,而且很容易留下安全问题。
系统边界上,还要明确验证码模块和业务模块的边界。TAC 本身不保存用户业务数据,它只记录与某一次验证请求相关的状态。因此业务系统需要在自己的会话里保存“本次验证是否通过”的结果,而不是在验证码模块里存储用户信息。
2.2 兼容性盘点的四个维度
老项目接入新组件最怕的就是“老牛拉新车”,所以我列了一张简单的检查单,接入前逐项过一遍:
| 检查维度 | 具体问题 | 判断标准 |
|---|---|---|
| 浏览器兼容 | 目标用户使用的浏览器版本范围 | TAC 前端交互是否支持最低版本 |
| 网络环境 | 是否存在内网部署、代理转发、限速 | 静态资源大小与加载方式是否可接受 |
| 会话机制 | 使用的会话方式是 Cookie、自定义 Header 还是 Token | 校验结果如何回写会话 |
| 部署路径 | 静态资源的部署路径是否被后端框架接管 | 能否直接通过 URL 访问组件脚本 |
前三项大家基本会检查,第四项最容易忽略。很多老系统的资源目录和业务代码耦合在一起,你直接把 TAC 的静态目录丢进去,结果发现路径被路由拦截,返回的是首页 HTML。这点在后面实操部分会细说。
2.3 前后端职责划分与校验闭环
接入 TAC 最核心的概念是“校验闭环”。我用文字描述这个数据流:前端点击校验,调用 TAC 前端脚本,用户完成交互,前端拿到一个临时的验证令牌,随后把验证令牌随业务请求发给后端;后端调用 TAC 校验接口,确认令牌有效且属于该会话,通过后才继续业务逻辑。
这个闭环里有三个最容易断裂的位置:前端拿到令牌后没有把它带到业务请求里;后端的校验接口在验证令牌后没有和当前会话绑定;以及验证结果没有做“一次性消费”,导致令牌可以被反复提交。每个位置都是一个坑,我在第 4 部分会展开讲。
3. 实操落地:传统 Web 项目中接入 TAC 的完整过程
3.1 后端接入:生成验证会话与校验接口
我以传统项目最常见的 PHP 后端为例,说一下后端模块的骨架。第一件事是在业务服务器上部署 TAC 后端服务,一般是一个独立的进程或者一个路由模块。部署完成后,我们需要在业务代码中新增两个动作:生成验证码会话、校验验证令牌。
先看生成验证码会话的伪代码:
// 生成验证码会话 public function captchaStart() { // 调用 TAC 后端服务生成题目与图形资源 $captchaId = tacServer()->createCaptcha([ 'type' => 'slide', // 滑块模式 'expire' => 120, // 120 秒过期 'client_ip' => getClientIp(), ]); // 把验证码 ID 放入当前会话,后续校验必须匹配 $_SESSION['tac_captcha_id'] = $captchaId; // 返回给前端的是 captchaId 与题图 URL return json([ 'captcha_id' => $captchaId, 'captcha_resource' => buildResourceUrl($captchaId), ]); }接着是校验接口,核心逻辑是先查本地会话里有没有对应的验证码 ID,再调 TAC 后端确认令牌有效:
public function captchaVerify() { $token = $_POST['tac_token']; // 从会话取出之前的 captchaId $captchaId = $_SESSION['tac_captcha_id'] ?? null; if (!$captchaId) { return fail('验证会话已过期,请重新验证'); } // 调用 TAC 校验接口,需要同时传 captchaId 和 token $result = tacServer()->verifyToken([ 'captcha_id' => $captchaId, 'token' => $token, ]); if ($result->valid) { // 一次性消费:立即销毁会话中的验证码 ID,防止重放 unset($_SESSION['tac_captcha_id']); return success('验证通过'); } return fail('验证失败,请重试'); }这段代码的核心思路是三步:生成时绑定会话、校验时同时核对 ID 与令牌、通过后销毁状态。销毁那一步很多人会漏,但不做的话,攻击者只要拿到一个有效令牌就能反复提交,等于验证码形同虚设。
3.2 前端接入:兼容老页面的组件嵌入方式
前端的接入要尽量少动公共组件。我的做法是,不在公共头部全局初始化 TAC,而是在目标页面按需加载。假设目标页面是登录页,用 jQuery 的老式写法,前端片段大概是这样的:
<div id="tac-container"></div> <button id="btnLogin">登录</button> <script src="/static/tac/sdk.js"></script> <script> $(function () { // 初始化验证码组件 var tac = new TacCaptcha({ container: '#tac-container', mode: 'slide', language: 'zh-CN', onSuccess: function (token) { // 校验通过后,把 token 存到全局变量,等待登录提交 window.__tacToken = token; }, onError: function (err) { console.error('TAC 验证异常', err); } }); $('#btnLogin').on('click', function () { var token = window.__tacToken; if (!token) { alert('请先完成验证'); return; } // 业务请求带上 token $.post('/login', { username: $('#username').val(), password: $('#password').val(), tac_token: token }, function (res) { // 业务逻辑 }); }); }); </script>这段代码有两点刻意为之的地方:一是把 token 存在全局对象上而不是放在表单隐藏域,这样能避免老页面提交时把隐藏域值覆盖掉;二是验证成功后才允许按钮提交,而不是在点击按钮时才初始化验证码,降低并发初始化带来的异常概率。
还有一点值得提醒:老系统的页面往往有多个表单,比如登录框、短信找回、注册框放在同一个页面。TAC 组件在同一时间只能服务一个容器,所以最好一个页面只展示一个需要验证的入口。如果确实有多个入口,就要在切换时显式销毁并重建组件,否则会出现验证码容器绑定错乱。
3.3 无构建工具链下的加载与降级方案
老项目没有前端构建工具,静态资源的管理基本靠“目录 + 版本号”。我的建议是把 TAC 的静态文件放到独立的资源目录,并采用带有版本号的路径,例如/static/tac/1.2.0/sdk.js。这样可以避免浏览器缓存旧脚本造成更新后行为不一致。
还需要考虑一种特殊情况:用户网络环境差,验证码脚本加载失败。这种场景下不能让整个登录功能挂掉,否则业务投诉会压过来。降级方案的设计原则是“验证码加载失败时,业务功能照常可用,但要有日志记录”。代码如下:
<script src="/static/tac/1.2.0/sdk.js" onerror="window.__tacLoadFailed=true"></script>后端在收到登录请求时,如果识别到验证码脚本加载失败,可以走一个临时策略,比如先放行但记录风险标记,让用户不会卡死在提交页面上。这是传统项目接入验证码时比较体面的妥协方式。
3.4 联调验证:从拿到令牌到完成校验的完整链路
联调阶段我用的是一个简单的调试面板来验证闭环。步骤是这样的:
- 打开登录页,先不操作验证码,直接点登录,确认前端会拦截并提示先完成验证。
- 完成一次验证,在浏览器网络面板里找到 TAC 前端发出的校验请求,观察返回的 token 结构。
- 提交登录请求,在后端日志里确认校验接口被调用,且返回了“验证通过”。
- 连续提交两次同样的登录请求,确认第二次会被拒绝,因为令牌已经一次性消费。
- 用手动修改 token 的方式模拟攻击,确认后端能识别无效 token 并拒绝请求。
- 把验证码会话过期时间调短,测试 120 秒之后重新验证是否会正常刷新。
这六步基本覆盖了正常路径和几个异常路径。我每次接完成都会跑一遍,能省掉大量上线后的偶发问题。
4. 我踩过的坑:TAC 接入中的典型问题与排查实录
4.1 坑一:跨域回调把整条链路带崩
第一次接入时,我把 TAC 的后端服务部署到了独立域名,前端页面在主域名,两者不是同源。单独测试前端组件没问题,单独调用后端接口也没问题,但一走到完整业务链路就发现验证码组件拿不到回调数据。排查下来是跨域问题:前端组件发出的校验请求被浏览器拦截,token 没有回到业务页面。
我当时采用的方案有两个选项:一是给后端接口加上允许跨域响应的请求头,二是不做跨域,直接把前后端模块部署到同一域名下。最后我选了后者,因为加跨域头虽然省事,但会让安全策略变复杂,传统项目的前端页面通常也没有完善的凭证管理模式。把 TAC 的前端资源和后端服务都放到主域名下,用一个反向代理路径转发,问题直接消失。部署路径大概是/tac开头的接口和/static/tac/开头的静态资源,都走同一个源。
提示:传统项目接入任何安全组件,第一优先级是“同源部署”。跨域部署虽然技术上成立,但在老代码里排查成本高,收益却很有限。
4.2 坑二:验证码会话过期与令牌重放
第二坑出现在联调第 4 步。我原本只在验证码后端存了令牌,业务侧没有做“一次性消费”。结果同一个登录请求被脚本连续重放,验证码形同虚设。这个问题本质上是闭环设计里“会话绑定”和“一次性消费”两关都没守住。
修复办法就是第 3 部分那段代码里的unset($_SESSION['tac_captcha_id'])。同时我还在业务表里增加了一个字段,记录此次登录操作是否已经消费过验证令牌。只靠后端验证码模块判空不够,因为业务请求可能绕过验证码模块,直接带着同一个 token 再次提交业务接口,所以业务侧也要做幂等判断。
4.3 坑三:老系统全局模板干扰组件渲染
第三个坑比较隐蔽。我们的公共模板有一个全局样式,把所有div的边距做了统一重置,导致 TAC 验证码组件渲染出来的滑块条高度被压缩,鼠标拖拽区域错位。光看前端控制台没有任何报错,但功能就是不对劲。
这种问题的排查思路是:不要一上来就怀疑脚本,先用浏览器开发者工具检查组件渲染区域的实际样式。我后来在接入文档里加了一条规范——所有接入 TAC 的页面,必须在容器上叠加一层带专属类名的样式重置,避免被全局样式污染:
.tac-box, .tac-box * { box-sizing: border-box; margin: 0; padding: 0; }4.4 常见问题与排查定位速查表
| 症状 | 可能原因 | 排查思路 | 解决参考 |
|---|---|---|---|
| 验证码组件不渲染 | 脚本未加载或路径被路由接管 | 检查网络面板确认 SDK 是否 200 返回 | 调整静态目录部署路径 |
| 点击验证没反应 | 跨域导致回调丢失 | 查看浏览器控制台是否有跨域报错 | 改成同源部署或用代理转发 |
| 登录请求被拒但验证通过了 | 校验与业务会话未绑定 | 检查后端是否读取了会话中的 captchaId | 校验时同时核对会话与令牌 |
| 同一个 token 可以反复提交 | 令牌未一次性消费 | 检查业务侧是否做幂等判断 | 验证通过后销毁会话或写消费标记 |
| 老浏览器下样式错乱 | 全局样式污染组件容器 | 检查渲染 DOM 的最终计算样式 | 为容器增加隔离的样式重置 |
| 验证码频繁过期 | 会话过期时间设置过短 | 检查前端页面提交耗时与会话时间 | 按业务场景调整过期时间,建议 90~180 秒 |
4.5 避坑技巧总结
最后总结几条我实际总结出的经验,常规文档不会写:
- 验证码会话的过期时间不要固定用默认值。登录页可能因为用户输入慢而耗时较长,如果过期时间太短,用户刚输完密码就过期,体验很差。我一般设 120 秒,注册流程设 180 秒。
- 前端拿到 token 之后,不要立刻清空验证状态,等业务请求返回成功后再重置组件。否则用户输错一次密码,验证码就白做一遍。
- 在传统项目里,接入安全组件一定要留“逃生门”,也就是验证码脚本加载失败或后端服务暂时不可用时,业务宁可放行一部分风险请求,也不能整体瘫痪。这条经验来自一次线上故障的教训。
5. 最后说点接地气的建议
作为一个常年维护传统项目的开发,我对“开源免费”这四个字其实非常警惕,因为免费通常意味着文档缺失、社区靠自发维护、坑要自己填。TAC 这套验证码好在思路简单,前后端边界清楚,所以我愿意把时间花在填坑上。
我个人实测下来的体会是,老项目接入验证码组件,真正难的从来不是技术,而是对整个系统边界的理解。前端不过是多了一段脚本,后端不过是多了两个接口,但你要想清楚验证状态放在哪、校验结果怎么回写、失败时的降级策略是什么。这些问题没有答案,再轻量的组件接进去也是负担。
最后再分享一个小技巧:如果你也准备在传统项目里接入类似的开源验证码组件,先别急着写业务代码,花半小时做一次最小化冒烟验证——单独部署前后端、用最简单的页面把完整链路跑通,再回到老系统里做集成。这一步能让你把“组件本身的问题”和“老系统的问题”彻底分开,少走弯路。以上就是我这次完整填坑记录,希望它能帮你省下至少一个通宵。