前几天有个做物联网平台的朋友找我,说他们新上的WebSocket服务"看上去一切都正常"——客户端能连上,握手也不报错,但一推业务数据,对端就静默断开,日志里啥都没有。查到最后发现是Token校验在连接建立之后才执行,而他们所有的测试用例都只验证到"连接成功"这一步就停了。这个案例特别典型,也正好引出今天这篇实战指南的核心:WebSocket接口的鉴权测试,从来不是"连上看一眼通不通"那么简单。
Postman从2020年的版本开始内置了WebSocket客户端能力,但很多人只拿它发发HTTP请求,对里面WebSocket这块的玩法并不熟悉。鉴权测试作为WebSocket接口安全验证里最关键的一环,涉及握手Header构造、连接生命周期管理、消息级断言、动态Token刷新等多个环节,每一步都有不少坑。这篇文章我会从底层逻辑讲到具体操作,覆盖Token类鉴权、握手期鉴权、连接后鉴权、错误码判定、自动化断言等场景,全程基于我在项目里实际用过的方案,希望能帮你在自己的服务上线前把这道安全关卡补扎实。
1. WebSocket鉴权为什么和HTTP完全不是一回事
1.1 一次握手建立长连接,鉴权窗口比想象的窄
先理清楚WebSocket的建立过程。客户端发起的是一个带升级意图的HTTP请求,服务端同意后返回101状态码,TCP连接被"借用"成一条全双工的WebSocket通道。这意味着,绝大多数的身份校验逻辑只能被压缩在两次HTTP交互里完成:一次是客户端的握手请求,一次是服务端的101响应。
这和HTTP接口有一个本质区别。HTTP是无状态的请求-响应模型,每个请求都可以重新带上Authorization头,Token过期了客户端能立刻刷新再试。但WebSocket一旦完成握手,这条TCP通道就固定了,你没办法在中途"重放"一次新的Authorization头。如果Token在长连接期间过期,能做的要么是服务端主动踢掉连接,要么是客户端断开重连。这个特性决定了鉴权测试的覆盖范围必须包括握手前、连接中、断开后三个阶段,而不只是"能不能连上"这一个判断点。
实际项目里还有一个容易忽略的问题:WebSocket握手虽然是HTTP协议,但很多网关、负载均衡器对Upgrade头的处理和支持程度并不一致。我见过有环境里Nginx配置没开Upgrade透传,结果客户端一路请求到后端都是200而不是101,整个连接建立流程直接静默失败。鉴权测试如果只盯着业务层的Token校验,很容易漏掉这类链路层问题。
1.2 鉴权方案选型直接决定测试设计
WebSocket的鉴权方案没有统一标准,我在不同项目里见过这么几类主流做法:
| 鉴权方式 | Token传递位置 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 握手Header携带 | Authorization/ 自定义Header | 语义清晰,符合HTTP习惯 | 浏览器端WebSocket API无法自定义Header | 服务端API、原生客户端 |
| URL Query参数携带 | wss://host/ws?token=xxx | 实现简单,浏览器也能用 | Token会进访问日志,有泄露风险 | 浏览器直连场景 |
| 子协议透传 | Sec-WebSocket-Protocol | 在协议协商层做鉴权,灵活 | 服务端解析复杂度高,且子协议本身用途是协商数据格式 | 自定义协议的服务 |
| Cookie鉴权 | 握手时携带Cookie | 复用已有登录态 | 跨域场景受限,CSRF风险需要额外防护 | Web应用同域部署 |
| 连接后首包鉴权 | 连接建立后第一条消息携带Token | 对握手阶段改动最小 | 需要额外的消息协议约定,连接已建立但未鉴权时有被攻击的窗口 | 消息队列、推送服务 |
测试设计要跟着鉴权方案走。比如你测的是浏览器直连的WebSocket服务,那Query参数携带Token的方案就必须重点覆盖;如果你的客户端是原生应用,那自定义Header方案就更常见。我在项目里见过最离谱的情况是服务端同时支持三种鉴权方式,结果每个客户端的实现都不一样,测试这边只覆盖了一种,上线后其他两种方式全都在报401。鉴权测试不是"测过就行",而是要"每种实际会用的方式都测到"。
2. Postman WebSocket测试的前置准备与环境搭建
2.1 从REST请求到WebSocket请求的入口与界面
Postman桌面版左侧边栏点"+新建",就能看到"WebSocket Request"的选项,这是独立于HTTP请求的一种请求类型。界面布局和REST请求类似,但有几个关键差异:
- URL输入框:需要填写完整的
ws://或wss://地址。注意用wss://时,Postman会通过TLS加密信道连接,证书校验和HTTP请求一样走系统信任链。 - Headers区域:只在握手阶段发送,连接建立后设置的Header不会再起作用。
- Message区域:这是核心差异区。你可以在这里写要发送的消息内容(POSTMAN支持文本、JSON、XML等格式),也可以手动输入要发给服务端的消息。
- Events区域:对应
Connected、Disconnected、Message received三类事件,每类事件可以挂JavaScript脚本。鉴权断言主要写在消息接收事件里。 - 下方消息日志:连接建立后的双向消息流都会在这里滚动显示,错误码、关闭帧信息都能直接看到。
第一次用的时候容易找不到"Send"按钮在哪。REST请求的主体按钮叫"Send",WebSocket这里换成了"Connect",点下之后按钮会变成"Disconnect"。整个交互逻辑从"发一次请求拿一次响应"变成了"建立连接、持续收发消息、手动断开",刚上手时感官上会有一些跳跃感,但其实背后的原理是一致的。
2.2 环境变量与Collection变量驱动的动态Token体系
鉴权测试绕不开动态Token。你不可能在测试环境里把Token写死,因为Token有过期时间,写死意味着每过几个小时全部测试都会挂掉。我在项目里的标准做法是:用环境变量存Token,用Pre-request Script在握手前自动拉取并刷新。
具体拆成三步:
- 先在环境配置里建好
baseUrl、authUrl、username、password等基础变量。 - 在WebSocket请求的Pre-request Script里写一段自动登录脚本,请求登录接口换取Token。
- 将Token写入环境变量,握手URL或Header里通过
{{wsToken}}这样的引用方式动态读取。
这套链路跑通之后,每次发起WebSocket连接之前都会自动执行登录,拿到的是"当前时刻"的有效Token,测试的稳定性和可重复性都会好很多。而且Postman的脚本环境支持console.log,配合开发者工具里的Console面板,调试脚本时能看到每一步的执行状态,比在黑盒里瞎猜要高效得多。
2.3 WebSocket测试在Collection里的组织方式
很多人用Postman测WebSocket时只在单个请求里点来点去,完全没有Collection的概念。但对于鉴权测试这种需要按场景矩阵去覆盖的任务,我建议至少拆成三个请求放在同一个Collection下:
- 握手鉴权成功用例:用有效Token建立连接,断言状态码101,断言收到欢迎消息。
- 握手鉴权失败用例:不带Token、带过期Token、带错误格式Token,分别验证服务端返回401还是403,以及返回的关闭码。
- 连接中鉴权失效用例:模拟Token过期后的行为,验证服务端是否主动断开、关闭码是否为4001或1008这类策略错误码。
Collection的作用不只是组织管理,还方便你后续把测试接入Newman或者CI流水线。虽然Newman对WebSocket支持的完整度还有限,但作为日常批量回归的入口,Collection比散落的单请求可靠得多。
3. 握手阶段的鉴权测试实战
3.1 核心场景一:Token放Header怎么验
需要验Token放Header的场景,通常来自非浏览器客户端。操作上很简单,在Headers区域维护键值对即可:
Authorization: Bearer {{wsToken}}但真正要细究的是"后端怎么验"。我见过两种实现风格:一种是只在握手阶段校验Header中的Token,校验通过就101,不通过就直接401;另一种是握手时不校验,等服务端准备推送或客户端发起第一条业务消息时才校验。两种风格在Postman里的验证方式完全不一样。
如果服务端是第一种风格,你在Postman里填入无效Token后点击Connect,可以看到握手阶段就返回了401,同时消息日志里会出现对应的HTTP状态码。如果服务端是第二种风格,那连接会成功建立,但紧接着可能会收到一条type为error的消息,或者服务端直接发一个关闭帧,关闭码是4001之类的自定义码。
有一个细节值得提:很多服务端框架在鉴权失败时会先返回HTTP错误码,但不会发WebSocket关闭帧,因为此时WebSocket连接尚未建立,协议上还不存在"关闭帧"这个东西。你如果在测试里预期收到关闭帧,那在握手失败这个场景里永远等不到,断言就会超时。所以测握手期鉴权时,断言对象是"连接失败"和"HTTP状态码",而不是"WebSocket关闭码"。
3.2 核心场景二:Query参数带Token的边界条件
Query参数携带Token的方案在浏览器直连场景里用得最多,因为浏览器端WebSocket API不支持自定义Header,而Query参数哪怕你用new WebSocket(url)这种最朴素的调用方式也能带。
测试这个场景时我强烈建议你覆盖下面这组边界条件:
- Token放在Query第一个参数:
wss://host/ws?token=xxx&clientId=abc,服务端如果只按参数名取,这种没问题。 - Token放在Query中间:
wss://host/ws?clientId=abc&token=xxx&appVersion=1.0,有些框架解析Query参数时会假设Token必须排在某个位置,虽然这很反直觉,但我确实在真实项目里遇到过。 - Token编码问题:JWT里有点号
.和连接符-,这两个字符在Query参数里不用编码,但如果你自己实现的服务端用了严格的正则校验,可能把非法字符直接拦截。测试时要确认带特殊字符的Token是否能正常通过。 - 重复参数:
?token=valid&token=expired,这种参数虽然在正常业务里不会出现,但服务端如果取的是第一个值而客户端发的是第二个值,线上就会出现"日志里显示Token是对的但实际校验的是错的"这种诡异问题。
这类边角条件在需求文档里通常不会写,但作为鉴权测试,它们的价值不亚于主流程用例。服务端如果连这种输入都能稳定处理,说明参数解析逻辑是健壮的,线上排查问题的时候你会感谢自己多测了这一手。
3.3 动态Token的过期与刷新机制模拟
Token是会过期的,而WebSocket测试更特殊的地方在于:你无法在连接建立后更新Token。所以"Token过期"这个场景在WebSocket里会被拆成两个子场景:
- 子场景A:握手时Token已过期。这种最简单,在Pre-request Script里用一个已经过期但格式正确的Token去发起连接,断言服务端返回401或403。为了构造过期Token,我习惯在脚本里先请求一个真实Token,然后从JWT的payload部分读exp字段,把时间往过去拨一下再签个名。这样构造出来的"过期Token"在格式上是绝对有效的,能避免因为格式问题导致测试目标不纯粹。
- 子场景B:连接建立后Token过期。这种场景必须让连接先建立成功,然后等待Token过期。如果Token有效期是5分钟,你真去等5分钟,效率太低。实操里我通常是找开发要一个短时效Token的调试入口,或者直接改本机系统时间。改时间这个方法虽然听起来有点土,但对于跑本地代码逻辑的服务端特别管用,前提是服务端校验Token时用的是系统时间而不是依赖时间服务器。
刷新机制的另外一个分支是"断线重连后的Token获取"。很多长连接客户端都有自动重连逻辑,重连时需要重新走一遍握手和鉴权流程,这时候如果设备上存的还是旧Token,重连必然失败。在Postman里模拟这个场景,其实就是在握手前脚本里去掉"重新登录并刷新Token"这段逻辑,直接用环境变量里上一次的旧值去连接。如果服务端代码在重连鉴权上没有做特殊处理,你很快就能复现出生产环境里"每过一小时客户端集体掉线重连失败"的经典事故。
4. 连接建立后的鉴权验证与消息级断言
4.1 用关闭码和错误消息判断鉴权结果
握手阶段过了不代表鉴权测试就收工了。连接建立后服务端可能因为鉴权失败主动断开,或者发一条错误消息然后保持连接,这两种行为在产品设计上可能导致完全不同的问题。
Postman的消息日志区域会展示接收到的所有消息,包括二进制帧、文本帧、Ping/Pong帧和关闭帧。你可以在Disconnected事件里读取关闭码和关闭原因,比如:
ws.on('disconnected', function() { const code = ws.closeCode; const reason = ws.closeReason; console.log('断开码:' + code + ',原因:' + reason); });WebSocket的关闭码分几个区间,1000是正常关闭,1001是主机重启,1008是策略违规,4000到4999是自定义区间。鉴权失败如果用1008,说明服务端按标准语义处理了;如果用4001或4002这种自定义码,说明服务端有自己的一套业务语义。测试断言一定要按服务端实际约定的码来,不能想当然地认为401对应的关闭码一定是1008。我在Ping/Pong测试时复盘过一次:有个服务把所有错误都关成1006(异常关闭),结果客户端无论鉴权失败还是网络断开都表现为1006,IT侧根本没法区分原因,后来强制要求服务端改用自定义关闭码之后,问题定位才变得容易。
消息日志里还会显示握手响应头,这有助于确认服务端下发的Sec-WebSocket-Accept和其他自定义头是否符合预期。比如有些服务端会在握手响应里透传用户ID、设备ID等信息,你可以检查这些字段来判断鉴权确实生效了,而不是"谁都能连上"。
4.2 连接后鉴权的消息交互模拟
对于"连接后第一条消息携带Token"这种方案,Postman的玩法要更细一点。你需要先Connect建立连接,等服务端下发一条challenge消息(比如{"type":"auth_required"}),然后在Message区域手工输入带Token的响应消息,点击Send。之后在Message received事件里写断言,检查是否收到auth_success或类似的成功响应。
这里有个关键技巧:WebSocket的消息是时序相关的,服务端可能会在challenge之后设定一个超时窗口(通常是5到10秒),窗口内没有提交凭据就断开。Postman里你手动输完消息到点击Send之间是有时间差的,遇到超时短的用例,你可能会莫名其妙看到连接被断开,但日志里却没有任何报错。我在实操里最常用的规避办法是:在脚本里通过ws.send()直接发送消息,而不是手动输入点击发送。脚本发送和手动发送的差距虽小,但当服务端窗口只有5秒时,这个差距就足以决定测试结果。
4.3 Test Script与断言脚本的编写范式
Postman的WebSocket测试脚本和REST请求的断言写法有部分相似,都是pm.test风格,但事件挂载的方式完全不同。REST请求是在请求完成后一次性执行Tests,而WebSocket是在Message received事件里为每一条消息触发一次。
以下是我在项目里常用的一套消息断言框架,已经按事件分类整理过:
// 连接成功事件:验证101与基础连接状态 ws.on('connected', function() { pm.test('WebSocket连接建立成功', function() { pm.expect(ws.status).to.eql(101); pm.expect(ws.isConnected()).to.be.true; }); }); // 消息接收事件:验证业务消息与鉴权状态 ws.on('message', function(data) { const msg = JSON.parse(data); pm.test('收到鉴权成功消息', function() { pm.expect(msg.type).to.eql('auth_success'); pm.expect(msg.code).to.eql(0); }); }); // 断开事件:验证关闭码是否符合鉴权失败预期 ws.on('disconnected', function() { pm.test('鉴权失败时以1008关闭连接', function() { pm.expect(ws.closeCode).to.eql(1008); }); });这套范式要注意两点。第一,ws.on('message')会在连接期间多次触发,如果你把"只该发生一次"的断言写成每次触发都执行,遇到服务端推送心跳消息时,测试会收到非预期数据导致误报。我习惯在事件脚本开头先做一个类型过滤,命中业务类型再执行断言,没命中就跳过。第二,pm.test里的断言失败不会阻止连接继续,它只是标记这条用例失败,连接还是会维持到手动断开或服务端断开。这对于观察完整交互过程是合理的,但你在批量跑用例时要理解"断言失败"和"连接中断"是两件事,别混为一谈。
5. 常见问题与排查技巧实录
5.1 握手直接401/403,怎么快速定位
握手阶段拿到的401一般是三种原因导致:Token真的无效、Token没有传递到服务端、服务端读取Header/Query参数的键名和你发送的不一致。
我的排查路径是这样:先在Postman的Console面板里打开请求日志,确认发出的握手请求里确实包含预期Header或Query参数。Console面板会显示实际发出的请求详情,这一步能排除"Postman配置和实际发送不一致"的问题。
接着看失败响应体的内容。大多数后端框架在鉴权失败时会写一段JSON说明原因,有些网关也会在响应头里加WWW-Authenticate字段。Postman的消息日志能看到响应头,但响应体内容有时只以摘要形式呈现,需要你切到Console里的原始报文去翻。
如果响应体完全没信息,可以用最笨的核对法:把你使用的Token拿到手,丢到类似在线JWT解析工具里看一眼payload,确认exp、nbf、aud这些字段是不是和当前时间匹配。我碰到过很多次"Token看起来有效但服务端时间比客户端快了几分钟"的情况,JWT的nbf字段一卡,整个请求全被拒绝。
5.2 连接建立成功但收不到任何消息
连接能建起来说明握手阶段是通过的,但收不到消息的原因可就多了。最常见的包括:
- 服务端要发消息但有延迟或没有触发条件。比如鉴权成功后服务端要等某个事件才推送,你在Postman里一直挂着,自然等不到。这种情况建议在连接建立后主动发一条业务消息或一条Ping帧,看服务端是否回应。
- 消息被代理/网关吞了。有些代理只处理HTTP Upgrade请求,对升级后的WebSocket数据帧支持不完整,导致数据发不出来或收不进去。你在本地直连测试发现正常、一接到测试环境就收不到消息,大概率就是链路中间环节的问题。
- Postman的消息日志默认只显示文本帧。如果服务端发的是二进制帧,内容里的可读性很差,你看着像没收到,实际上收到了但没正确解码。可以把日志格式切到Hex或JSON视图,确认二进制帧确实到达。
5.3 Postman WebSocket测试的局限性怎么补
Postman的WebSocket能力在日常调试和接口验证上是够用的,但它毕竟不是专门的长连接测试工具,有那么几个短板是客观存在的:
- 消息频率和并发能力有限。你要模拟几千个客户端同时建立连接做鉴权压测,Postman做不到,这种场景得交给专业压测工具。
- 断线重连的自动化程度不够。Postman没有内置"连接断开后自动重连"的选项,你要测试重连逻辑就得靠脚本自己监听断开事件再重新Connect。这个能实现,但相对繁琐。
- 历史消息的时序对比能力弱。当你需要按时间线回放和分析一段复杂的鉴权交互时,Postman的消息日志不如抓包工具那么结构化。
我的做法是:Postman承担日常功能性和鉴权正确性测试,特别是异常场景矩阵的回归;遇到深度的网络层问题时,配合抓包工具去做报文级分析。比如消息时序混乱、连接被RST、TLS握手异常这类问题,抓包工具能给出更强的证据链。两者分工明确,效率最高,也避免在Postman上反复尝试"钓"一个它根本展示不出来的信息。
6. 鉴权测试的进阶思路与工程化建议
6.1 把鉴权用例做成可重复执行的回归集
鉴权代码在业务迭代中非常容易被无意破坏,而破坏往往发生在你意想不到的改动上。比如有人给网关加了一层新的路由前缀,结果把WebSocket的Upgrade路径给挡了;有人升级了鉴权SDK,默认的Token校验策略变了,老客户端的Token全部失效。这些都是线上问题,但完全可以靠回归测试预先拦截。
我的建议是给WebSocket鉴权用例建一个独立的Collection,里面的每个请求都配上明确的测试标签,比如auth-success、auth-invalid-token、auth-token-expired、auth-missing-header、auth-wrong-origin。每个标签对应一类测试意图,跑的时候按标签执行即可。配合Postman的Runner或者命令行工具,在每次发版前把整个鉴权集跑一遍,成本低收益高。
6.2 工程化中的安全细节
WebSocket鉴权测试里有一个安全细节容易被忽略:Query参数携带Token时,Token会被完整记录在各种网关日志、代理日志和Postman历史记录里。如果你的测试环境日志被更多人接触到,Token泄露的风险是真实存在的。
我自己的习惯是:
- 测试环境里使用专用的测试账号和短时效Token,避免复用生产环境凭据。
- 对于只能在Header里带Token的服务,就坚持用Header方式测试,不用Query参数绕过去;对于支持多种方式的服务,Query参数用例只跑功能验证,不在日志链路里长期保留。
- 定期清理Postman历史记录和环境变量里的旧Token,尤其是使用共享工作区时,别让过期的、半有效的凭证长期驻留。
安全测试的初衷是保护系统,负责测试的人如果自己先制造了新的信息泄露点,那就违背初衷了。
6.3 从鉴权测试延展到全链路安全验证
鉴权验证只是WebSocket安全测试的一部分,但你把鉴权链路吃透之后,可以自然地往周边延展。比如握手时的Origin头校验:浏览器发起的WebSocket请求会带Origin头,服务端可以借此判断请求是否来自被允许的页面,这是一种轻量级的CSRF防护。你在Postman里可以手动修改Origin头的值,验证服务端是否真的做了来源校验,而不是只校验Token。
再比如心跳机制与鉴权的联动:很多服务端在连续几次心跳无响应时会主动断开连接,断开后客户端携带旧Token重连被拒。你把鉴权用例和心跳超时用例组合起来,就能覆盖"假死连接"这个高频线上故障场景。我实际排查过的一个事故就是设备在弱网环境下心跳超时被服务端踢下线,重连时却还在用过期Token,形成了"踢掉-重连-再踢掉"的死循环。这种问题单测鉴权或者单测心跳都发现不了,只有把两者放一起做组合验证才暴露得出来。
WebSocket鉴权测试这件事,核心不是学会Postman按钮怎么点,而是建立起一条完整的验证链路:从握手前的Token构造,到握手时的Header传递,再到连接后的消息级校验,最后到断开时的关闭码判定。每一条线上真实发生的鉴权事故,几乎都能在这条链路的某个环节找到突破口。希望这份实战指南能帮你把这些环节都提前卡住,别等到生产环境出事了再回头看日志。