这年头做网站最烦的,不是服务器扛不住,而是半夜打开后台,注册量曲线突然拉成一条直线,用户ID一排排往下刷,邮箱后缀全是同一个服务商。这种场景我碰到过几次之后,决定不再跟第三方验证码SDK来回扯皮,干脆自己写了套行为式人机验证组件,代号就叫 T3Code。这个名字没什么深意,就是第三套方案的意思。T3Code解决的,是很多业务都会遇到的一个麻烦:怎么在尽量不打扰真实用户的前提下,把脚本、批量注册、自动刷票这些机器流量挡在门外。这篇内容不聊多高大上的架构,就讲讲我整个设计和落地过程,里面踩过的坑和调整过的参数,都会原样写出来,对想自建风控或验证服务的团队应该有点用。
有人可能觉得,验证码不就是弹个图、滑个块、点个汉字吗,有什么好折腾的。但真正接过线上验证服务的人都明白,问题从来不在“生成一道题”,而在“判断一个人”。T3Code没有采用传统的答题模式,而是把验证过程拆成了前端行为采集、后端特征评分、策略拦截三层,让真用户几乎无感通过,让脚本在第一步就栽跟头。下面我把整个项目的来龙去脉拆开细讲。
1. 为什么我要自己写一套行为验证码
1.1 传统图形验证码的三个硬伤
选型之前,团队内部先开过一次小会,把市面上的方案挨个盘了一遍。图形验证码是最早被否掉的,理由有三个。第一,用户体验太割裂,用户正在填注册表单,突然跳出来一张扭曲的数字图片,能明显感觉到“被拦了一下”,每次验证都是在打断操作流。第二,图形识别的难度很难平衡,太简单了OCR秒解,太难了真用户也要输错两遍。现在成熟的识别服务识别率已经很高,那种四位数字加噪点的方案基本等于裸奔。第三,无障碍性很差,视障用户依赖读屏工具时,图形验证码几乎不可用,投诉比例高得离谱。
滑块验证码比图形验证码好一些,交互轻、通过快,但它的问题出在“轨迹可模拟”。市面上针对滑块验证码的工具我已经见过不少,先按固定速度滑到头,再返回一段拟人抖动,配合随机延时,通过率能到七成以上。滑块验证本质上还是“做对一道题”,题库和规则一旦被人逆向,整条防线就垮了。T3Code做行为式验证的核心逻辑不一样:不关心用户是否做对了某件事,只关心操作轨迹是否符合人类的自然习惯。脚本可以模拟一道题的答案,但要连续模拟出几百毫秒内人类手部运动的全部细节,难度会指数级上升。
1.2 我对验证码的判断标准
动工之前,我给T3Code定了三条硬标准。第一,真实用户必须无感。无感的意思不是不展示验证,而是大多数情况下根本不用触发验证,只有风险分数够高才弹一次轻量确认。第二,判断不能是“二值”的。传统验证码只有通过和不通过两个结果,T3Code必须输出一个风险评分,业务方可以根据场景决定阈值。第三,误杀率必须可控。用户网络抖动、触控屏硬件差异、浏览器缩放比例都可能影响采集数据,不能因为这些原因把真人判成机器。
这第三条后来成了整个项目里最磨人的部分。一开始我天真地以为,只要特征维度够多,误杀率自然就低。实际上特征越多,噪声也就越多,如果不对特征做权重调优,模型反而容易学歪。后来我把特征分成强特征和弱特征两类,强特征直接决定评分上限,弱特征只在强特征触发嫌疑时作为辅助证据。比如“设备指纹不完整”是弱特征,单独出现不扣分;“轨迹时长低于200毫秒且没有加速度变化”是强特征,出现就直接拉高风险。规则明确之后,误杀率才降下来。
1.3 为什么选择自研而不是接入大厂产品
团队里有同事提议直接接大厂的验证码服务,省维护成本。我承认这是多数团队应该走的路,但T3Code最后坚持自研,主要是三个原因。一是数据归属。行为数据里面其实包含了大量用户习惯信息,接第三方等于把这些数据送给别人,从数据安全角度我不是很放心。二是策略自由度。大厂服务给的是标准答案:返回通过或不通过,你很难针对自己业务的特定攻击模式做定制。三是被集成时的不可控。回调延迟、签名规则变化、对方服务升级导致的前端兼容问题,这些我都踩过。自研之后,前端代码、后端接口、评分策略全在自己手里,出问题随时可以改。
当然,自研也不是没有代价。开发、测试、上线后的调优都需要有人持续投入,一个小团队如果只有两三个人,不建议轻易尝试。我的建议是,先花两周时间做技术验证,确认自己的场景确实有定制需求,再决定是否自研。
2. 核心模块拆解:T3Code到底采集什么、怎么判断
2.1 前端埋点:不是所有事件都需要
T3Code的前端部分,最初被我设计成了一个被动监听器,不主动向用户发起验证交互。它只负责在后台采集几类事件:鼠标的mousedown、mousemove、mouseup,以及移动端的touchstart、touchmove、touchend。这里有个容易被忽略的细节,单纯的x、y坐标不够,必须把事件类型、触发时刻、点击目标区域一起记录下来。为什么?因为人类点击是有“目标感”的,手在移动过程中会先快速接近目标,再减速微调,最后稳定点击。脚本模拟的轨迹往往直线匀速,缺少这个减速瞄准的过程。
采集频率不能太密,否则数据量太大,前端性能扛不住。我的做法是,mousemove事件用requestAnimationFrame做了节流,理论上每帧最多记录一次,实际间隔大概在16ms到60ms之间动态变化。这个动态变化本身也是一个特征,脚本生成的轨迹时间间隔往往过于均匀,真人操作的时间间隔天然带有抖动。前端采集到的事件会放入一个环形数组,最多保留最近500个点,每轮交互结束后打包压缩,等待业务请求时一起发送。
下面是我当时采集逻辑的一个简化版本,保留了核心思路:
class BehaviorCollector { constructor() { this.points = []; this.maxPoints = 500; this.boundHandler = this.handleMouseMove.bind(this); } start() { window.addEventListener('mousedown', this.handleMouseDown.bind(this)); window.addEventListener('mousemove', this.boundHandler); window.addEventListener('mouseup', this.handleMouseUp.bind(this)); document.addEventListener('touchstart', this.handleTouchStart.bind(this)); document.addEventListener('touchmove', this.handleTouchMove.bind(this)); document.addEventListener('touchend', this.handleTouchEnd.bind(this)); } handleMouseMove(e) { const now = Date.now(); // 这里用时间戳差做基础节流,避免每帧多次记录 if (this.lastTime && now - this.lastTime < 20) return; this.lastTime = now; this.pushPoint({ x: e.clientX, y: e.clientY, t: now, type: 'move' }); } pushPoint(point) { this.points.push(point); if (this.points.length > this.maxPoints) { this.points.shift(); } } getSnapshot() { const snapshot = this.points.slice(-300); this.points = []; return snapshot; } }注意一个细节:坐标要用clientX/clientY,不能用offsetX,因为不同浏览器对offsetX的基准定义存在差异。touch事件的坐标也要统一成视口坐标,否则后面算轨迹特征时会出现坐标系不一致导致的脏数据。
2.2 轨迹特征工程:把原始点序列变成可计算指标
采集到原始点序列之后,下一步是特征工程。这是整个T3Code里我最看重的一步,也是让评分结果可解释的关键。脚本的轨迹不管多拟真,在数学特征上一定和真人存在差异。我主要提取了六类特征:轨迹点数、持续时长、平均速度、加速度突变次数、回程比例、曲率变化。其中“回程比例”很有意思,它统计的是轨迹往回走的距离占总路径的比例。真实用户操作时经常会有小幅回拉动作,比如错过目标后修正,脚本模拟的轨迹极少出现这种回拉。
转折点数量和曲率变化我也算了很久。判断转折点的方式很简单,取连续三个点,如果中间点与前后两点连线产生的夹角小于某个阈值,就记一个转折点。真人轨迹的转折点分布比较稀疏且不均匀,而脚本模拟的轨迹要么转折点过少(直线型),要么过多且均匀(刻意加入抖动时容易露馅)。我给这两类情况都设置了扣分项。下面是Python端计算部分特征的示例:
import math def compute_turning_points(points, angle_threshold=30): # points: [{x, y, t}] count = 0 for i in range(1, len(points) - 1): p0, p1, p2 = points[i - 1], points[i], points[i + 1] v1 = (p1['x'] - p0['x'], p1['y'] - p0['y']) v2 = (p2['x'] - p1['x'], p2['y'] - p1['y']) dot = v1[0] * v2[0] + v1[1] * v2[1] len1 = math.hypot(v1[0], v1[1]) + 1e-6 len2 = math.hypot(v2[0], v2[1]) + 1e-6 cos_angle = max(-1, min(1, dot / (len1 * len2))) angle = math.degrees(math.acos(cos_angle)) if angle < angle_threshold: count += 1 return count特征算完之后,还要做归一化。不同设备的分辨率不同,同样是移动100像素,在1080P屏幕和4K屏幕上的物理距离差异很大。我把坐标统一除以视口宽高,转成0到1之间的比例值,再做速度计算就会稳定很多。这个细节不处理的话,同一套阈值在不同分辨率下结果会漂移,线上排查时很容易被误导。
2.3 设备指纹与上下文信号的整合
只靠轨迹特征还不够,T3Code里还整合了设备指纹和上下文信号。设备指纹的部分包含Canvas指纹、屏幕DPI、时区、语言、UserAgent等常规信息。这些信息单独看都不可靠,但组合起来能有效提高攻击方的模拟成本。比较核心的是上下文信号:页面停留时长、用户是否滚动过页面、滚动节奏、页面焦点变化次数。真人在访问一个页面时,必然有一个阅读或扫视的过程,会产生零星的滚动和焦点切换;脚本往往直接发起请求,缺少这些行为信号。
这里要强调,设备指纹异常不应该直接判机器,而是作为加权项。我给设备指纹赋予的权重上限是30%,行为轨迹权重上限是60%,上下文信号权重上限是10%。这样即使攻击方伪造了设备指纹,只要行为轨迹真实,总分也不会过高。反过来,如果一个session的上下文信号全部缺失、设备指纹异常、轨迹特征又完美得不自然,那基本可以确定是脚本了。
2.4 后端评分逻辑与阈值怎么定
T3Code后端最终会输出一个0到100的风险分数,同时附上命中的证据标签,比如“轨迹时长过短”“点击前无停顿”“设备指纹不一致”。证据标签这件事非常重要,它不是为了给人看的,而是为了方便排查误杀。用户被拦截后,客服只要把标签发给技术,技术人员能立刻定位是哪条规则误判。如果没有标签,面对一个光秃秃的分数,任何优化都只能靠猜。
阈值方面,我没有使用单阈值,而是分了三档。低于35分直接放行,35到60分进入轻量二次验证,高于60分直接拒绝。二次验证这个设计很关键,它把人工兜底的出口留住了。一些真实用户因为鼠标故障、触控板驱动异常导致分数偏高,直接拒绝会造成不可逆的用户流失,二次验证能给他们补救机会。
| 风险分区间 | 处理动作 | 适用场景 |
|---|---|---|
| 0-35 | 放行 | 全部场景 |
| 35-60 | 二次验证 | 注册、登录、评论 |
| 60-100 | 拒绝 | 支付、改密、领取权益 |
阈值具体取多少不是拍脑袋定的。我在灰度阶段用历史数据做过分布统计,把真实用户分数的95分位和脚本分数的5分位作为参考边界,再根据业务容忍度微调。总的原则是:宁可让漏网脚本多跑两步,也不要误杀真实用户。
3. 从零接入T3Code的完整流程
3.1 前端一次性接入怎么做
T3Code的前端发布包压缩后大约18KB,不依赖任何框架,接入方式很简单。页面加载时引入脚本,初始化后调用getToken方法,拿到一个加密的行为凭证,塞到业务请求的header或body里。这个token的有效期默认60秒,过期之后前端会静默重新采集生成新的token。如果页面存在跨域请求,需要在初始化阶段配置好传递方式和白名单。
<script src="/js/t3code.min.js"></script> <script> window.T3Code.init({ appId: 'your_app_id', mode: 'passive' }); async function submitRegisterForm(formData) { const token = await window.T3Code.getToken(); const response = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...formData, t3_token: token }) }); // ... } </script>接入初期最容易犯的错是只在注册接口加验证,登录、找回密码、支付确认这些接口全裸奔。攻击者没那么傻,哪个口子好打就打哪个。我的建议是,把T3Code当成一个全局防护层,所有写操作和敏感读操作都默认带上token,后面再根据实际情况决定哪些接口可以放宽校验,而不是反过来。
3.2 后端校验接口怎么设计
后端服务我用Python FastAPI写的,核心校验接口是/t3code/validate,业务方通过内部调用或HTTP请求把token传过来,服务端解析签名、检查有效期、重放状态,然后计算出风险分返回。签名校验用的是HmacSHA256,secret只保存在服务端。token本身是一个JSON结构,包含采集时间、前端版本号、行为数据的序列化压缩结果,以及用于防篡改的签名。
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class ValidateRequest(BaseModel): token: str class ValidateResponse(BaseModel): risk_score: int pass_flag: bool tags: list[str] @app.post("/t3code/validate", response_model=ValidateResponse) async def validate_t3code(req: ValidateRequest): payload = parse_token(req.token) if payload is None: return ValidateResponse(risk_score=100, pass_flag=False, tags=["token_invalid"]) if is_replay(payload["token_id"]): return ValidateResponse(risk_score=100, pass_flag=False, tags=["token_replay"]) features = extract_features(payload["behavior"]) risk_score, tags = score(features, payload) pass_flag = risk_score < 35 return ValidateResponse(risk_score=risk_score, pass_flag=pass_flag, tags=tags)重放校验这里尤其想说一下。token必须是一次性的,服务端在第一次校验通过后就把token_id写入缓存,设置60秒过期。缓存建议用Redis,直接放内存会有多实例不一致的问题。除了token_id,我还会记录客户端IP和UserAgent,同一个IP在短时间内频繁使用不同token提交业务请求,也是一个高优先级的风控告警信号。
3.3 灰度发布和效果评估方法
T3Code上线没有采用全量开关,而是先花了两周做灰度。第一阶段接在公司内部低风险接口,比如论坛点赞、评论提交,目标是看通过率和误杀率。第二阶段接注册接口,这个接口机器流量最集中,能明显看出拦截效果。第三阶段才接登录和支付。灰度期间我重点盯四个指标:请求通过率、拒绝率、二次验证通过率、平均响应时间。
通过率反映的是真实用户体验,拒绝率反映的是拦截力度。这两个指标看着矛盾,其实是配合用的。通过率在85%以下,说明阈值过严,真实用户被误伤;拒绝率低于10%,说明阈值过松,机器流量可能大量混入。二次验证通过率能反映拦住的用户里有多少是真人,这个值长期高于50%,说明风险评分还需要精细化。平均响应时间则必须控制在80ms以内,付费接口更是要压到50ms以下。
我自己在灰度期间干过一件笨但有效的事:把每条风险分大于60的真实请求日志打出来,逐条回放当时的轨迹数据。看了一百多条之后才发现,很多真实用户的轨迹都缺少进入页面后的首次点击停顿,原因是他们用了键盘Tab键在表单间跳转,鼠标轨迹根本没有完整采集。后来我给键盘操作单独定义了行为模式,问题才解决。
4. 上线之后踩过的坑与排查实录
4.1 误杀率突然飙升,问题出在触控板
灰度第二周,误杀率从3%一下跳到了11%,后台一看证据标签,“轨迹时长过短”占了七成。排查了很久才发现,触发群体集中在MacBook用户和部分Windows触控板用户。这些人操作时习惯于轻触触控板完成点击,产生的轨迹点数量极少,时长也短,被模型误判成了脚本。修复方案不是放宽轨迹时长规则,而是增加一个触控板识别分支:当检测到设备是触控板时,降低轨迹时长权重,提高加速度突变权重。真人即使点击快,也必然有加速度从0到峰值再到0的变化过程,脚本反而容易忽略这个特征。
这个坑给T3Code留下了一条永久规则:任何特征在做判断之前,都必须先分析设备环境的分组差异。分组的好处是,你不用为所有用户找一套普适阈值,只需要在每个分组内找到相对稳定的规律。
4.2 脚本开始模拟真人轨迹怎么办
上线一个月后,我盯后台数据发现一个异常模式:某个IP段的请求,行为打分普遍在20到30之间,低于拒绝阈值但远高于正常用户。调出轨迹回放,发现这些轨迹做得有模有样,有起点偏移、有中途停顿、有返回修正,单看任何一条都很像真人。但把一百条轨迹叠在一起,问题就暴露了:转折点的分布过于整齐,停顿间隔集中在400ms到600ms之间,明显是用了同一个模板加随机噪声生成的。
对付这种模板化生成的轨迹,我增加了一个“熵特征”计算。正常人操作的时间间隔分布是幂律式的,有时快有时慢,间隔的熵值偏高。模板加噪声生成的数据,间隔分布虽然看起来随机,但方差偏小,熵值达不到真人水平。这个特征加入后,模拟轨迹的风险分普遍涨了20到30分。这里也暴露了一个核心认知:不要追求单条轨迹的识别精度,要利用统计分布来识别批量伪造。脚本永远面临一个两难,如果完全模拟真人统计分布,生成成本极高;如果只模拟表象,批量数据又会在统计上露出马脚。
4.3 兼容性坑位:微信内置浏览器和低端安卓
T3Code在移动端的适配问题比较多。微信内置浏览器对touch事件的坐标在某些Android机型上存在偏差,第一版上线后经常出现点击坐标不在元素范围内的离奇记录。排查后发现是viewport缩放导致的坐标换算差异,修复方案是统一使用getBoundingClientRect拿元素位置,避免直接累加touch偏移量。低端安卓机上,requestAnimationFrame在页面切换时会掉帧,导致轨迹中断。这里我又加了一层兜底:如果检测到断点时间超过200ms,就把中断前后的轨迹拆成两段分别打分,否则一段看起来过长的静止时间会拉低整体速度特征。
兼容性问题的排查思路,我建议反过来做:先收集线上的环境分布数据,按浏览器、操作系统、屏幕尺寸三个维度分组看分数分布,哪个组分布异常就优先查那个组。不要指望一套代码覆盖所有环境,而是要在代码里设置多个分支,让采集逻辑适应环境差异。
4.4 容易漏掉的细节:token复用、时钟偏移、缓存穿透
上线第三个月,运营反馈有人“刷验证码”,仔细一查是前端把token存进了localStorage,用户刷新页面后token被复用,而服务端只校验签名不校验重复使用。修复方案是token里附带精确到毫秒的时间戳,并且服务端在有效期内拒绝同一token第二次出现。时钟偏移的问题也出现过一次,用户的终端时间不准,导致token时间戳比服务器时间快了五分钟,服务端一直报过期。后来我把有效期校验改成允许前后30秒的时钟偏移,问题才解决。
还有一个性能坑。校验接口上线时没有设置限流,高峰期一个攻击者用同一个伪造token批量请求,把服务端打出了不少无效计算。后来我在网关层加了按IP和token双重限流,单个IP每秒最多请求10次校验接口。限流不是为了避免服务崩溃,而是为了逼迫攻击方增加资源成本,让他们得不偿失。
| 问题 | 表现 | 修复方案 |
|---|---|---|
| token复用 | 刷新后仍能通过校验 | 一次性token + 服务端缓存 |
| 时钟偏移 | 真实验证被拒 | 前后30秒宽容窗口 |
| WebView坐标偏移 | 点击坐标异常 | getBoundingClientRect换算 |
| 模拟轨迹 | 单条通过,批量异常 | 增加时间间隔熵特征 |
5. 给准备自建验证能力的团队几句经验
如果你正在考虑做类似T3Code的东西,我最大的建议是:从规则起步,不要一上来就铺深度学习模型。规则系统的优势是可解释、可调整,误杀一个用户可以立刻定位到具体规则;深度学习模型虽然能压指标,但调参周期长,出了问题很难向业务解释。T3Code目前的架构里,深度学习模型只作为评分策略的一个补充模块,核心判断仍然以规则特征为骨架。这种“规则为主、模型为辅”的思路,让我在快速迭代中占了很大便宜。
另外,日志一定要从第一天就开始做完整。T3Code每一条评分记录都包含原始轨迹摘要、特征值、命中标签、版本号、环境信息。没有这些数据,任何一次误杀排查都会变成猜谜。数据安全同样不能忽略,行为轨迹属于敏感个人信息,我这边做的是在服务端只保留聚合后的统计特征,原始轨迹在评分完成后直接丢弃,不落库。
最后再分享一个小技巧。T3Code上线之后,我把前端采集到的所有评分结果都做了可视化回放工具,能看到轨迹点的行进动画和每个点的实时加速度。这个工具在调参过程中帮了大忙。很多问题不是看数字能看出来的,把轨迹画出来,一眼就能看出不对劲的地方。视觉直觉在风控系统调试里,远比想象的更有用。