滑动验证码这活儿,看着简单,做起来全是细节。最近我把一个自研的滑动验证码从零手写了一遍,不依赖任何第三方验证码服务,前端负责渲染和采集拖动轨迹,后端负责校验和风险判定,整套代码整理出来开放给有兴趣的同学。我写这篇东西不是单纯丢代码,而是想把每一步背后“为什么这么写”也讲清楚。如果你正在纠结要不要自己搞一套滑动验证码,或者想弄明白缺口图是怎么生成的、后端到底校验了什么,这篇文章应该可以帮到你。做完这个项目之后我自己最大的感受是:验证码的本质不是图片识别,而是一场行为风控的博弈,代码只是最表层的东西。
1. 为什么自己写一套滑动验证码,而不是直接调第三方
1.1 自研的动机与适用场景
市面上的商业验证码服务很多,随便接一下就能用,为什么还要自己写?我当时的核心诉求有三个:成本、自定义、数据主权。
先说成本。很多验证码服务是按请求次数计费的,验证码的调用量往往比真实的业务请求还高,因为每个用户进入页面就会拉一次,不一定要提交表单。流量稍微起来之后,这是一笔不小的开销。自研的话,图片资源可以用自己的CDN,接口走自己的服务,成本几乎可以忽略不计。
再说自定义。第三方验证码的展现样式是固定的,虽然能换主题色,但是滑块形状、缺口位置、交互文案想深度定制就很难。我在一个活动页里需要把验证码的滑块做成品牌吉祥物的样子,用第三方方案基本办不到,除非自己在上层又罩一层UI,但这又绕了远路。自己做的话,缺口形状可以任意画,交互逻辑可以随时调,视觉上完全可控。
最后是数据主权。验证码其实能采集到不少用户行为数据:拖动轨迹、每一步的时间戳、按下位置、抖动幅度。这些数据如果全送到第三方服务商那里,就会形成一份用户画像留在别处。很多企业级场景,尤其是数据安全要求严格的项目,不能接受这一点。自研之后,所有轨迹数据都只落在自己的服务器上,规则也自己定。
那是不是所有场景都适合自研?也不是。如果你的产品正在承受大量黑产攻击,对方可以花钱买到真人操作的工作室,那花大力气自研性价比很低,不如直接接入成熟的商业风控产品。我建议的是这几种场景可以考虑自研:内部系统登录入口、活动页防刷、爬虫拦截补充、私有化部署环境。
1.2 自研验证码的能力边界:能防谁,防不了谁
写之前一定要想清楚一件事:滑动验证码本质上只能防“低成本的批量脚本”,防不住“高投入的真人代填”。脚本问题在于,它可以瞬间自动识别图片缺口、模拟拖动轨迹,如果我们的校验逻辑写得太简陋,被人分析出缺口坐标后,甚至不需要真实拖动,直接构造一个HTTP请求把坐标提交上去就过了。
所以自研方案在架构设计上,要把“前端展示”和“服务端校验”分开。前端只是给真实用户提供交互界面,服务端才是最终裁决者。哪怕前端代码被人扒干净,他也不应该能从接口层面直接伪造一次成功验证。
另一个边界是性能。验证码校验不能做成一个耗时很高的计算,否则会拖慢正常用户的操作。我在设计时给自己定了一个目标:一次验证服务端处理时间控制在50毫秒以内,所有风险规则都基于简单数值计算,不跑机器学习模型。这套方案对付绝大多数自动化工具有余,但如果你要上高强度的风控,建议还是再叠加IP限制、账号维度频控、设备指纹等手段。
写这个项目之前我把商业滑动验证码的交互流程研究了一遍,发现大多数实现都可以抽象成三个环节:生成随机缺口、用户拖拽对齐、服务端校验轨迹。我的代码也按这个思路拆模块,接下来的内容就一个一个环节展开。
2. 前端实现拆解:从图片缺口到可拖拽滑块
2.1 拼图缺口是怎么“挖”出来的:Canvas异形裁剪原理
滑动验证码最关键的一个视觉元素,是背景图上那个异形缺口。缺口不能是一个简单矩形,否则用户很容易瞄准,而且脚本也容易用纯色块检测定位。常见的做法是设计一个有弧度和凹凸的结构,类似一个把小锯齿和半圆组合起来的形状。
生成缺口我建议用Canvas来做,核心原理是“异形裁剪”。先在画布上画好背景图,再用一条路径勾勒出目标的轮廓,最后通过destination-out这个全局合成模式把画布上对应区域挖掉。这样一张带缺口的背景图就得到了。同时把同样的形状单独画在一块透明画布上,就得到了滑块按钮的图案。
这里有一个容易踩的坑:如果背景图来自另一个域名,Canvas调用drawImage之前必须给图片对象设置crossOrigin = 'anonymous',否则画布会被浏览器标记为“被污染”,后面拿到toDataURL时会直接抛异常。这个我后面专门用一节讲。
生产环境里更稳妥的做法是把“挖缺口”这步放到后端,比如用图像处理库生成缺口图和滑块图,前端只负责展示。但前端生成有一个额外好处:在做Demo或者原型演示时,不用写服务端,直接本地就能看到效果。我的完整代码里给了两套玩法:前端生成版和后端返回版。本文代码讲解以前端生成为主,因为它的逻辑更透明,方便理解缺口坐标从哪儿来。
下面这段代码演示了如何绘制一个带凸起形状的路径,并把它用于挖图:
function drawGapPattern(ctx, x, y, w, h) { ctx.beginPath(); // 缺口主体 ctx.moveTo(x, y); ctx.lineTo(x + w * 0.3, y); ctx.arc(x + w * 0.3, y + h * 0.15, h * 0.2, -Math.PI / 2, 0); ctx.lineTo(x + w * 0.7, y); ctx.arc(x + w * 0.7, y + h * 0.1, h * 0.25, 0, Math.PI / 2); ctx.lineTo(x + w, y + h * 0.5); // 底部锯齿 ctx.lineTo(x + w * 0.7, y + h * 0.5); ctx.lineTo(x + w * 0.7, y + h * 0.8); ctx.lineTo(x + w * 0.4, y + h * 0.7); ctx.lineTo(x + w * 0.4, y + h); ctx.lineTo(x + w * 0.1, y + h * 0.6); ctx.lineTo(x, y + h); ctx.closePath(); }实际使用的时候,路径不需要像我这样画得特别对称,越不规则越难被脚本秒识别。但也要注意,太难看的形状会让用户找不到落点,我一般会在“识别难度”和“用户体验”之间取一个平衡。
补充一个原理细节:滑块的图案和背景图上的缺口,必须用同一个坐标、同一个形状生成。生成之后,前端在渲染时直接把缺口区域定位到(gapX, gapY),滑块图案则跟随拖动距离移动。这里的一个关键点是,不要把缺口坐标硬编码在代码里,而是由后端在生成验证码时随机产生并保存,否则人家看一次源码就等于拿到了万能钥匙。
2.2 滑块轨道和缺口定位:CSS结构设计要点
视觉结构上,我按三层来叠:
- 最底层是背景图,里面已经包含缺口;
- 中间层是一个用于标记缺口位置的占位块,比如半透明的灰色形状;
- 最上层是滑块,由轨道、遮罩和按钮三部分组成。
滑块默认停在轨道左端,用户按住按钮向右拖。轨道宽度和背景图宽度保持一致,拖动的最大距离就是轨道的宽度减去按钮宽度。判断成功的条件是:按钮左侧边缘与缺口区域中心的水平距离小于一个容差值。
在这里有必要说一个前端常见误区:滑块按钮的定位不要用left属性直接改,因为你设置left会引起布局重排,而且容易受父容器内边距影响。我习惯用transform: translateX(px)来控制位移,它不会触发重排,动画更顺,同时算位移时只需要维护一个数值变量,减少很多边界判断。
轨道的遮罩层也有讲究。用户拖动时,轨道左侧已经走过的距离应该显示成高亮或者变色,这个遮罩层通常用width加过渡来实现。还有一个细节是按钮的“抓取”手感,默认状态用cursor: grab,拖动过程中改成grabbing,松开后回到默认。这些交互细节看着小,但直接影响用户对验证码完成度的感知。
3. 完整代码逐段讲解:纯手写版滑动验证码
3.1 HTML结构:三层叠加的关系
下面这份HTML是一个非常精简但不失完整的结构。我删掉了无关的业务代码,只保留验证码本身的核心骨架。
<div id="captcha" class="captcha-box"> <div class="captcha-bg"> <canvas id="bgCanvas" width="400" height="220"></canvas> <div class="gap-ref" id="gapRef"></div> </div> <div class="captcha-rail"> <div class="rail-mask" id="railMask"></div> <div class="rail-btn" id="railBtn"> <canvas id="silderCanvas" width="60" height="60"></canvas> </div> <div class="rail-text" id="railText">按住滑块拖动完成拼图</div> </div> </div>背景画布我直接放在captcha-bg里,这样它和缺口参考层天然叠加。gap-ref这个元素专门用来定位缺口中心,它是一个半透明的占位形状,用户看得到要拖到哪里,但脚本拿到它的位置也只是拿到了预期值,最终判定仍然由服务端做。滑块按钮里的silderCanvas是独立的,它画出来的形状会和背景图的缺口一模一样。
需要注意,滑块按钮的尺寸要略大于缺口形状,因为按钮还承担“手握住”的视觉作用。比如缺口尺寸是50x50,按钮画布可以做成56x56,多出来的部分作为透明边界。这样拼图成功后,形状拼接视觉上没有明显的缝隙,观感更舒服。
3.2 CSS关键点:坐标对齐与手势限制
CSS部分我挑几个关键点解释。首先,整个验证码容器水平居中,宽度固定,背景画布和轨道必须等宽。我把gap-ref用left和top设置为缺口坐标,但这地方要特别留意:Canvas的实际显示尺寸可能通过CSS被缩放,所以坐标不能直接用生成时的Canvas坐标,要先做一次像素比例换算。
理想状态下,Canvas内部尺寸和CSS尺寸要保持一致,也就是设置width和height属性时就用实际渲染的像素值,不要再用CSS去拉伸。但如果设计稿要求响应式,那就必须在计算坐标时乘以一个缩放系数。
.captcha-box { width: 400px; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; border: 1px solid #e3e6eb; border-radius: 8px; background: #fafafa; padding: 16px; } .captcha-bg { position: relative; width: 400px; height: 220px; overflow: hidden; border-radius: 4px; } .captcha-rail { position: relative; width: 400px; height: 48px; margin-top: 12px; background: #e8eaf0; border-radius: 4px; overflow: hidden; } .rail-mask { position: absolute; left: 0; top: 0; height: 100%; width: 0; background: rgba(68, 147, 248, 0.28); } .rail-btn { position: absolute; left: 0; top: 0; width: 56px; height: 100%; display: flex; align-items: center; justify-content: center; background: #ffffff; box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); border-radius: 4px; cursor: grab; touch-action: none; transform: translateX(0); }最关键的一条CSS是.rail-btn上的touch-action: none;。移动端浏览器默认会把横向滑动当作页面滚动,如果没有这一行,手指按住滑块拖动时页面会跟着飘,导致轨道位移和视觉脱节。这一行是移动端能顺滑拖动的必要条件。
3.3 拖拽核心JavaScript:坐标计算与阈值判断
接下来是核心脚本。我尽量把每一步讲透。
class SliderCaptcha { constructor(options) { this.container = options.container; this.onSuccess = options.onSuccess || (() => {}); this.onFail = options.onFail || (() => {}); this.gapX = 0; this.gapY = 0; this.isDragging = false; this.startX = 0; this.startY = 0; this.currentX = 0; this.trace = []; this.railWidth = 400; this.railBtnWidth = 56; this.tolerance = 4; this.init(); } async init() { await this.initBackground(); this.bindEvents(); } async initBackground() { // 这里生产环境应改为从服务端获取 gapX/gapY this.gapX = Math.round(80 + Math.random() * 200); this.gapY = Math.round(40 + Math.random() * 130); // 省略 Canvas 挖图代码,见 2.1 this.renderGapRef(this.gapX, this.gapY); } bindEvents() { this.btn = this.container.querySelector('.rail-btn'); this.mask = this.container.querySelector('.rail-mask'); this.text = this.container.querySelector('.rail-text'); this.btn.addEventListener('mousedown', (e) => this.startDrag(e)); this.btn.addEventListener('touchstart', (e) => this.startDrag(e), { passive: false }); document.addEventListener('mousemove', (e) => this.moveDrag(e)); document.addEventListener('touchmove', (e) => this.moveDrag(e), { passive: false }); document.addEventListener('mouseup', () => this.endDrag()); document.addEventListener('touchend', () => this.endDrag()); } startDrag(e) { e.preventDefault(); this.isDragging = true; const point = this.getPoint(e); this.startX = point.x; this.startY = point.y; this.trace = [{ x: 0, y: 0, t: Date.now() }]; this.btn.style.cursor = 'grabbing'; } moveDrag(e) { if (!this.isDragging) return; e.preventDefault(); const point = this.getPoint(e); let offsetX = point.x - this.startX; offsetX = Math.max(0, Math.min(offsetX, this.railWidth - this.railBtnWidth)); this.currentX = offsetX; this.btn.style.transform = `translateX(${offsetX}px)`; this.mask.style.width = offsetX + this.railBtnWidth + 'px'; const relativeY = point.y - this.startY; this.trace.push({ x: Math.round(offsetX), y: Math.round(relativeY), t: Date.now() }); } endDrag() { if (!this.isDragging) return; this.isDragging = false; this.btn.style.cursor = 'grab'; const distance = Math.abs(this.currentX - (this.gapX - this.railBtnWidth / 2 + 8)); if (distance < this.tolerance) { this.text.textContent = '验证通过'; this.text.style.color = '#389e0d'; this.onSuccess(this.trace); } else { this.text.textContent = '拖动滑块到缺口处'; // 失败时播放一个回弹动画 this.btn.style.transition = 'transform 0.3s ease'; this.btn.style.transform = 'translateX(0)'; this.mask.style.width = '0px'; setTimeout(() => { this.btn.style.transition = ''; }, 300); this.onFail(this.trace); } } getPoint(e) { if (e.touches) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } return { x: e.clientX, y: e.clientY }; } }这个类最核心的逻辑在endDrag里。但我必须强调,前端判断成功只是为了用户体验,如果直接把这个判断结果提交给后端,那是非常不安全的。前端代码跑在别人的浏览器里,任何一个参数都可以被改掉,所以真实项目中,前端只负责把轨迹数组、总耗时、移动距离这些数据提交给后端,由后端做最终判断。
轨迹数组的设计也值得留意。每个点都包含x、y和t,这就是一条完整的拖动轨迹。后面的风险校验全靠它,比如判断用户是匀速拖过去的还是中间有停顿,判断有没有在Y轴上做出人类手抖的微小摆动。写到这里你会发现,滑动验证码的“验证”重心其实已经从“图片识别”转移到了“行为识别”。
4. 后端校验与防刷:只判断“缺口对齐”远远不够
4.1 校验接口与一次性令牌
我建议后端至少提供两个接口:一个用于获取验证码参数,一个用于提交验证结果。
获取验证码接口的核心工作是生成一张随机背景图、挖出缺口、保存坐标,并把坐标关联到一个随机ID上。这个ID我们习惯叫captchaId,它是一个临时令牌,关联当前会话,并且只能使用一次。当用户提交验证结果时,后端根据captchaId找到当时保存的缺口坐标,再校验前端提交的拖动轨迹,判断通过之后就立即删除这个captchaId,防止同一个验证码被重复提交。
一次性令牌这个细节,很多新手会漏掉。如果不做,脚本可以反复用同一个验证码撞答案,或者把同一组合法的验证数据重放到其他请求上。加了captchaId并且校验成功后直接删除,逻辑才闭环。
接口设计大致是这样:
POST /api/captcha/init Response: { captchaId: "a3f8...", bgImage: "/images/captcha/a3f8.png", width: 400, height: 220 } POST /api/captcha/verify Request: { captchaId: "a3f8...", trace: [{x, y, t}, ...], duration: 1560, userAgent: "..." } Response: { pass: true, riskScore: 0.1 }注意init接口不要把gapX和gapY返回给前端。前端只需要显示背景图和滑块,缺口坐标保存在后端,前端只能通过Canvas图层把缺口显示出来,或者通过缺口参考块定位,但不需要把具体数值暴露给JS计算。实际实现里,如果背景图已经包含了缺口,那用户在视觉上能看到位置,脚本也能通过图像识别找到这个位置,所以缺口坐标并不是什么核心机密,真正的防线是后面的轨迹校验。
4.2 轨迹数据的时间、速度与抖动特征分析
拿到一段轨迹之后,我通常会提取四类特征。
第一类是总耗时。真实用户从按下到松手,最少也要几百毫秒,通常需要一两秒。如果提交数据里的duration小于300毫秒,大概率是脚本直接POST坐标,直接拒绝。但这也不是绝对的,有些用户手速很快,所以我不会用单一条一票否决,而是把它作为风险评分里的一个加项。
第二类是位移曲线。真实用户拖动滑块时,速度不是恒定不变的:起步慢,中间加快,接近目标时又会减速微调。我可以用相邻点的位移和时间戳算出一个速度序列,然后查看速度曲线是否接近“先加速、再减速”的逻辑。脚本模拟的轨迹往往是一条笔直的匀速直线,或者速度曲线过于平滑,这是很强的机器特征。
第三类是轨迹点数。人手动拖动时,浏览器mousemove事件触发的频率和鼠标物理移动速度有关,通常几十到上百个点。脚本为了模拟,可能只生成十几个点,或者干脆所有点都是等间距的。我在代码里会对掉点数设置一个最低门槛,比如少于5个点直接拒绝。
第四类是Y轴抖动。真实的手部动作不可能完全水平,即使滑块只有X轴方向移动,鼠标或手指在Y轴也会有一点点波动。脚本模拟出来的轨迹,Y轴往往要么完全不变,要么太规律。我会算一下Y轴的方差,保持在一个“像人”的范围。
下面是后端校验的一个轻量Python示例,展示如何提取这些特征:
import math import time def verify_trace(trace, duration, gap_x, tolerance=4): if duration < 300: return False, "duration_too_short" if not trace or len(trace) < 5: return False, "trace_no_enough_points" xs = [p["x"] for p in trace] ys = [p["y"] for p in trace] times = [p["t"] for p in trace] start_time = times[0] normal_times = [(t - start_time) for t in times] if xs[-1] - gap_x > tolerance: return False, "position_mismatch" # 简单计算速度变化 speeds = [] for i in range(1, len(trace)): dt = normal_times[i] - normal_times[i - 1] dx = xs[i] - xs[i - 1] if dt > 0: speeds.append(abs(dx) / dt) if not speeds: return False, "trace_invalid" avg_speed = sum(speeds) / len(speeds) max_speed = max(speeds) min_speed = min(speeds) if max_speed > 0 and (max_speed - avg_speed) < 0.5: return False, "speed_too_even" # Y轴抖动检查 y_var = sum((y - (sum(ys) / len(ys))) ** 2 for y in ys) / len(ys) if y_var < 0.01: return False, "no_human_jitter" return True, "ok"这个示例当然比较简单,真实环境我还会加上滑动方向上的回退次数、相邻点时间间隔的分布、轨迹拟合后的残差等。
4.3 风险评分:把规则组合起来
上面每个特征单独看都不能完美区分人和机器,所以我用的是“打分制”。每条规则根据命中情况给出一个风险分,最后累加,超过阈值就拒绝。
比如:
- 坐标未命中缺口,风险分100,直接拒绝;
- 总耗时过短,风险分60;
- 速度曲线过于平滑,风险分40;
- Y轴方差过低,风险分30;
- 轨迹点数过少,风险分20;
- 移动过程中出现明显回退(大于20像素后又拉回来),风险分30。
最终总分超过80,就要求用户重新验证。这套规则可以塞进一个简单的配置中心,方便后续调整。
还有一点很重要:验证码校验接口一定要加频率限制。同一个IP或者同一个captchaId,一分钟内最多验证几次,超过就直接返回失败。否则人家用分布式代理跑脚本,轨迹规则再怎么调都会被打穿,频率限制相当于兜底。
我把后端逻辑用Node.js重写了一遍,核心判断完全一致,就是为了满足不同技术栈读者的需求。说到底,校验逻辑不复杂,复杂的是怎么让它误判率低、拦截率高。
5. 接入踩坑实录:兼容性、性能与体验调优
5.1 touch事件与click事件打架
第一个坑发生在我接移动端页面的时候。明明电脑上拖得好好的,手机上一按滑块页面就开始滚动,滑了半天位置没变。原因就是我前面讲的:没有在滑块按钮上设置touch-action: none。这个属性是为了告诉浏览器“这个元素上的触摸动作不要交给浏览器默认处理”。
加上之后问题解决了,但又出现另一个问题:touchmove事件需要手动调用preventDefault(),并且在绑定监听时要传入{ passive: false },否则现代浏览器会忽略preventDefault。我一开始用React的合成事件也遇到了类似情况,需要特别注意。如果你用原生事件,不要漏掉被动监听这个细节。
另外,在mousedown和touchstart场景下,我都需要记录起点坐标。这里有一个细微差别:鼠标事件可以直接用e.clientX,触摸事件要用e.touches[0].clientX。我的getPoint方法已经处理了这个分支。
还有一点值得说明的是,我最终把事件绑定在document而不是滑块按钮上。这样用户即使快速拖出滑块区域,也能正常触发mouseup和touchend,避免滑块卡在半路。
5.2 图片跨域导致画布被“污染”
我最初的Demo背景图用了图片服务商的地址,打开页面一看背景显示正常,但一调用canvas.toDataURL就报错。查了半天才发现是“画布污染”问题。浏览器安全策略规定,如果drawImage用了跨域图片,而且图片没有通过CORS授权,画布内的像素数据就是不可读的,任何尝试读取像素或者导出图片的API都会抛异常。
解决办法是在图片对象上设置crossOrigin = 'anonymous',同时要求图片服务返回正确的Access-Control-Allow-Origin响应头。
const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { ctx.drawImage(img, 0, 0, width, height); // 继续切缺口... }; img.src = 'https://your.cdn.com/captcha/bg.jpg';自建图片服务是最稳妥的。如果你只是本地测试,也可以把图片放在同域名下,不需要跨域,但这套代码上了生产环境就要提前规划好图片资源的部署。我的建议是:所有的验证码背景图统一走自己的静态资源域名,并且在该域名的响应头里加上Access-Control-Allow-Origin: *,避免后续所有画布相关的麻烦。
5.3 滑块拖不到缺口位置:定位偏差问题
第三个坑比较隐蔽,是关于坐标换算的。缺口生成时用的是Canvas内部像素坐标,比如400x220,但页面为了适配小屏,把整个验证码容器用CSS缩放成了350px宽。这个时候我在CSS里用gapRef.style.left = gapX + 'px',就会有一个偏差,因为Canvas显示宽度已经缩放了,但gapX还是原始像素。
解决这个问题有两个方向。第一个方向是关闭缩放,让Canvas的CSS宽度和它的width属性一致。第二个方向是引入一个缩放系数,然后在设置gapRef位置时做乘法:
const scale = cssWidth / canvasWidth; gapRef.style.left = (gapX * scale) + 'px'; gapRef.style.top = (gapY * scale) + 'px';更建议的方式是使用 “背景偏移” 而不是绝对定位。你可以把缺口图先切成一个小块,然后用background-position: -gapX -gapY控制缺口在图中的位置,通过同样的坐标系避免CSS缩放换算。具体选哪种看你的设计,但一定要保证“生成坐标-显示坐标-判断坐标”三套一致。
还有一个小坑:按钮的宽度会影响判断公式。用户看到的滑块顶部左下角并不是缺口坐标的中心,我在代码里用gapX - railBtnWidth / 2 + 8这个修正公式去对齐,不同的滑块视觉设计需要微调这个常量。这是那种“看代码以为不对,一跑起来刚刚好”的细节,建议大家根据自己的按钮样式调试。
5.4 性能优化:别让验证码拖慢页面加载
滑动验证码涉及的静态资源包括一张背景图和一个滑块形状图。背景图最好不要用太高清的大图,我用的是400x220尺寸,压缩后控制在30KB以内。滑块按钮的Canvas只有几十像素,性能压力忽略不计。
真正影响性能的是初始化接口。如果每进一个页面就调一次验证码接口,高频场景下会给后端带来很大压力。解决方案是前端懒加载,用户点击登录按钮之后再去初始化验证码,而不是进页面就初始化。另一个方案是接口加缓存,同一个图片模板在短时间内复用,但缺口坐标仍要随机生成。
我用一个简单的队列做了并发控制,防止用户反复点击刷新验证码导致服务端被打。这个在代码里没有体现,但上线前一定要加。
6. 从这套验证码延伸出去的更多玩法
6.1 纯前端演示版与前后端联调版的差别
我开放出来的代码里,最早版本是纯前端的,所有逻辑都在浏览器里跑,适合你快速把静态页面打开看一眼效果。但你要真把它部署上线,这个版本的安全性和没有是一样的。原因在于,前端代码里存着gapX和gapY,而且成功判断也在前端,任何人打开控制台执行一句captcha.onSuccess([fakeTrace])就能直接触发后续逻辑。
所以我把“前后端分离”版也做了出来:前端只采集不裁决,后端通过captchaId拿到生成参数并完成判断。代码上,前端initBackground部分改为请求后端接口,后端verify接口负责计算风险分。
如果你打算进一步减少依赖,可以像我在开发里做的那样,把背景图列表放在静态JSON里,后端只提供captchaId和随机缺口坐标,然后用一层代理返回拼接好的图片。图片本身可以是预生成的,不用每次动态绘制,性能更好。
6.2 综合体验:除了拼图,还能加哪些行为特征
项目做深了以后你会发现,滑动验证码只是整个人机校验里的一环。真正牢固的体系,会把这套验证码和业务风险融合在一起。
举个例子,我可以记录用户鼠标进入验证码区域之前的移动轨迹。有的脚本是直接定位到滑块按钮然后模拟点击,没有任何悬停和微操作;真人则会有一个比较自然的“寻找滑块”过程。这个数据从mousemove里就能拿到,不用额外加接口。
再比如,可以分析滑块从按下到松手期间,页面上是否有其他的点击或者滚动事件。真人可能在拖动过程中不小心触发点击,脚本通常不会。把这些多维行为数据汇总后,即使轨迹本身做得像人,也会在更宏观的维度露出破绽。
还有更轻量的一招:在验证成功之后,下发一个短时效的签名Cookie,业务接口读取这个Cookie才放行。这样即使验证码接口被绕过了,业务接口还有一层校验兜底。这个Cookie只做“最近成功验证过”的标记,真正关键的数据,比如风险分,还是要存到服务端。
对我来说,手写这套滑动验证码的最大收获,不是省了第三方服务的费用,而是彻底搞懂了人机校验这场博弈游戏的底层逻辑。验证码做得再花哨,背后都是在问同一个问题:屏幕对面的操作,到底有没有体温?这套代码可以帮你入门,但要应对真实生产环境,你还需要把轨迹数据和业务特征不断揉在一起打磨。最后给个实用建议:如果有人找你要这套代码,直接把我这个版本给他,但记得提醒他,前端Demo只能做教学演示,生产环境一定要走后端校验那套逻辑。