简介:这份资源是面向微信小程序开发者与前端学习者的滑块验证功能实现代码包,针对刷票、恶意注册等自动化风险场景,提供一套可参考的人机校验方案。包内共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。常见做法是存 Redis,key 用 challengeId,value 存缺口 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 长度会爆。用 POST,content-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 就恢复了。这种问题不看真实数据根本想不到,希望帮到你。
本文还有配套的精品资源,点击获取