ThinkPHP5集成滑动验证:从登录防刷到中间件降级实战
1. 从一次登录接口被刷说起为什么滑动验证值得单独折腾去年帮一个做在线教育的小团队处理过一次登录接口被批量刷的问题。对方用的是 ThinkPHP5 搭的后台登录页只有账号密码两个字段没有任何人机校验。结果某个周末接口被脚本跑了十几万次撞库请求虽然密码加密做得还行没被撞开但服务器负载直接飙红正常用户登录全部超时。事后复盘最省事的补救方案就是在登录环节加一道人机验证。滑动验证这类交互式验证核心价值在于用极低的用户操作成本把纯脚本请求挡在业务逻辑之外。它不像传统图形验证码那样需要用户辨认扭曲字符体验上顺滑很多对转化率的影响也小。而 ThinkPHP5 作为国内中小项目里非常常见的一套框架把它和滑动验证对接起来是很多后端同学绕不开的一个具体活儿。这篇内容就是围绕“ThinkPHP5 集成滑动验证”这件事展开的。我会把整个链路拆开讲前端怎么加载验证组件、后端怎么校验、密钥怎么管、失败怎么兜底、上线后怎么观察效果。适合已经能跑通 ThinkPHP5 基础项目、但没做过人机验证对接的后端同学也适合想搞清楚“验证到底该放在哪一层”的开发者。文中涉及的服务端接口调用我会用通用的参数结构来说明具体到你自己项目时把对应的密钥和接口地址替换掉即可。需要先说明一点滑动验证的校验动作必须放在服务端完成前端那点交互只是采集行为数据真正的判定结果由验证服务返回。很多人第一次做会误以为前端滑过了就算通过这是最容易踩的坑后面会专门讲。2. 集成前必须想清楚的三个设计问题动手写代码之前有几个决策点如果没想明白后面返工的概率非常高。我把它们放在最前面是因为这几个问题决定了你整个集成的骨架长什么样。2.1 验证环节放在登录前还是登录后这是第一个要拍板的事。常见做法有两种一种是在用户提交账号密码之前就先做一次滑动验证验证通过了才允许提交登录表单另一种是先提交账号密码服务端在验证账号密码的同时顺带校验滑动票据。第一种做法的好处是能在最外层就把脚本流量挡掉连数据库查询都省了对防刷最有效。缺点是用户多了一步操作如果验证组件加载慢会拖慢首屏体验。第二种做法用户体验更连贯但意味着撞库请求还是会打到你的账号校验逻辑上只是最后被滑动验证拦下数据库压力并没有减少。我的建议是如果你的登录接口已经被刷过或者业务本身对安全性敏感选第一种把验证前置。如果只是想做一层轻量防护、不想改动现有登录流程太多选第二种。下面代码示例我会以第一种为主因为它更能体现“拦截”的价值。2.2 票据是一次性还是可复用滑动验证服务在用户滑动通过后会返回一个凭证通常叫 ticket 或 token。这个凭证拿到服务端去校验时要明确它的生命周期。绝大多数验证服务的凭证都是一次性的校验成功一次之后就失效不能重复使用。这一点直接影响到你的接口设计。如果你把校验接口做成“校验通过后返回一个自定义的 session 标记后续请求靠这个标记放行”那就要保证这个自定义标记本身也有合理的过期时间否则等于把一次性凭证换成了一个长期通行证防护效果大打折扣。我一般会把自定义标记的有效期设得很短比如 5 分钟并且绑定当前会话。2.3 验证失败时的降级策略验证服务本身也可能出问题网络抖动、服务端限流、密钥配置错误。这时候你的登录接口是直接拒绝所有用户还是放行这是个业务取舍。从安全角度验证服务不可用时应该拒绝也就是“fail closed”。但从可用性角度全站登录挂掉是灾难。折中方案是验证服务异常时降级到备用验证方式比如传统图形验证码或者对同一 IP 的请求做频率限制后放行。这个策略要在集成之初就定好不要等出事了才临时加。提示降级逻辑一定要有日志记录否则你根本不知道验证服务到底挂过几次、影响了多少用户。3. 前端验证组件的接入与参数传递前端这块看起来简单但参数传递的细节没处理好后端拿到的数据就是错的。我按实际接入顺序来讲。3.1 引入验证脚本与初始化滑动验证一般会提供一个 JS 脚本通过 script 标签引入。引入之后在需要验证的页面元素上初始化。初始化的核心是绑定一个容器元素并配置验证成功、失败的回调。!-- 登录页表单区域 -- form idloginForm methodpost action/login/doLogin input typetext nameusername placeholder账号 input typepassword namepassword placeholder密码 !-- 滑动验证容器 -- div idsliderCaptcha/div input typehidden namecaptcha_ticket idcaptchaTicket button typesubmit登录/button /form script src验证服务提供的脚本地址/script script // 初始化滑动验证 var captcha initSliderCaptcha({ element: #sliderCaptcha, onSuccess: function (data) { // 把验证凭证写入隐藏字段随表单一起提交 document.getElementById(captchaTicket).value data.ticket; }, onFail: function () { document.getElementById(captchaTicket).value ; } }); /script这里的关键点是验证成功后拿到的凭证要放进一个隐藏的 input 里跟着表单一起 POST 到后端。不要试图用 JS 直接调后端校验接口再决定是否提交那样凭证的传递链路会变复杂而且容易在并发场景下出错。3.2 表单提交前的兜底校验用户可能没滑动就直接点了登录按钮。这时候隐藏字段是空的如果直接提交后端会收到空凭证。虽然后端也应该拦截但前端先拦一道体验更好。document.getElementById(loginForm).addEventListener(submit, function (e) { var ticket document.getElementById(captchaTicket).value; if (!ticket) { e.preventDefault(); alert(请先完成滑动验证); return false; } });这段代码很朴素但能挡掉大部分“手快”的用户。注意 alert 只是示例实际项目里换成页面内的提示更友好。3.3 验证组件加载失败的容错验证脚本是从外部加载的如果加载失败initSliderCaptcha就是 undefined页面会直接报错用户连登录按钮都点不了。所以初始化代码要包一层判断。if (typeof initSliderCaptcha function) { // 正常初始化 } else { // 脚本没加载出来显示提示或降级到备用验证 document.getElementById(sliderCaptcha).innerHTML 验证组件加载失败请刷新重试; }这个细节很多教程不会提但线上环境网络复杂脚本加载失败是真实会发生的。提前处理掉能省掉不少客诉。4. 后端校验接口的完整实现后端是整个集成的核心。ThinkPHP5 的目录结构比较固定我按控制器、服务层、配置三个部分来组织代码。4.1 配置项的统一管理密钥、接口地址这类东西不要硬编码在控制器里。ThinkPHP5 有配置文件机制放在application/extra/目录下新建一个captcha.php。?php // application/extra/captcha.php return [ access_key_id 你的AccessKeyId, access_key_secret 你的AccessKeySecret, verify_url 验证服务提供的校验接口地址, timeout 3, // 请求超时秒数 ];用config(captcha.access_key_id)就能在任意位置读取。这样做的好处是换环境时只改配置不动代码也避免了密钥被提交到代码仓库。4.2 封装一个独立的验证服务类不要把校验逻辑直接写在控制器里。抽一个服务类出来控制器只负责调用和组装响应。这样后面如果要换验证服务商只改这一个类就行。?php // application/common/service/CaptchaService.php namespace app\common\service; class CaptchaService { /** * 校验滑动验证凭证 * param string $ticket 前端传来的凭证 * param string $remoteIp 用户IP部分服务需要 * return bool */ public function verify($ticket, $remoteIp ) { if (empty($ticket)) { return false; } $config config(captcha.); $params [ AccessKeyId $config[access_key_id], AccessKeySecret $config[access_key_secret], Ticket $ticket, RemoteIp $remoteIp, ]; // 发起 HTTP 请求到验证服务 $result $this-httpPost($config[verify_url], $params, $config[timeout]); if ($result false) { // 请求失败记录日志返回 false 走降级逻辑 trace(captcha verify request failed, error); return false; } $data json_decode($result, true); // 不同服务返回结构不同这里以常见的 code 字段为例 return isset($data[code]) $data[code] 200; } private function httpPost($url, $params, $timeout) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); $response curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno) { return false; } return $response; } }这里有几个点值得展开说。第一CURLOPT_TIMEOUT一定要设不设的话验证服务卡住会把你的 PHP 进程也拖住高并发下直接雪崩。第二CURLOPT_SSL_VERIFYPEER保持 true不要为了省事关掉证书校验那等于把请求暴露在中间人风险下。第三请求失败和校验失败要区分开前者是服务不可用后者是凭证无效两者的处理策略不一样。4.3 控制器里的调用与响应控制器拿到表单提交的 ticket调用服务类校验根据结果决定是否继续登录流程。?php // application/index/controller/Login.php namespace app\index\controller; use app\common\service\CaptchaService; use think\Controller; use think\Request; class Login extends Controller { public function doLogin(Request $request) { $username $request-post(username); $password $request-post(password); $ticket $request-post(captcha_ticket); // 第一步校验滑动验证 $captchaService new CaptchaService(); $passed $captchaService-verify($ticket, $request-ip()); if (!$passed) { return json([code 1001, msg 人机验证未通过请重试]); } // 第二步校验账号密码 // ... 这里是你原有的登录逻辑 return json([code 0, msg 登录成功]); } }注意校验顺序先验证滑动再查账号。这样脚本请求在第一步就被挡掉了根本不会走到数据库查询。这就是前面说的“前置验证”的价值所在。4.4 凭证的一次性消费与防重放前面提过凭证是一次性的。但有个细节如果用户网络慢重复点击了登录按钮可能会发出两个携带同一 ticket 的请求。第一个请求校验成功并消费了 ticket第二个请求就会因为 ticket 已失效而失败用户看到的就是“莫名其妙登录失败”。处理办法是在前端提交后立即禁用按钮同时后端对同一 ticket 做短时间的幂等处理。简单做法是用缓存记录已消费的 ticketkey 用 ticket 的哈希过期时间设 60 秒。第二个请求进来时先查缓存如果已存在就直接返回“处理中”而不是报验证失败。$cacheKey captcha_ticket_ . md5($ticket); if (cache($cacheKey)) { return json([code 1002, msg 请求处理中请勿重复提交]); } // 校验通过后写入缓存 cache($cacheKey, 1, 60);这个细节能显著减少用户侧的“玄学失败”反馈。5. 上线后暴露出来的几个真实问题代码写完跑通只是开始真正的问题都是上线后冒出来的。我按遇到频率从高到低排。5.1 密钥泄露与权限过大最常见的问题是密钥直接写在了前端或者提交到了公开仓库。滑动验证的密钥是服务端专用的绝对不能出现在任何前端代码里。我见过有项目把 AccessKeySecret 写进 JS 里等于把大门钥匙挂在了门把手上。另一个问题是密钥权限过大。申请密钥时应该遵循最小权限原则只授予验证接口所需的权限不要图省事用主账号的密钥。一旦泄露影响范围完全不同。5.2 验证服务超时导致的登录雪崩前面代码里设了 3 秒超时但如果验证服务整体响应变慢大量请求会堆积在这 3 秒上。PHP 的进程数是有限的堆积到一定程度整个站点都会无响应。应对办法有两个层面。一是把超时设得更激进比如 1.5 秒验证服务正常响应都在几百毫秒内1.5 秒足够。二是加熔断连续失败 N 次后短时间内直接走降级逻辑不再请求验证服务给它恢复的时间。// 简单的熔断计数 $failKey captcha_fail_count; $failCount cache($failKey) ?: 0; if ($failCount 10) { // 熔断中直接走降级 return $this-fallbackVerify(); }5.3 移动端与桌面端的兼容差异滑动验证在桌面浏览器上表现稳定但在某些移动端浏览器里触摸事件和鼠标事件的差异会导致滑动轨迹采集异常用户明明滑对了却提示失败。这个问题在老旧安卓机上尤其明显。排查这类问题的思路是先确认是前端采集问题还是后端校验问题。可以在 onFail 回调里把失败原因打点到日志看是“轨迹异常”还是“凭证无效”。如果是轨迹异常通常是前端组件版本和浏览器兼容性问题升级组件或调整初始化参数能解决。5.4 日志里看不到有效信息很多项目集成完就完事了验证失败率多少、失败原因分布如何一概不知。等用户投诉了才去查发现日志里只有一句“验证失败”什么上下文都没有。我的做法是在校验服务类里把关键信息都记下来ticket 的前几位不要记全避免泄露、用户 IP、请求耗时、验证服务返回的原始 code。这样出问题时能快速定位是网络问题、密钥问题还是用户操作问题。记录字段用途注意事项ticket 前 8 位关联具体请求不要记录完整 ticket用户 IP判断是否集中失败注意隐私合规请求耗时发现服务变慢超过 1 秒要告警返回 code区分失败类型不同 code 含义要整理成文档6. 把验证做成可复用的中间件如果项目里不止登录一个地方需要人机验证比如注册、找回密码、发短信接口那每个控制器都写一遍校验代码就很蠢。ThinkPHP5 支持中间件把验证逻辑抽成中间件是更合理的做法。6.1 中间件的编写?php // application/http/middleware/CaptchaCheck.php namespace app\http\middleware; use app\common\service\CaptchaService; class CaptchaCheck { public function handle($request, \Closure $next) { $ticket $request-post(captcha_ticket); $service new CaptchaService(); if (!$service-verify($ticket, $request-ip())) { return json([code 1001, msg 人机验证未通过]); } return $next($request); } }然后在需要验证的路由上挂载这个中间件。这样控制器里就完全不用关心验证逻辑了职责更清晰。6.2 中间件的粒度控制不是所有接口都需要验证。比如登录后的普通业务接口就不需要只有那些“匿名用户可访问且可能被滥用”的接口才需要。所以中间件要能按路由灵活挂载而不是全局注册。ThinkPHP5 里可以在路由定义时指定中间件Route::post(login/doLogin, index/Login/doLogin)-middleware(CaptchaCheck); Route::post(register/doRegister, index/Register/doRegister)-middleware(CaptchaCheck);这样只有这两个接口走验证其他接口不受影响。6.3 中间件里的降级开关中间件里还应该留一个配置开关方便在验证服务出问题时一键关闭验证应急用。这个开关要放在配置里改完立即生效不需要发版。if (!config(captcha.enable)) { return $next($request); }注意这个开关是应急用的平时必须保持开启。关闭期间要有监控告警提醒尽快恢复。7. 关于验证效果的一些实测观察集成完成之后我持续观察了一段时间的拦截数据有几个发现值得分享。第一滑动验证对纯脚本的拦截率非常高基本能挡掉九成以上的自动化请求。但对手工打码平台的效果有限因为打码平台背后是真人操作。所以滑动验证不是万能的它解决的是“低成本批量请求”问题不是“所有恶意请求”问题。第二验证失败率在移动端明显高于桌面端差距大概在两到三倍。除了前面说的兼容性问题还有一个原因是移动端用户操作更随意滑动轨迹不规范。如果你的用户主要是移动端建议把验证的容错参数调宽松一些。第三验证组件的加载时间对首屏影响不可忽视。如果登录页本身很轻验证脚本可能占到总加载时间的一半以上。可以考虑延迟加载等页面主体渲染完再加载验证脚本。第四把验证和频率限制结合使用效果更好。滑动验证挡掉脚本频率限制挡掉漏网之鱼。两者叠加防护效果比单独用任何一个都强。// 简单的频率限制示例 $ipKey login_freq_ . md5($request-ip()); $count cache($ipKey) ?: 0; if ($count 20) { return json([code 1003, msg 操作过于频繁请稍后再试]); } cache($ipKey, $count 1, 60);这段逻辑可以放在滑动验证之前先做一层粗筛减少验证服务的请求量。8. 几个容易被忽略的收尾工作集成做完还有几件收尾的事如果漏了后面会很难受。一是密钥轮换。密钥用久了要定期更换更换时要有平滑过渡方案不能直接换掉导致线上全挂。通常验证服务支持同时配置新旧两个密钥过渡期结束后再停用旧的。二是监控告警。验证失败率、验证服务响应时间、熔断触发次数这几个指标都要有监控。失败率突然升高或者响应时间变长都要能及时收到告警。三是文档沉淀。把密钥位置、配置项含义、降级开关位置、常见问题排查步骤写成文档。不然过几个月你自己都忘了当时怎么设计的更别说交接给其他人。四是回归测试。每次改动登录相关代码都要把滑动验证的完整链路测一遍正常滑动通过、不滑动直接提交、重复提交、验证服务超时。这几个场景覆盖了绝大多数线上问题。我在实际项目里踩过最深的坑是验证服务的一次区域性故障。当时没有熔断机制所有登录请求都在等验证服务超时三秒超时叠加起来直接把 PHP 进程池占满整个站点跟着挂了。后来加了熔断和降级再遇到类似情况登录接口能在几百毫秒内切换到备用逻辑用户几乎无感知。这件事让我意识到任何外部依赖都要假设它会挂提前想好挂了怎么办比祈祷它不挂靠谱得多。