简介这份资源是面向微信小程序开发者与前端学习者的滑块验证功能实现代码包针对刷票、恶意注册等自动化风险场景提供一套可参考的人机校验方案。包内共29个文件以jpg与png图片素材为主用于拼图背景、滑块状态与提示图标另有wxml与wxss负责页面结构与样式js与json承担滑动事件监听、位置比对及页面配置压缩包约118KB体量轻便便于直接嵌入现有项目调试。内容围绕前端交互与后端校验两条主线展开涉及滑块布局、拖动进度反馈、服务端位置匹配以及时间戳、随机数、有效期等防重放思路并兼顾动画过渡与无障碍辅助设计。目前已有1452人学习适合希望快速理解滑块验证完整链路、补齐安全校验经验的开发者参考借鉴。1. 滑块验证在小程序里到底难在哪微信小程序滑块验证代码看起来只是「拖一下、对一下位置」的小交互真落到项目里翻车点比想象中多。小程序没有 DOM拿不到浏览器里那套getBoundingClientRect、requestAnimationFrame的顺手体验canvas是原生组件层级、触摸事件、真机与开发者工具的差异都会让一个在模拟器里跑得挺顺的滑块到真机上变成「拖不动」「对不准」「一松手就复位」。更现实的是滑块验证本身是安全能力不是 UI 装饰位置校验放在前端等于没校验必须有一次服务端参与。这篇笔记面向两类人一类是刚接手小程序登录、注册、领券、提交表单等场景需要加一道人机校验的开发者另一类是做过后端风控想搞清楚小程序端滑块到底能拿到哪些信号、哪些信号不可信的人。我会按「先讲清滑块验证的组成和选型再给一套能跑通的前后端最小实现最后把真机踩过的坑摊开」的顺序写。代码用 JavaScript 和 Node.js 风格示例思路换成任何后端语言都能平移。2. 滑块验证的组成与选型别一上来就写拖拽2.1 一次完整滑块验证到底包含哪几块很多人以为滑块验证就是「一张背景图 一个缺口 一个滑块」其实它至少由四块组成缺一块都会在某个环节出问题。第一块是挑战生成。服务端生成一个随机缺口位置把缺口坐标、挑战 ID、过期时间存起来返回给前端的是背景图、滑块图和一个 challengeId。缺口坐标绝对不能直接下发到前端否则抓包就能拿到答案。第二块是前端交互。用户按住滑块横向拖动前端实时更新滑块位置同时采集拖动过程中的轨迹点时间戳、x 坐标、y 坐标。这些轨迹点是后续风控的原料不是用来算答案的。第三块是提交校验。用户松手后前端把 challengeId 和轨迹数据提交给服务端服务端比对最终 x 坐标与存储的缺口位置允许一定像素误差。第四块是风控判定。除了位置对不对还要看轨迹像不像人总耗时、加速度变化、是否有停顿、y 轴抖动是否合理。位置对但轨迹是匀速直线的大概率是脚本。这四块里前端只负责采集和展示判定权全部在服务端。常见做法是位置校验和轨迹校验分开打分位置是硬门槛轨迹是软门槛。2.2 为什么不用纯前端校验也不建议直接上行为验证 SDK纯前端校验的问题很直接小程序代码包可以被反编译wx.request的请求可以被拦截重放。你把缺口坐标写在前端等于把答案贴在门上。所以任何「只在前端比对」的方案都不算验证只能算交互演示。那为什么不直接接第三方行为验证 SDK对于有合规要求、预算充足、且不想自己维护风控规则的团队接成熟 SDK 是合理选择。但很多中小项目的情况是只需要一道轻量人机校验不想引入外部依赖、不想处理跨域和域名白名单、不想为每次验证付费。这时候自建一套滑块验证代码更可控代价是要自己处理轨迹采集和服务端判定。选型上我一般按三个维度判断验证强度是否防脚本、是否防重放、接入成本前端改动量、后端存储要求、可维护性挑战过期、并发、日志。滑块验证在强度上属于中等能挡住大部分批量脚本挡不住针对性逆向如果你的场景是高频敏感操作滑块应该和短信验证码、设备指纹叠加使用而不是单独扛。2.3 挑战数据的存储与过期策略挑战数据存哪里直接决定这套代码能不能上生产。最省事的是存内存 Map但多实例部署就废了请求打到另一台机器上找不到 challengeId。常见做法是存 Rediskey 用 challengeIdvalue 存缺口 x 坐标、创建时间、已尝试次数设置 60 到 120 秒过期。过期时间不能太长否则挑战可以被反复利用也不能太短用户网络慢一点就过期了体验很差。我一般设 90 秒同时限制同一个 challengeId 最多校验 3 次超过就作废并要求重新获取。这样即使有人拿到 challengeId 想暴力试坐标试错成本也会迅速上升。下面是一个挑战生成和存储的最小实现用 Redis 做示例换成其他 KV 存储逻辑一致。// 服务端生成滑块挑战 const Redis require(ioredis); const redis new Redis(); // 生成指定范围内的随机整数 function randomInt(min, max) { return Math.floor(Math.random() * (max - min 1)) min; } async function createChallenge() { const challengeId slider_${Date.now()}_${randomInt(100000, 999999)}; // 缺口 x 坐标假设画布宽 300缺口宽 50留出边距 const targetX randomInt(60, 240); const payload { targetX, createdAt: Date.now(), attempts: 0 }; // 90 秒过期存字符串读取时 JSON.parse await redis.set(challengeId, JSON.stringify(payload), EX, 90); return { challengeId, // 注意这里只返回图片地址绝不返回 targetX bgUrl: /api/slider/bg?cid${challengeId}, sliderUrl: /static/slider-block.png }; }这段代码的关键点有三个。targetX的取值范围要结合画布宽度和缺口宽度算留边距是为了避免缺口贴边导致用户拖不到。EX 90是过期时间单位秒。返回体里只有 challengeId 和图片地址targetX留在服务端这是整个方案的安全底线。提示challengeId 不要用自增 ID 或可预测的格式否则攻击者可以枚举有效挑战。加时间戳和随机数是最低要求。3. 前端拖拽实现触摸事件、轨迹采集与提交3.1 用 touchstart/touchmove/touchend 接管滑块小程序里做拖拽核心是三个触摸事件。touchstart记录起点touchmove计算位移并更新滑块位置touchend触发提交。这里有个容易忽略的点touchmove的触发频率在真机上和开发者工具里不一样不能假设每次移动都均匀采样轨迹点要带时间戳。滑块的可移动范围要限制在 0 到最大位移之间超出要 clamp否则用户拖出画布后松手提交的 x 坐标会是个离谱的值。另外拖动过程中要禁用页面滚动否则手指在滑块上竖向移动时页面会跟着滚体验很糟。小程序里可以在滑块区域用catchtouchmove而不是bindtouchmove来阻止冒泡。// 小程序页面滑块拖拽逻辑 Page({ data: { sliderLeft: 0, // 滑块当前 left 值 maxLeft: 0, // 最大可拖动距离 challengeId: , tracks: [] // 轨迹点数组 }, onLoad() { // 假设画布宽 300滑块宽 50最大位移 250 this.setData({ maxLeft: 250 }); this.fetchChallenge(); }, async fetchChallenge() { const res await wx.request({ url: /api/slider/challenge }); this.setData({ challengeId: res.data.challengeId }); }, onTouchStart(e) { // 记录起始 x 和起始时间 this.startX e.touches[0].clientX; this.startTime Date.now(); this.setData({ tracks: [] }); }, onTouchMove(e) { const moveX e.touches[0].clientX; let left moveX - this.startX; // 边界 clamp防止拖出范围 left Math.max(0, Math.min(left, this.data.maxLeft)); // 采集轨迹点相对时间、x、y const point { t: Date.now() - this.startTime, x: left, y: e.touches[0].clientY }; this.setData({ sliderLeft: left, tracks: this.data.tracks.concat(point) }); }, onTouchEnd() { // 松手后提交带上 challengeId 和轨迹 wx.request({ url: /api/slider/verify, method: POST, data: { challengeId: this.data.challengeId, finalX: this.data.sliderLeft, tracks: this.data.tracks }, success: (res) { if (res.data.pass) { // 校验通过继续业务逻辑 } else { // 失败重置滑块并重新获取挑战 this.setData({ sliderLeft: 0 }); this.fetchChallenge(); } } }); } });onTouchMove里做了两件事更新视觉位置和采集轨迹。轨迹点用相对时间t而不是绝对时间服务端判定时不受客户端时钟影响。finalX是松手时的位置服务端拿它和targetX比对。注意tracks用concat会产生新数组拖动频繁时有一定性能开销真机上如果卡顿可以改成 push 后手动setData指定路径或者降低采样频率。3.2 轨迹数据怎么采才有判定价值轨迹不是越多越好关键是有没有区分度。脚本拖动的典型特征是时间间隔均匀、x 匀速递增、y 几乎不变。真人拖动的特征是起步慢、中间快、结尾有微调y 轴有轻微抖动偶尔有停顿。采集时至少要保留t、x、y三个字段。如果只采 x 不采 y就没法判断是不是水平直线。如果只采位置不采时间就没法算速度。采样频率上touchmove本身在真机上大概 60Hz 左右直接全采就够用不需要额外定时器。有个细节touchmove在快速拖动时可能丢点导致轨迹看起来有跳跃。这不是大问题服务端判定时看的是整体趋势不是逐点连续性。但如果你的判定规则对时间间隔要求很严就要在服务端做一次重采样或插值。3.3 提交校验的请求体设计与防重放提交时除了 challengeId、finalX、tracks建议再加一个客户端生成的nonce和timestamp。服务端校验 timestamp 与服务器时间差不超过 2 分钟nonce 在有效期内只能用一次。这样即使请求被截获重放也会因为 nonce 已用或时间过期而失败。请求体不要用 GET轨迹数据可能几百个点URL 长度会爆。用 POSTcontent-type用application/json。小程序里wx.request默认是application/json不用额外设置。注意不要把targetX通过任何接口下发给前端包括图片接口的 query 参数。有些实现为了前端画缺口把坐标放在图片 URL 里这是典型的自欺欺人。4. 服务端校验位置比对与轨迹风控怎么写4.1 位置校验的误差容忍与尝试次数位置校验是最直接的一关。服务端取出存储的targetX和提交的finalX比对差值在容忍范围内就算位置通过。容忍范围一般设 5 到 10 像素太小了真人很难对准太大了脚本容易蒙混。我一般设 8 像素配合前端滑块和缺口的视觉对齐。尝试次数必须限制。同一个 challengeId 每失败一次attempts加一超过 3 次直接删除挑战返回「请重新获取验证」。这样暴力试坐标的成本是每次都要重新拿挑战而重新拿挑战又需要新的 challengeId形成闭环。// 服务端校验滑块结果 async function verifySlider({ challengeId, finalX, tracks }) { const raw await redis.get(challengeId); if (!raw) { return { pass: false, reason: challenge_expired }; } const payload JSON.parse(raw); if (payload.attempts 3) { await redis.del(challengeId); return { pass: false, reason: too_many_attempts }; } // 位置校验容忍 8 像素 const positionPass Math.abs(finalX - payload.targetX) 8; // 轨迹风控 const trackPass checkTracks(tracks); // 无论成败都增加尝试次数 payload.attempts 1; await redis.set(challengeId, JSON.stringify(payload), EX, 90); if (positionPass trackPass) { await redis.del(challengeId); // 通过后立即作废防重放 return { pass: true }; } return { pass: false, reason: positionPass ? track_suspicious : position_mismatch }; }这段代码里positionPass和trackPass是分开的方便日志排查到底是位置不对还是轨迹可疑。通过后立即del挑战保证一个挑战只能用一次。失败时保留挑战但增加 attempts让用户还有机会重试但次数有限。4.2 轨迹风控的三个可落地指标轨迹风控不需要上机器学习三个统计指标就能过滤掉大部分低级脚本。第一个是总耗时。真人拖动 250 像素通常需要 400 到 1500 毫秒。低于 300 毫秒的基本是脚本瞬间完成高于 5000 毫秒的可能是挂机或异常。这个区间不是绝对的但可以作为第一层过滤。第二个是x 方向的速度变化。计算相邻轨迹点的dx/dt看速度是否有波动。真人拖动速度是先增后减脚本往往是匀速。可以用速度的标准差除以平均速度得到一个变异系数低于某个阈值就判定为过于匀速。第三个是y 轴抖动。真人手指拖动时 y 坐标会有几像素的抖动脚本通常 y 恒定。统计 y 的标准差如果接近 0可疑。// 轨迹风控三个统计指标 function checkTracks(tracks) { if (!tracks || tracks.length 5) return false; const totalTime tracks[tracks.length - 1].t - tracks[0].t; // 耗时过滤300ms 到 5000ms if (totalTime 300 || totalTime 5000) return false; // 计算 x 方向速度序列 const speeds []; for (let i 1; i tracks.length; i) { const dt tracks[i].t - tracks[i - 1].t; if (dt 0) continue; speeds.push((tracks[i].x - tracks[i - 1].x) / dt); } if (speeds.length 3) return false; const avgSpeed speeds.reduce((a, b) a b, 0) / speeds.length; const variance speeds.reduce((a, b) a Math.pow(b - avgSpeed, 2), 0) / speeds.length; const cv Math.sqrt(variance) / (avgSpeed || 1); // 变异系数过低说明过于匀速 if (cv 0.15) return false; // y 轴抖动检测 const ys tracks.map(p p.y); const avgY ys.reduce((a, b) a b, 0) / ys.length; const yVar ys.reduce((a, b) a Math.pow(b - avgY, 2), 0) / ys.length; if (Math.sqrt(yVar) 0.5) return false; return true; }这三个阈值需要根据你的实际用户群体调。太严会误伤真人太松会放过脚本。建议上线初期把判定结果和轨迹都打日志观察一周再收紧。变异系数 0.15 和 y 标准差 0.5 是起步值不是标准答案。4.3 把校验结果和业务动作绑定滑块验证通过后不能只返回一个pass: true就完事。正确做法是服务端在验证通过时签发一个一次性的verifyToken业务接口凭这个 token 执行敏感操作。token 存 Redis设置很短的有效期比如 60 秒用一次就删。这样做的原因是如果只返回 pass前端可以伪造这个响应或者把 pass 状态缓存下来反复用。有了 verifyToken业务接口必须拿真实 token 来换而 token 只有服务端验证通过才会签发链路就闭合了。// 验证通过后签发一次性 token async function issueVerifyToken() { const token vt_${Date.now()}_${Math.random().toString(36).slice(2)}; await redis.set(token, 1, EX, 60); return token; } // 业务接口校验 token async function consumeVerifyToken(token) { if (!token) return false; const exists await redis.get(token); if (!exists) return false; await redis.del(token); // 一次性 return true; }提示verifyToken 要和具体业务动作绑定更安全比如存的时候带上action: submit_order消费时校验 action 是否匹配防止一个 token 被用到别的接口上。5. 真机与开发者工具的差异那些模拟器里看不出来的坑5.1 触摸事件在真机上的三个偏差第一个偏差是clientX的基准。开发者工具里clientX相对视口真机上也是但如果页面有滚动或自定义导航栏起始点的计算会偏。稳妥做法是用touchstart时的clientX作为基准而不是用滑块元素的初始位置这样滚动后也不会错位。第二个偏差是touchmove的触发频率。开发者工具里移动一次可能触发很多次真机上快速拖动时触发次数少轨迹点稀疏。如果你的风控规则对点数有要求比如「少于 10 个点判定失败」真机上真人快速拖动可能只有 6 到 8 个点会误伤。所以点数阈值要设低或者用时间间隔而不是点数来判断。第三个偏差是touchend和touchcancel的区别。用户拖动过程中如果被电话、通知打断触发的是touchcancel而不是touchend。如果只监听touchend这种情况下滑块会卡住。要同时监听touchcancel在里面做重置。5.2 canvas 层级与图片加载时序如果滑块背景用canvas绘制要注意小程序里canvas是原生组件层级最高普通view盖不住它。滑块如果放在canvas上面可能被遮挡。常见做法是背景用image而不是canvas或者用cover-view覆盖。用image更简单也避开了 canvas 的绘制时序问题。图片加载时序也容易出问题。挑战接口返回图片地址后图片还没加载完用户就开始拖会导致视觉上缺口位置和实际不符。要在图片bindload之后再允许拖动加载期间滑块置灰。5.3 网络抖动下的挑战过期与重试真机网络比开发者工具差挑战接口返回慢、提交接口超时都常见。如果挑战 90 秒过期用户加载图片花了 10 秒拖动犹豫了 30 秒提交时可能刚好过期。体验上要在过期时给明确提示并自动重新获取挑战而不是让用户对着一个失效的滑块反复拖。提交接口超时的情况不要自动重试因为重试可能触发「尝试次数 1」三次超时就把挑战耗尽了。正确做法是超时后重新获取挑战让用户重来一次。这看起来浪费但比误判成攻击要好。6. 避坑与排查五条血泪记录6.1 现象开发者工具能过真机一直失败原因通常是轨迹点数或耗时阈值卡在了真机边界上。开发者工具里touchmove触发密集轨迹点多、耗时长真机上快速拖动点少、耗时短被风控误判。解决把耗时下限从 300ms 降到 200ms点数下限从 10 降到 5同时把判定结果和轨迹打日志用真实数据调阈值。不要用模拟器的数据定阈值。6.2 现象用户拖到正确位置松手后滑块弹回原因多半是提交接口返回失败前端重置了滑块。失败的原因可能是位置差了几像素也可能是轨迹风控误伤。排查时先看服务端日志的reason字段区分是position_mismatch还是track_suspicious。解决如果是位置差几像素把容忍范围从 5 调到 8 或 10如果是轨迹可疑检查是不是 y 轴抖动阈值太严。另外确认前端提交的finalX是不是被 clamp 过有时候视觉位置和提交值不一致。6.3 现象同一个用户连续失败三次后再也过不了原因是尝试次数用完后挑战被删除但前端没有重新获取挑战还在用旧的 challengeId 提交。服务端返回challenge_expired前端如果只提示「验证失败」而不刷新挑战用户就会卡死。解决前端在收到失败响应时判断 reason 是否为challenge_expired或too_many_attempts是则自动调用fetchChallenge重新获取并重置滑块位置。不要假设挑战一直有效。6.4 现象抓包发现 challengeId 可以被复用原因是验证通过后没有删除挑战或者删除失败但没报错。Redis 的del是异步的如果代码里没 await可能删除还没完成下一个请求就进来了。解决验证通过后await redis.del(challengeId)确保删除完成再返回。同时给 challengeId 加一个「已使用」标记双重保险。对于高并发场景可以用 Redis 的GETDEL原子操作。6.5 现象轨迹数据太大导致请求超时原因是拖动时间长、采样点多tracks 数组几百个点JSON 序列化后几十 KB弱网下提交超时。解决前端在提交前对轨迹做抽稀比如每隔一个点取一个或者按时间间隔重采样到 50 个点以内。抽稀后再算风控指标趋势不变但请求体小很多。抽稀逻辑放在前端服务端不用改。7. 进阶把滑块验证做成可复用的安全组件前面讲的是一套能跑通的最小实现。如果你要在多个页面、多个项目里复用值得把它抽成一个独立模块前端一个组件、后端一个中间件业务侧只关心「验证通过后拿到 token」。前端组件化时把滑块 UI、触摸逻辑、轨迹采集、提交重试都封在组件内部对外只暴露bind:success事件和verifyToken。这样业务页面引入组件后只需要在 success 回调里拿 token 调业务接口。组件内部要处理图片加载、挑战过期、失败重试业务侧不用管。后端中间件化时把挑战生成、校验、token 签发做成三个独立接口业务接口只加一个 token 校验中间件。token 校验中间件从请求头或请求体里取 token调consumeVerifyToken失败直接返回 403。这样业务代码里看不到滑块逻辑安全能力对业务透明。验证方法上我一般用三步确认这套东西是否可靠。第一步用 Postman 或 curl 直接调校验接口传错误的 finalX确认返回失败且 attempts 增加。第二步写一个脚本模拟匀速直线拖动确认被轨迹风控拦截。第三步用真机手动拖动 20 次统计通过率低于 90% 就说明阈值太严需要放宽。检查项预期结果不通过时的排查方向错误 finalX 提交返回 position_mismatch检查 targetX 存储和比对逻辑匀速脚本轨迹返回 track_suspicious检查变异系数和 y 抖动阈值同一 challengeId 重复提交第二次返回 challenge_expired检查通过后是否 del真机手动拖动 20 次通过率不低于 90%放宽耗时和点数阈值验证通过后调业务接口无 token 返回 403检查 token 签发和消费链路最后说个我自己的习惯滑块验证的阈值从来不是一次定死的上线第一周我一定会把每次校验的 finalX、targetX、轨迹统计值、判定结果打成结构化日志每天看一遍分布。有次发现通过率突然掉了 15%查日志发现是某个机型上touchmove的 y 坐标恒定不变被 y 抖动检测误杀了把阈值从 0.5 降到 0.2 就恢复了。这种问题不看真实数据根本想不到希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?