ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数字运算验证码实战:Canvas绘制、服务端校验与限流防刷

数字运算验证码实战:Canvas绘制、服务端校验与限流防刷

1. 为什么要在登录页放一道算术题:方案选型与整体设计

前段时间帮朋友看一个后台系统,登录接口被人用脚本按字典刷了两天,日志里全是间隔 300 毫秒的请求。他们原来的方案是一个纯前端的滑块,滑过去就放行——脚本连滑块的 DOM 都不用找,直接调fetch把登录接口打穿。这种场景下,最划算的止血办法往往不是上全套风控,而是在登录表单前面加一道数字运算验证码:屏幕上出现类似7 × 3 = ?的式子,用户填个 21 就能过。

数字运算验证码,说白了就是把传统的“照抄四个歪歪扭扭的字母”,换成“算一道小学算术题”。它属于图形验证码的一个变种,核心价值在于用极低的理解成本换取一次有效的人工交互。对用户来说,看懂8 + 5比辨认8和B难不了多少,甚至更轻松;对机器来说,要稳定识别带旋转、带干扰线的手写风格算式,再算对结果,成本比识别纯字符高出一截。

这篇文章适合三类人看:正在被垃圾注册、撞库脚本骚扰,想找个轻量方案顶一下的前端;要给自己项目做一个可复用验证码组件的工程师;以及想搞清楚“验证码到底防得住什么、防不住什么”,避免把安全责任全压在前端的人。下面我会从选型逻辑、参数设计、代码实现、踩坑排查一路讲到上线自检,代码给的是可直接跑的版本,参数也给了推荐区间,你可以照着改。

1.1 从字符验证码到算术验证码,中间差的是什么

传统字符验证码的痛点是公认的:l和1、0和O、rn和m,用户输错三次就开始骂人,转化率肉眼可见地掉。后来越来越多的产品转向“行为型”验证,比如滑块、点选文字,体验确实好,但实现复杂度和依赖成本也上去了——你得有图库、有轨迹分析、有风控打分,小项目根本背不动。

算术验证码刚好卡在中间。它的题目空间是有限的、可穷举的(比如两个 1 到 20 的数,加减乘,也就一千多种组合),这意味着它天然不适合承担强安全职责,但它的生成成本几乎为零,不需要图库、不需要第三方服务、不需要模型,一个 Canvas 加几十行 JS 就能跑起来。我用它最多的场景是:内部管理后台登录、表单提交防重复、评论区防灌水、以及一些低频接口的轻量人机判别。

关键判断标准其实就一条:你的接口被脚本打的价值高不高。如果对方只是无差别扫全网,算术验证码能把 90% 的“顺手脚本”挡在门外,性价比极高;如果是有人盯着你的业务专门写绕过逻辑,那前端验证码再怎么设计都是纸糊的,得往服务端限流、设备指纹、行为风控上走。把这两件事分清楚,后面的设计才不会跑偏。

1.2 四段式链路:生成、绘制、交互、校验

我在做这类组件时习惯把它拆成四段,每段职责清晰,替换成本低:

阶段触发位置核心职责关键约束
生成服务端接口产出题目、题目 ID、有效期答案绝不下发
绘制前端 Canvas把题目渲染成带干扰的图形可读性优先于防识别
交互前端组件输入、刷新、错误提示、倒计时状态单一来源,避免竞态
校验服务端接口比对答案、一次性作废、计失败次数幂等 + 防重放

这个拆法的好处是,前端只管“看起来像什么”,服务端管“到底对不对”。很多人图省事,把a、b和答案一起塞进前端变量里,用户提交时前端自己判断对错——这种写法我见过不止一次,F12 打开控制台就能看到answer: 21,等于没做。

注意:只要答案是前端能拿到的,这道题就只是“防误触”,不是“防脚本”。如果业务上确实需要前端先做一次预判(比如减少无谓请求),那服务端必须再做一次同样的校验,前端的判断只能当体验优化。

1.3 先把安全边界说清楚,别背不该背的锅

我在评审里最常纠正的一个认知偏差:验证码不是身份认证,它只是提高批量请求的单位成本。算术验证码尤其如此,它的题目空间小,理论上脚本拿到题图后做 OCR、识别出“7×3”,再算个乘法,一次请求也就几十毫秒的事。所以它的真实定位应该是“过滤器”,而不是“门锁”。

真正让脚本难受的,是配合它一起上的几件事:同一 IP 或同一会话的失败次数限制(比如连续 5 次错误锁定 60 秒)、验证码一次性使用(用过即焚,防止同一个 token 重放)、题目短有效期(2 到 5 分钟)、以及登录接口本身的账号维度限流。这四件事里,验证码只占一环,剩下三环都在服务端。

还有个容易忽略的点:验证码接口本身也会被打。如果/captcha/generate没有任何频率控制,脚本可以疯狂拉题,把内存撑爆。我一般会按 IP 加一个 10 秒 20 次的软限制,超了就返回 429,同时记录来源,方便后续判断。

2. 核心细节解析:一道题背后藏着多少参数

一道看起来随机的6 + 9 = ?,实际上每个数字、每根线条都是被约束过的。这一块是最容易写成“能用但难用”的地方——参数拍脑袋定,结果要么是题目太简单(全是 1 到 5 的加法),要么是干扰太重(用户自己都看不清)。我按题目生成、绘制、校验三块拆开讲。

2.1 题目生成器:运算符、数值范围与结果约束

生成器的第一原则是结果必须对普通人心算友好。具体来说:结果保持非负整数,最好在 0 到 100 之间;减法必须保证被减数大于减数,不然出现负数,用户会犹豫;乘法两个因子控制在 2 到 9,避免出现13 × 17这种需要动笔的;除法要么不做,要么强制整除,且除数不能为 1(太送分)也不能为 0。

我给的一个实用配比是:加法 45%、减法 30%、乘法 25%,除法默认关闭。加法的和控制在 5 到 30,减法的差控制在 1 到 20,乘法结果不超过 81。这个区间是试出来的——再大,中年用户和年纪大的运营同事会抱怨;再小,脚本枚举一千多次就能把所有组合撞完。

// 题目生成:服务端执行,返回 {id, expression} 给前端,answer 存服务端 const OPERATORS = ['+', '-', '×']; function pickInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function generateQuestion() { const op = OPERATORS[pickInt(0, 2)]; let a, b, answer, expression; if (op === '+') { a = pickInt(2, 18); b = pickInt(1, 12); answer = a + b; expression = `${a} + ${b} = ?`; } else if (op === '-') { a = pickInt(8, 25); b = pickInt(1, a - 1); // 保证被减数大于减数 answer = a - b; expression = `${a} - ${b} = ?`; } else { a = pickInt(2, 9); b = pickInt(2, 9); answer = a * b; expression = `${a} × ${b} = ?`; } return { expression, answer }; }

这里有个细节值得说:×我用的是乘号字符而不是字母x,因为字母x在小字号下容易和×混淆,反而坑了用户。同理,减号用标准的-,别用全角破折号,渲染出来太长会挤占画布。

实操心得:题目里不要出现0作为加数或减数,比如9 + 0。用户会怀疑是不是渲染出错,反馈里有一半“验证码显示不全”的投诉都来自这种题。

2.2 Canvas 绘制的坐标系与干扰元素配比

绘制这块,我踩过的坑比生成多得多。Canvas 的坐标是逻辑像素,但在高分屏(devicePixelRatio 为 2 或 3)上,如果不做缩放,画出来的文字会糊成一团,用户第一反应是“你这验证码坏了”。

正确做法是先按 CSS 尺寸设样式,再把画布的物理像素放大 dpr 倍,最后用ctx.scale(dpr, dpr)把坐标系拉回来:

function setupCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; }

干扰元素的配比,我一般按这个基准走:画布 120×40,文字字号 22 到 24,字符间距在基准值上随机 ±3,整体旋转 ±6 度,单字旋转可以再放宽到 ±8 度;干扰线 2 到 3 条,颜色取与文字同色系但透明度 0.3 左右;噪点 25 到 35 个,半径 1 到 1.5 像素。

这里的关键取舍是:干扰要有,但不能影响“等式结构”的识别。因为算术验证码的难点对用户来说不是认字,而是认出这是个什么运算。如果线条直接从等号上穿过去,用户会读成7 - 3,那就彻底失败了。我的做法是让干扰线避开文字基线区域,只在上下留白处活动。

function drawCaptcha(ctx, expression, width, height) { // 背景:极浅的随机色,纯白反而让噪点太扎眼 ctx.fillStyle = `hsl(${pickInt(0, 360)}, 30%, 97%)`; ctx.fillRect(0, 0, width, height); // 干扰线:限制在文字上下两侧 for (let i = 0; i < 3; i++) { ctx.beginPath(); ctx.strokeStyle = `hsla(${pickInt(0, 360)}, 50%, 60%, 0.35)`; ctx.lineWidth = 1; const y = i % 2 === 0 ? pickInt(0, 8) : pickInt(height - 8, height); ctx.moveTo(0, y); ctx.bezierCurveTo(width / 3, y + pickInt(-6, 6), (2 * width) / 3, y + pickInt(-6, 6), width, y); ctx.stroke(); } // 文字:逐字绘制,各自随机旋转偏移 const chars = expression.split(''); const fontSize = 23; const gap = width / (chars.length + 1); ctx.textBaseline = 'middle'; chars.forEach((ch, idx) => { ctx.save(); const x = gap * (idx + 1); const y = height / 2 + pickInt(-2, 2); ctx.translate(x, y); ctx.rotate(((pickInt(-8, 8)) * Math.PI) / 180); ctx.font = `bold ${fontSize}px system-ui, sans-serif`; ctx.fillStyle = `hsl(${pickInt(0, 360)}, 65%, 35%)`; ctx.textAlign = 'center'; ctx.fillText(ch, 0, 0); ctx.restore(); }); // 噪点:避开中心带,减少对等式的干扰 for (let i = 0; i < 30; i++) { ctx.beginPath(); ctx.fillStyle = `hsla(0, 0%, ${pickInt(40, 80)}%, 0.4)`; ctx.arc(pickInt(0, width), pickInt(0, 6) + (i % 2 ? height - 12 : 0) + 3, 1.2, 0, Math.PI * 2); ctx.fill(); } }

注意最后噪点的写法,我把它们的 y 坐标压在了上下 12 像素的边带里。这是一个很小的改动,但用户投诉率明显下降——之前噪点随机撒在中间,很多人反馈“那个点是不是小数点”。

2.3 校验逻辑:答案为什么不能下发到前端

校验这块的写法直接决定了这套方案是不是白做。我见过的最离谱实现是:接口返回{question: "7 × 3 = ?", answer: 21},前端拿来存着,用户提交时前端比对,比对通过才发登录请求。这种实现连抓包都不用,直接在Network面板里看一眼响应就全懂了。

正确链路应该是:服务端生成题目时算出一个随机captchaId,把captchaId → answer存进缓存(Redis 或进程内的 LRU 都行),设置 120 到 300 秒过期;前端拿到的只有captchaId和expression;用户提交时带上captchaId和用户填的value,服务端取出答案比对,比完立刻删除这个 key,无论对错。

“比完立刻删除”这一步是为了防重放。假设不删,用户拿到captchaId和正确答案(他自己算出来的),可以在有效期内重复使用这个 ID 提交一百次请求,每次都通过。加上一用即焚,同一个 ID 只有一次机会。

// 服务端校验:Node 环境示意 async function verifyCaptcha(cache, captchaId, userValue) { if (!captchaId || userValue === undefined || userValue === null) { return { ok: false, reason: 'EMPTY_INPUT' }; } const key = `captcha:${captchaId}`; const record = await cache.get(key); // 先删除,再判断,天然防重放和并发穿透 await cache.del(key); if (!record) return { ok: false, reason: 'EXPIRED' }; const attempt = Number(String(userValue).trim()); if (!Number.isInteger(attempt)) return { ok: false, reason: 'INVALID_FORMAT' }; if (attempt !== record.answer) return { ok: false, reason: 'MISMATCH' }; return { ok: true }; }

把del放在判断之前,是我特意做的。如果先判断再删除,在并发场景下两个请求可能同时读到记录、同时通过,等于一次题目用了两次。先删再加一个“删除成功与否”的判断,就能保证只有一个请求拿到记录,另一个直接走EXPIRED分支。

注意:错误提示不要区分MISMATCH和EXPIRED在前端显示的具体文案,统一提示“验证码错误或已过期,请刷新重试”。区分得太细,等于告诉脚本“你这个 ID 有效,只是答案算错了”,反而帮对方缩小了搜索空间。

3. 实操过程:从零把一个算术验证码组件写出来

这一块我按真实落地顺序走一遍,从目录结构到组件封装,再到与后端的接口约定。代码以原生 JS 为主,样板足够清晰,你想迁到 Vue 或 React 里,把生命周期钩子换一下就行。

3.1 结构划分与接口约定

我习惯这么拆:

src/ captcha/ generator.js # 题目生成(服务端复用) renderer.js # Canvas 绘制(前端) captcha.js # 组件封装、状态管理 captcha.css # 样式 api/ captcha.js # 请求封装

接口我固定成两条,参数少、语义明确:

接口方法请求响应
/api/captcha/newGET无(或带 scene 标识){ captchaId, expression, expiresIn }
/api/captcha/verifyPOST{ captchaId, value }{ ok, reason }

scene这个可选参数有用:如果同一个站点有登录、注册、评论三处都用验证码,带上场景标识可以分开统计失败率,也方便后续针对某一处单独调难度。

组件封装时,我强调一个原则:状态单一来源。当前题目、加载状态、错误信息、剩余有效时间,全部挂在一个对象上,任何一次刷新都整体替换,而不是零散地改某个字段。这样能避免“题目已经换了,但错误提示还停在上一轮”的经典 bug。

3.2 组件骨架与渲染入口

export function createCaptcha(options) { const { container, canvasSize = { width: 120, height: 40 }, onVerify, // 外部传入的校验函数,返回 Promise<{ok, reason}> refreshOnError = true // 失败后是否自动换题 } = options; const state = { captchaId: null, expression: '', loading: false, error: '', verified: false }; const input = document.createElement('input'); input.type = 'text'; input.inputMode = 'numeric'; // 移动端唤起数字键盘 input.autocomplete = 'off'; input.maxLength = 3; input.placeholder = '计算结果'; input.className = 'captcha-input'; input.setAttribute('aria-label', '请输入算式计算结果'); const canvas = document.createElement('canvas'); canvas.className = 'captcha-canvas'; canvas.setAttribute('role', 'img'); const refreshBtn = document.createElement('button'); refreshBtn.type = 'button'; refreshBtn.textContent = '换一题'; refreshBtn.className = 'captcha-refresh'; container.append(input, canvas, refreshBtn); return { state, input, canvas, refreshBtn, refresh }; }

input.type这里我特意用text而不是number。number类型在桌面端会带上下箭头,鼠标滚轮滚一下数字就变了,用户会莫名其妙;而且number在部分浏览器里允许输入e和-,校验时还得额外过滤。用text加inputMode="numeric",键盘体验一样,脏数据更好控。

maxLength设 3 是因为我们的答案上限是 81,两位数字就够,留一位容错。如果答案位数不固定,就按最大可能的位数设。

3.3 刷新、提交与竞态处理

这是整个组件最容易出问题的地方。想象这个场景:用户点了“换一题”,请求还在路上,他又点了第二次,第一个请求后回来把captchaId覆盖了,此时画布上显示的是题 B,但绑定的 ID 是题 A 的,用户怎么填都不对。

解决办法是加一个请求序号,只有最新一次请求能写入状态:

let requestSeq = 0; async function refresh() { const seq = ++requestSeq; state.loading = true; state.error = ''; renderLoading(); try { const res = await fetch('/api/captcha/new').then(r => r.json()); if (seq !== requestSeq) return; // 已有更新的请求,本次结果丢弃 state.captchaId = res.captchaId; state.expression = res.expression; state.loading = false; state.verified = false; drawCaptcha(canvas.getContext('2d'), res.expression, canvasSize.width, canvasSize.height); canvas.setAttribute('aria-label', `验证码算式:${res.expression}`); } catch (e) { if (seq !== requestSeq) return; state.loading = false; state.error = '加载失败,请重试'; renderError(); } } async function submit() { const raw = input.value.trim(); if (!raw) { showError('请先填写计算结果'); return; } if (!state.captchaId) { showError('验证码已失效,请刷新'); return; } if (state.verified) return; // 防连点重复提交 state.loading = true; const result = await onVerify({ captchaId: state.captchaId, value: raw }); state.loading = false; if (result.ok) { state.verified = true; showSuccess(); return; } showError('验证码错误或已过期,请刷新重试'); if (refreshOnError) refresh(); // 失败即换题,杜绝穷举 }

state.verified这个锁很关键。没有它,用户手快连点两次提交,第一次通过后captchaId已经被服务端删了,第二次必然失败,页面上就会闪现一个“验证码错误”,体验极差。

refreshOnError我默认给true。原因是:如果不换题,同一个captchaId已经被服务端删了,用户再填也是过期,只能引导手动刷新,不如自动换来得干脆。代价是网络慢的时候会闪一下,所以我配了个 300 毫秒的过渡动画。

实操心得:刷新按钮一定要加type="button"。如果组件是放在<form>里的(登录页基本都是),不写这个属性,浏览器会把它当成提交按钮,点一下整个表单就提交了,用户一脸懵地看到“密码错误”。

3.4 服务端侧的缓存与限流细节

缓存这块,如果你的部署环境有 Redis,就用SET key value EX 240,过期时间我建议 240 秒,比前端展示的倒计时略长一点,避免网络延迟导致的“倒计时还有 5 秒但其实已经过期”。

没有 Redis 的话,进程内 Map 也能顶,但必须自己加清理逻辑,否则内存会慢慢涨:

const store = new Map(); const TTL = 240 * 1000; function set(id, answer) { store.set(id, { answer, expireAt: Date.now() + TTL }); } function take(id) { // take 语义:取出即删除 const item = store.get(id); if (item) store.delete(id); if (!item) return null; if (item.expireAt < Date.now()) return null; return item; } // 每 60 秒扫一次过期键 setInterval(() => { const now = Date.now(); for (const [k, v] of store) { if (v.expireAt < now) store.delete(k); } }, 60 * 1000).unref?.();

多实例部署时,进程内 Map 会出问题:请求打到了 A 实例生成的题,校验时落到 B 实例,B 查不到,直接判定过期。这个坑我踩过,表现是“验证码随机性失败”,大约一半的请求会报过期。判断方法很简单:看失败率是不是接近(实例数 - 1) / 实例数。解决办法要么上集中缓存,要么在负载均衡上做会话粘滞,前者更推荐。

限流我一般分两层:生成接口按 IP 限制(10 秒 20 次),校验接口按 IP 和账号双维度限制(10 分钟内 5 次失败,锁定 60 秒,锁定期间即使算对也不放行)。第二层的锁定要有明确的解除逻辑,别做成永久封禁,否则用户换个网络环境还是进不来,客服压力全在你这边。

4. 常见问题与排查技巧实录

写到这儿,我想把这两年攒下来的问题清单摊开说说。算术验证码的坑,一半在渲染,一半在交互细节,真正涉及算法的反而少。

4.1 高频问题速查表

现象大概率原因排查方法处理方式
文字发虚、边缘模糊未按 dpr 缩放画布在高分屏下对比 canvas.width 与 CSS 宽度按 2.2 的分辨率适配写法重设
验证码区域点击无反应上层有透明遮罩或 z-index 冲突用开发者工具逐个隐藏兄弟元素调整层叠顺序,或把输入框放进同一容器
移动端键盘弹出后页面跳动输入框聚焦触发滚动观察布局视口变化容器加固定高度,聚焦时不做布局重排
输对了仍提示错误captchaId 被重复刷新覆盖在提交前打印 captchaId 与画布文案引入请求序号,丢弃过期响应
多实例部署时随机失败缓存未集中统计失败率是否接近 (n-1)/n换用集中缓存或做会话粘滞
用户反馈算式看不清干扰过重或颜色对比不足缩小窗口、调低亮度复现降低干扰密度,保证文字与背景对比度
提交后无任何反馈校验函数 Promise 未 catch看控制台是否有未捕获异常补 try/catch 并统一错误提示

这张表我基本是照着客服记录反过来整理的,其中“输对了仍提示错误”出现频率最高,八成都是竞态问题。

4.2 三个我亲自踩过的坑

第一个是点击穿透。当时把 canvas 和输入框做成绝对定位叠在一起,结果移动端上输入框聚焦后键盘弹起,页面被压缩,输入框被挤到 canvas 下面,用户点输入框的时候实际点到了 canvas,光标死活不出现。后来改成正常文档流布局,canvas 和输入框并排,问题一次解决。结论是:别为了好看硬叠绝对定位,验证码区域本身也不大,横向排版更稳。

第二个是输入法。测试同事用中文输入法在输入框里打了个21,但输入法还在候选状态,value拿到的是空字符串,提交时提示“请先填写”。用户在候选框里按了回车,候选词上屏,看起来数字已经在那儿了,其实没提交。处理方式是在compositionstart和compositionend上做标记,compositionend时再读取值;更省事的做法是直接给输入框加pattern="\d*"并在提交时用Number(raw)转换,输入法的问题在数字场景下影响很小。

第三个是颜色对比度。有一版为了“好看”,文字用了浅色调,干扰线用了深色,结果运营的同事在会议室投影上根本看不清。后来我把文字统一压到hsl(色相, 65%, 35%),亮度固定在 35% 附近,背景用 97% 的浅色,对比度稳稳落在 7:1 以上。这个参数调整看着不起眼,但它是用户投诉下降最明显的一次改动。

4.3 给看不清的人留条路

这一点经常被忽略。算术验证码对普通人就是一眼的事,但对色弱用户、视力不佳的用户、以及在强光环境下看手机的人,就是折磨。我一般会做三件事:

第一,给 canvas 加aria-label,把算式文字直接写进去,读屏软件可以念出来,用户听题作答。第二,提供“换一题”按钮,并保证这个按钮不需要先算对才能点(有些实现把刷新按钮放在校验通过之后才可用,这完全是反人类)。第三,如果业务允许,给一个降级通道,比如同一账号连续失败 3 次后,切换到更简单的题型或者走人工审核。

还有个小细节:aria-label里的乘号要写清楚。7 × 3 = ?读屏可能念成“七三”,更友好的写法是7乘以3等于多少。这个字符串在前端做一次替换就行,成本极低。

5. 性能、兼容与上线前的自检

最后一节聊聊收尾的事。这类组件不复杂,但上线前该看的还是得看一遍,尤其是兼容性和体积。

5.1 体积与渲染开销

整套东西的核心代码压缩后不到 3KB,没有第三方依赖,Canvas 绘制一次耗时在 1 到 3 毫秒之间,对首屏几乎无影响。真正需要注意的是刷新频率:如果用户狂点“换一题”,每次都触发网络请求和重绘,移动端上会有轻微卡顿。我的做法是给刷新按钮加 500 毫秒节流,同时请求序号机制已经保证了结果不会错乱。

内存方面,如果组件在单页应用里频繁挂载卸载,记得清定时器。倒计时用的setInterval一定要在组件销毁时clearInterval,否则每进一次页面就多一个定时器在跑,久了页面会明显变慢。

注意:Canvas 元素本身不会自动回收绘图上下文,但只要你不在每次重绘时反复getContext创建新的上下文对象,就没有额外开销。我在代码里把 ctx 缓存在变量里复用,这一点在小页面上看不出差别,但在长列表里每个条目都放验证码的场景下很关键。

5.2 上线前的自检清单

检查项通过标准
答案是否只在服务端抓包看/new响应,不含答案字段
是否一次性使用同一 captchaId 提交两次,第二次必须失败
是否有过期机制等待超过有效期后提交,返回过期
失败是否换题答错一次后,画布内容必须变化
高分屏是否清晰手机和 Mac 上查看文字边缘
移动端键盘是否正常点击输入框能唤起数字键盘
表单内按钮类型刷新按钮为 button,不会误提交表单
限流是否生效连续快速请求生成接口,能拿到 429
无障碍属性读屏能念出算式,输入框有标签
错误提示是否统一前端不同分支提示文案一致,不泄露细节

这张清单我每次上线前都会过一遍,尤其是“是否一次性使用”和“失败是否换题”这两条,最容易在赶工时被漏掉。

6. 一点个人体会

做了这么多版验证码,我最大的感受是:它的价值不在“多难”,而在“多合适”。数字运算验证码不是最安全的方案,甚至算不上安全方案,但它在成本、体验、防护力度这三者之间的平衡点,是很多中小项目真正需要的。我见过有人为了防脚本上了很重的方案,结果自家运营同事每天登录都要滑三次,最后不得不加白名单,绕了一圈回到原点。

如果你现在正准备给自己的项目加一道这样的关卡,我的建议是先算笔账:你的接口被打的频率有多高、造成的损失有多大、加这道题会让正常用户多花几秒。如果损失明显大于体验代价,那就上;如果不能,先去做服务端限流和日志告警,收益比前端验证码高得多。真要做,就按上面这套来,答案放服务端、一次一用、失败换题、配套限流,四件事做到位,挡住大部分顺手脚本完全够用。至于更狠的攻击,那就不是一道算术题该操心的事了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进