做接口自动化测试这么多年,我一直觉得登录态处理是整套用例里最绕不开又最容易被忽略的一环。尤其后端把登录密码做了 RSA 加密、登录成功返回 token、后续每个接口都要在 header 里带 token 这种链路,听起来不复杂,但真正在 Apifox 里落地时,很多人会卡在前置脚本怎么写、加密库从哪来、token 怎么共享这几个点上。这篇文章就把我实际跑通的这套方案完整拆开讲清楚,从 RSA 加密密码的前置脚本,到发送登录接口拿 token,再到把 token 塞进后续请求 header,全程基于 Apifox 最新版的脚本能力,适合正在做接口测试、需要处理登录鉴权链路的同学直接参考复现。
1. 场景与需求拆解:为什么绕不开 RSA 加密和 token
1.1 登录接口为什么要做 RSA 加密
以前做接口测试,登录接口大多是明文密码直接提交,或者做个简单的 Base64 就算完事。现在稍微正规一点的后端,密码传输基本都是 RSA 加密。RSA 是非对称加密,服务端持有私钥,前端或者客户端持有公钥,登录时用公钥把密码加密后传给后端,后端用私钥解密验证。这么做的好处是即使请求被抓包,中间人拿到的也只是密文,没有私钥根本还原不出原始密码。
你可能会问,HTTPS 本身就加密了,为什么还要在业务层再做一次 RSA?我见过不少项目这么干,核心原因有两个:一是部分老系统的 HTTPS 配置不严格,存在降级风险,业务层加密能多一道防线;二是网关层或安全审计需要看到请求内容时,密码不能被明文暴露。所以在 Apifox 里做登录接口测试,第一步就是模拟前端的 RSA 加密逻辑。
但这里有一个很容易踩的坑:RSA 加密每次生成的密文都不一样。因为标准 RSA 加密算法里带了随机填充(比如 PKCS#1 v1.5 或 OAEP),同样的明文,两次加密结果不同,这是正常现象,后端每次都能正确解密。很多人在 Apifox 里发现两次加密结果不一样,以为脚本写错了,其实不是。
1.2 token 在接口测试里的完整链路
登录成功之后,后端一般会返回一个 token,这个 token 可能放在响应体的 data 字段里,也可能放在 header 的 Authorization 里,还有可能是单独的 Set-Cookie。拿到 token 之后,后续所有需要鉴权的接口都要在请求头里带上它,常见格式是Authorization: Bearer <token>,也有项目习惯用自定义 header 比如X-Token或者token字段。
所以完整链路是三步:
- 第一步:执行前置脚本,用 RSA 公钥加密登录密码,替换请求 body 里的密码字段。
- 第二步:发送登录接口请求,从响应里解析出 token。
- 第三步:把 token 存入环境变量或全局变量,后续接口在前置脚本或 header 模板里引用它。
在 Apifox 里,这三步分别对应前置脚本、断言/脚本、环境变量管理。重点是怎么让这三个环节自动串联,而不是每次手动把 token 复制粘贴到下一个接口。下面我按实际操作顺序,把每一步的关键代码和细节讲清楚。
2. RSA 加密前置脚本:从原理到 Apifox 落地
2.1 Apifox 脚本环境里用什么做 RSA 加密
Apifox 的脚本运行在沙箱环境里,内置了一些常用的 JavaScript 库,其中就包括JSEncrypt和crypto-js。JSEncrypt 是前端 RSA 加密最常用的库,API 简单,支持 PKCS#1 v1.5 填充,适合对接大多数 Java、Go、Node.js 后端的 RSA 解密逻辑。
用的时候直接在前置脚本里require('jsencrypt')引入即可,不需要额外安装依赖。如果你们的后端用的是 OAEP 填充,或者需要分段加密(比如超长密码或敏感信息),JSEncrypt 可能不够用,这时候可以用 Node.js 内置的crypto模块,Apifox 脚本环境也支持。我建议优先用 JSEncrypt,因为大多数场景够用,代码写起来最直观。
这里先看一个最基础的 JSEncrypt 加密代码:
// 前置脚本:使用 JSEncrypt 加密密码 // 获取环境变量里的公钥 const publicKey = pm.environment.get('rsa_public_key'); // 从请求 body 中读取原始密码 const password = pm.request.body.get('password'); // 创建 JSEncrypt 实例 const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); // 加密密码 const encryptedPassword = encryptor.encrypt(password); // 把加密后的密码写回请求 body pm.request.body.update({ password: encryptedPassword });这段代码看着简单,但有几个细节必须注意。第一,pm.request.body.get('password')只能用在 body 是application/x-www-form-urlencoded或直接能解析成对象的情况下,如果你的请求体是 JSON 字符串,需要先用JSON.parse解析。第二,pm.environment.get('rsa_public_key')里的公钥必须是完整的 PEM 格式字符串,包括-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----这两行,少了任何一行,JSEncrypt 都会报错。
2.2 公钥从哪来、格式不对怎么办
公钥一般由后端同学提供,通常是放在一个接口里返回,或者写在项目文档里。但很多同学拿到的公钥格式五花八门,常见的有这两种:
- PKCS#1 格式:
-----BEGIN RSA PUBLIC KEY----- - PKCS#8 格式:
-----BEGIN PUBLIC KEY-----
JSEncrypt 对 PKCS#8 格式支持比较好,大多数情况下你直接把后端给的公钥粘进环境变量就能用。如果后端给的是 PKCS#1,而 JSEncrypt 解析报错,可以试试下面这个转换思路:让后端直接给 PKCS#8 格式,或者用在线工具转换。注意这个工具选择一定要用本地或者可信的转换方法,不要把公钥粘贴到来路不明的网站,公钥虽然是公开的,但涉及安全卫生习惯还是严谨点好。
还有一个高频问题:公钥里带有换行符,存到 Apifox 环境变量时被压缩成一行,导致解析失败。解决办法很简单,环境变量里可以正常保存多行文本,你粘贴时保留原始换行即可。如果担心复制过程中换行丢失,可以在脚本里手动拼换行符:
const publicKey = pm.environment.get('rsa_public_key'); // 如果公钥被压缩成一行,手动还原 const pemHeader = '-----BEGIN PUBLIC KEY-----'; const pemFooter = '-----END PUBLIC KEY-----'; const base64 = publicKey.replace(pemHeader, '').replace(pemFooter, '').replace(/\s+/g, ''); const fullPublicKey = `${pemHeader}\n${base64.match(/.{1,64}/g).join('\n')}\n${pemFooter}`;这段代码能把连续的 Base64 字符串按 64 个字符一行重新格式化,这样不管环境变量里存储时换行是否丢失,都能得到一个标准 PEM 格式的公钥。我在实际项目中遇到过一次公钥从配置中心导出时被压缩成一行的情况,当时就是用这个办法解决的。
3. 从登录接口获取 token:前置脚本里实现请求串联
3.1 在登录接口的前置脚本里发送登录请求
这里有一个很多人没转过弯来的点:我们现在本身就在测登录接口,为什么要在一个请求的前置脚本里再发一次登录请求?其实这个思路不是用来测登录接口本身的,而是用来给其他接口做前置鉴权的。比如你有几十个业务接口都需要登录态,不想在每个接口的前置脚本里各写一套登录逻辑,就可以用一个专门的“登录辅助请求”放在业务接口的前置脚本里,或者把登录接口单独拿出来,在测试集合级别配置“前置脚本”统一执行。
Apifox 里实现这个能力的关键是apifox.sendRequest,这个 API 是同步发送请求的,和pm.sendRequest的异步方式不同。同步意味着脚本会一直等待响应返回,再继续执行下面的代码,这对“先登录、后取 token、再改请求头”这种顺序依赖的场景特别合适。我实测下来,apifox.sendRequest在断言脚本和前置脚本里都能用,非常稳。
下面是一个完整的登录请求前置脚本写法:
// 前置脚本:在业务接口请求前,先登录获取 token // 构造登录请求 const loginRequest = { url: pm.environment.get('base_url') + '/api/auth/login', method: 'POST', header: { 'Content-Type': 'application/json' }, body: { mode: 'raw', raw: JSON.stringify({ username: pm.environment.get('login_username'), password: '123456' // 这里会做 RSA 加密 }) } }; // 对密码做 RSA 加密 const publicKey = pm.environment.get('rsa_public_key'); const encryptor = new JSEncrypt(); encryptor.setPublicKey(publicKey); const rawBody = JSON.parse(loginRequest.body.raw); rawBody.password = encryptor.encrypt('123456'); loginRequest.body.raw = JSON.stringify(rawBody); // 同步发送登录请求 const loginResponse = apifox.sendRequest(loginRequest); // 解析响应,提取 token const responseData = loginResponse.json(); const token = responseData.data.token; // 把 token 存入当前环境 pm.environment.set('auth_token', token); console.log('获取到 token:', token);这段代码完整覆盖了登录到取 token 的过程。注意几点:第一,登录请求的 URL 我用了环境变量base_url,不要把域名写死在脚本里,否则换环境跑用例时你会改到怀疑人生。第二,密码加密的逻辑和上一节一样,先构造原始请求体,再加密替换,最后重新序列化。第三,loginResponse.json()假设响应是 JSON 格式,如果后端返回的不是标准 JSON,你可以改用loginResponse.text()然后自己截取。
3.2 token 解析和写入环境变量的避坑经验
解析 token 是另一个容易出问题的地方。不同后端的 token 返回结构差异太大,有的在data.token,有的在data.access_token,有的直接整个响应就是一个裸 token 字符串,还有的放在响应 header 的Authorization里。我的习惯是先用console.log把整个响应体打出来,确认结构后再写解析代码。
比如响应长这样:
{ "code": 0, "data": { "token": "eyJhbGciOiJIUzI1NiIs..." }, "message": "success" }那解析代码就是const token = responseData.data.token;。
如果返回结构复杂,比如 token 要自己拼接,或者需要从响应头里取,可以这样处理:
// 从响应 header 里取 token(示例:Authorization 头) const authHeader = loginResponse.headers.get('Authorization'); const token = authHeader.replace('Bearer ', ''); // 或者从 Set-Cookie 里取 const setCookie = loginResponse.headers.get('Set-Cookie'); const token = setCookie.split(';')[0].split('=')[1];token 入库之后,建议顺手设置一个过期时间。Apifox 环境变量没有内置过期机制,但你可以在脚本里记录一个时间戳,下次请求时判断是否过期,过期就重新登录。这个技巧对长跑任务特别有用,不然 token 失效后,后面的请求会全部 401,你还得手动重新跑登录。
// 保存 token 和获取时间 pm.environment.set('auth_token', token); pm.environment.set('auth_token_time', Date.now()); // 其他接口前置脚本里判断是否过期(比如 token 有效期 2 小时) const tokenTime = pm.environment.get('auth_token_time'); if (!tokenTime || Date.now() - tokenTime > 2 * 60 * 60 * 1000) { // 重新登录逻辑 }这里的时间判断逻辑最好抽成一个公共脚本,Apifox 支持“公共脚本”功能,把常用的登录、RSA 加密、token 刷新逻辑放到公共脚本里,各个接口直接调用,维护起来省心得多。
4. 请求接口 header 里带上 token:两种主流方式
4.1 方式一:直接在请求头里引用变量
最简单的方式是手动在接口的 header 里添加一个键值对,值直接引用环境变量,比如:
- Key:
Authorization - Value:
Bearer {{auth_token}}
Apifox 会自动替换{{auth_token}}为环境变量里存储的真实 token。这种方式的好处是直观、不需要写任何脚本,适合接口数量少、token 过期不频繁的项目。
但这种方式有个明显的局限:如果 token 放在环境变量里,而你的环境变量值在脚本里是被动态更新的,那接口发送时会自动取最新值吗?答案是会。Apifox 在发送请求时会对变量做一次渲染,所以只要auth_token这个环境变量的值已经更新,即使你之前手动引用过{{auth_token}},也会用最新的值。
不过如果 token 不是放在环境变量里,而是放在全局变量里,或者你需要在请求头发送前做额外处理(比如加签名、拼接多个 token),手动引用就有点力不从心了。
4.2 方式二:前置脚本动态设置请求头
更灵活的方式是在请求接口的前置脚本里动态设置 header。用pm.request.headers.upsert这个方法,可以新增或覆盖一个请求头。这个方法的好处是可以在设置 header 的同时做逻辑判断,比如判断 token 是否存在、是否过期,如果异常就走重新登录的分支。
// 前置脚本:动态设置请求头 token const token = pm.environment.get('auth_token'); if (!token) { // 如果没有 token,触发登录流程 // 可以把登录逻辑抽到这里调用,这里简单打印日志 console.error('未获取到 token,请先执行登录接口'); } else { pm.request.headers.upsert({ key: 'Authorization', value: 'Bearer ' + token }); }如果你的项目用的不是标准的 Bearer Token,而是自定义 header 名,比如X-Token、X-Access-Token,或者要求同时带多个 header,写法是一样的,只是把 key 换成对应的名称。upsert这个方法的语义是“有则更新,没有则新增”,所以即使接口模板里已经手动配过这个 header,也会被脚本里的值覆盖。
还有一类特殊场景:后端要求 header 里的 token 也做一层加密,或者要求把 RSA 加密后的密码作为 header 传递。这时候前置脚本的逻辑会复杂一点,但套路不变:先取原始值,加密,再用upsert写进去。
4.3 多个环境切换时 header 的隔离策略
我强烈建议在 Apifox 里把不同环境的auth_token分开存储。比如dev环境和test环境的 token 不要混用,否则你从 dev 切到 test,环境变量自动加载的是 test 的 token,但如果你不小心在全局变量里存了一份 dev 的 token,接口发送时可能会因为优先级问题用到全局变量的值,导致鉴权失败。
Apifox 变量优先级大致是:请求参数里手动写的值 > 环境变量 > 全局变量。所以当你手动在 header 里引用{{auth_token}}时,如果环境变量里没有,但全局变量里有,会自动取全局变量的值。这个机制有时候会掩盖问题:你以为用的是环境变量,实际用的全局变量。排查 header 鉴权失败时,第一件事就是分别检查环境变量和全局变量里有没有同名变量。
我在一次联调中踩过这个坑:环境变量里设置了auth_token,全局变量里也残留了一个旧的auth_token,后端的 token 校验逻辑能看到两个值吗?看不到,Apifox 只会选一个。当时我百思不得其解,为什么明明登录成功了,接口还是 401,后来把全局变量清空,一切正常。所以建议在项目里规定:token 这类敏感动态数据只放环境变量,全局变量只放真正的公共静态数据,比如固定的 app_id。
5. 常见问题与排查技巧实录
5.1 RSA 加密报错、token 解析失败的几个高频问题
我把这段时间后台收到的和身边同事问过的问题汇总一下,做成一个速查表,方便你们对号入座。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
encrypt返回 false 或空字符串 | 公钥格式不对,或公钥包含不可见字符 | 检查公钥是否为标准 PEM 格式,去掉多余空格和换行,或用手动换行代码格式化 |
提示jsencrypt is not defined | 脚本环境里没有正确引入库 | 确认使用require('jsencrypt'),Apifox 内置支持,不需要安装 npm 包 |
| 每次请求第一次能成功,第二次就 401 | token 存到了全局变量,被旧值覆盖 | 检查全局变量和环境变量是否有同名 token,统一用环境变量 |
| 登录响应解析出来 token 是 undefined | 返回结构和你写的不一致 | 先用console.log(JSON.stringify(responseData))打印真实结构,再定位字段 |
| 请求头发送了,但后端说没收到 token | header 名不对或大小写问题 | 确认后端要求的 header 名,有的框架大小写敏感 |
提示apifox.sendRequest is not defined | 在旧版 Apifox 或非脚本上下文使用 | 升级 Apifox 到最新版,确认在脚本编辑区域调用 |
5.2 一次真实的 token 失效排查过程
讲一个我印象很深的排查案例。当时我在做一个用户中心接口的回归测试,用例跑着跑着突然从第 40 个接口开始全部返回 401。第一反应是 token 过期了,但我设置的 token 有效期是 24 小时,不应该这么快过期。重新跑登录接口,token 也正常。后来我在前置脚本里加了日志,发现一个现象:前面 39 个接口用的是新 token,第 40 个接口开始,环境变量里的 token 变成了另一个值。
顺着这个线索继续查,发现项目里存在两个环境:dev和test。我当时用的是dev环境跑用例,但某个前置脚本里写死了pm.environment.set('auth_token', xxx),这个pm.environment只操作当前环境。问题出在另一个接口的断言脚本里,有人写了pm.globals.set('auth_token', oldToken)。由于环境变量和全局变量同名的优先级问题,第 40 个接口发送时,Apifox 优先取了环境变量,按理说应该没问题。但诡异的是,那个接口的请求头里没有写{{auth_token}},而是写死了token字段,导致它永远用的是全局变量里的旧值。
最终结论是:不要在请求头里写死 token,也不要混用全局变量。统一用环境变量 + 动态脚本注入,问题就消失了。这个案例给我的教训是,接口测试的鉴权链路看起来代码量不大,但变量作用域一乱,排查起来非常耗时。
5.3 批量接口循环调用时的 token 与 header 注意事项
如果你的集合里做了数据驱动,需要循环调用同一批接口,每个循环都带同样的 token,那要注意一个隐藏问题:前置脚本会在接口发送前执行,如果循环次数很多,登录逻辑也会跟着执行多次,这会平白增加登录接口的压力。更合理的做法是只让第一个接口前置脚本去取 token,或者在公共脚本里加一个判断:如果环境变量里已有未过期的 token,就直接复用,不重复登录。
我之前做过一个 500 次循环的性能测试,为了模拟真实用户,需要每个循环都用同一个 token 但不同参数。如果每个循环都重新登录,登录接口会成为瓶颈,测试结果完全失真。后来我在前置脚本里加了 token 存在性判断,只有第一次循环才会真正发登录请求,后面都直接复用。这个优化对压测场景很有用。
// 循环调用的前置脚本:token 存在就直接用,不存在才登录 if (pm.environment.get('auth_token')) { pm.request.headers.upsert({ key: 'Authorization', value: 'Bearer ' + pm.environment.get('auth_token') }); return; } // 不存在则走登录逻辑(这里省略登录代码) // ...还有一点,如果接口响应里出现了request header is too large的报错,别光想着是不是 token 太长了。JWT 类型的 token 确实会比其他 token 长不少,但一般也没到撑爆 header 的程度。优先检查是不是某个前置脚本多次调用了pm.request.headers.add,导致同一个 header 被添加了 N 遍。add方法不做去重,upsert才会。所以统一用upsert能避免这类重复 header 的问题。
5.4 不同后端框架对 header 命名的兼容性
最后聊一个偏门但实用的点:后端框架对 header 的解析策略不一样。比如 Spring Cloud Gateway、Nginx、Node.js 的http-proxy这些组件,有的会统一把 header 名转为小写,有的则保留原样。如果你在 Apifox 里配的 header 是Authorization,抓包时发现变成authorization,不要慌,这是正常的兼容处理,后端能正确识别。
但如果后端是自定义网关,要求 header 名必须精确匹配,那你必须严格按后端文档写。我见过一个案例,后端要求x-auth-token,测试人员在 Apifox 里写成了x-auth-Token,后端识别不了,一直报签名错误。这种问题用肉眼很难发现,建议在后端文档里直接复制 header 名。
另外,如果你在 header 里传递的是带特殊字符的值,比如加密后的密码或 token 中含+、=、/这类字符,Apifox 默认不会对 header 值做 URL 编码,这是对的。千万别手动编码,否则后端解出来的值和原始值不一致。我有一次在别的工具里踩过这种坑,Apifox 在这块的处理我觉得反而更符合预期。
6. 最后再分享一个我常用的调试技巧
前置脚本调试最怕的就是“看不到过程”。我的习惯是在脚本关键位置加console.log,Apifox 的响应面板里有一个“控制台”可以查看这些日志。比如 RSA 加密完成后,先打一条日志看看密文长度是否正常(一般 RSA 加密后的 Base64 字符串长度在 170~300 字符之间),如果长度异常,大概率是公钥格式问题。登录响应解析前,先打一条响应全文,确认字段路径,不要盲写解析代码。
等脚本稳定之后,再把这些调试日志注掉,或者在关键位置保留一条简短的日志,方便以后排查。我自己会在正式用例里保留 token 获取成功和 token 失效这两条日志,遇到问题打开控制台一眼就能看到是哪一步出了问题。如果你们项目里有同事刚接触 Apifox,我建议把上面这套 RSA 加密登录、token 获取、header 注入的逻辑整理成一个公共脚本模板,直接放到团队的知识库里,大家照着改就行,能少走不少弯路。