
前阵子我把一个ESP8266温湿度计接进OneNET可视化面板HTTP上报请求却一直被拒返回体里写着token校验失败。我前后折腾了两天一开始以为模块固件问题后来把请求日志一帧帧拆开看才确认问题全出在Token生成这一环参数顺序、时间戳精度、URL编码任何一个细节不对平台就不认。这篇东西就是那次排错过程的完整复盘给准备用ESP8266/ESP32直连OneNET、或者打算直接调OneNET API做后台的同学做个参考。OneNET的Token机制和很多平台不太一样它不查数据库不依赖会话状态全靠一串自包含的字符串完成鉴权。正因如此它的灵活性很高但代价就是如果你不了解它的生成规则排查起来会非常痛苦。1. 为什么接入OneNET的第一道坎恰恰是Token生成1.1 我遇到的具体报错HTTP请求被拒我的硬件侧很简单ESP8266开WiFi连接采集DHT11温湿度然后通过HTTP POST把数据推给OneNET。代码逻辑看着没问题WiFi也早连上了但POST请求返回的HTTP状态码始终不对平台返回的响应体里面明确写着token校验失败。刚开始我完全没有头绪。因为代码是从一个老教程里改的教程里的请求头用的是APIKey方式而我手上的项目已经在用新版Token方式了。我把请求打出来发现我发过去的token字符串很长里面满是version2018-10-31resproducts/...et...这类结构潜意识里觉得这不就是照着格式拼的吗总不会错吧。结果恰恰是这些参数在细节上没对齐。我后来把生成Token的代码单独拎出来用一个最小测试程序在电脑上跑一次就复现了失败。这也说明一个问题Token的坑通常不在你不会拼而在你拼的时候参数含义没吃透。1.2 Token和API Key、Session到底有什么不同很多做嵌入式开发的朋友对API Key比较熟对Token反而陌生。简单说API Key是一把长期有效的固定钥匙你把它放在请求头里服务器一看就知道你是谁。而Token更像你用钥匙去自助机上换出来的临时通行证有效期由你自己定服务器只认这个通行证。OneNET用的token是一种自描述字符串平台收到请求后并不需要去数据库里查你这个token是啥它只需要根据token里的参数、加上它自己保存的密钥重新算一遍签名跟你传上来的签名做对比。验签通过请求就放行验签不通过直接返回错误。这种设计有个好处服务端无状态扩展起来很轻松。坏处也很明显任何一端参与签名的参数不一致另一端就算把你拒了也不会告诉你哪一个字段不对。所以我排错的时候只能自己把整条链路查一遍。2. Token不是玄学先把res、et、method、sign四个参数对齐2.1 参数逐一拆开看我这边实际接触的平台版本Token生成需要四个核心参数加一个版本标识。这里直接给一个可运行的Python示例代码本身不复杂复杂的是参数背后的含义import hashlib import time def make_onenet_token(res, api_key, expire_seconds3600): # et 是过期时间必须是Unix秒级时间戳 et int(time.time()) expire_seconds # 签名原文按官方要求顺序拼接et 在前res 居中api_key 垫底 sign_src f{et}{res}{api_key} sign hashlib.md5(sign_src.encode(utf-8)).hexdigest() token fversion2018-10-31res{res}et{et}methodmd5sign{sign} return token if __name__ __main__: res products/你的产品ID api_key 你的APIKey或AccessKey print(make_onenet_token(res, api_key))我把参数列表整理成了下面这张表排错的时候对照着看会清楚很多参数含义最容易踩的坑version协议版本标识不要自由发挥改成别的值版本不同解析规则可能不同res资源标识产品ID、设备ID填错或漏掉斜杠注意区分产品和设备维度et过期时间单位是秒不是毫秒别用本地时间格式化字符串method签名算法通常为md5注意小写sign最终签名值必须是十六进制小写字符串不能大写不能加前缀2.2 时间戳秒级还是毫秒级一切错误的源头这是我最先踩中的坑而且我怀疑很多人都会栽在这里。Token里的et虽然叫过期时间但它要的是Unix时间戳的秒数而不是毫秒。有次我图省事从JavaScript那边复制了一段Date.now()的逻辑把毫秒级的数值直接塞进了et。结果是平台解析token的时候发现et是一个好几位的大数直接判定为非法时间范围然后返回token校验失败。道理很简单平台是按秒来算的你把毫秒喂进去它算出来的当前时间和你的过期时间对不上号。正确写法是int(time.time()) 过期秒数千万别再乘1000。还有一个细节过期时间不能设得太短。我之前为了安全把过期时间设成了60秒结果设备从开机、连WiFi、再做NTP同步前前后后花了几十秒等它真正发请求的时候token早就作废了。后来我统一用3600秒一小时过期既不会太长也足够设备从容完成整个启动流程。2.3 签名字符串拼接顺序错了签名就是废纸签名部分是最让人头大的。MD5本身不难难在拼接顺序。我看到过好几种在网上流传的写法有et res key的也有res et key的还有把version也拼进去的。这里我没有偷懒的办法可走只能以你所在平台的官方文档生成的示例代码为准。我最后是把控制台里给的示例请求完整复刻了一遍确保拼接顺序和签名格式和平台期望完全一致才把问题解决。给一个我排查时用的笨办法但确实有效先用固定的一组res和api_key手动拼出et res api_key这个原始字符串。把这个字符串原封不动地贴到一个在线MD5工具或者本地Python里算出MD5值。把这个MD5值和你想提交的sign做对比如果顺序写反了一眼就能看出来是哪里对不上。这个方法的神奇之处在于它会逼你把参与签名的原始字符串看成一段普普通通的文本而不是一堆变量。你会很清楚地看到到底是1700000000products/xxx你的key这种格式还是别的什么格式。格式一旦对了后面就顺了。2.4 URL传递环节斜杠和符号也会改变Token含义Token生成对了不代表请求就能过。还有一个隐蔽的坑在URL传递环节Token里天然包含、、/这些字符如果你直接把它拼进URL的query参数里服务器的解析器会把token拆得七零八落。正确姿势是用URL编码把整个token包起来。比如Python里这样处理import urllib.parse token make_onenet_token(res, api_key) params {token: token} # 交给requests库它会自动处理URL编码 resp requests.get(https://你平台提供的API地址, paramsparams)如果你习惯用curl也是类似思路让curl帮你做编码curl -G https://你平台提供的API地址 --data-urlencode token你的token这里请特别注意参与签名的那段明文必须是未编码的原始字符串。我第一次就是在生成token之前先把res里的斜杠做了编码结果products/xxx变成了products%2Fxxx签名虽然稳定生成了但平台那边解码出来后和我算的永远对不上前前后后又白折腾了一个多小时。3. Token失效排查一次真实鉴权失败背后的完整链路3.1 第一步在PC上复现把设备端变量排除掉遇到Token失效我建议你第一件事不是翻设备代码而是先在PC上复现一次。设备端的不确定因素太多了WiFi不稳定、板载库版本不同、内存不足、时间没同步随便哪一个都能让你误判问题出在Token上。我当时的做法是把生成Token的Python函数单独拎出来在电脑上生成一个完整token然后用同一个HTTP请求工具发出去。PC的系统时间通常是同步好的网络也稳定如果这一步还失败基本就能断定是Token的生成逻辑或者平台配置出了问题跟设备无关。3.2 第二步对比时间设备时间差几秒都可能致命PC复现没问题之后再把同一套逻辑搬到ESP8266上结果又失败了。这个时候我开始怀疑设备端时间。在嵌入式环境里一个很容易被忽略的事实是单片机上电之后如果没有外部RTC芯片或者NTP同步它的系统时间是1970年1月1日。你在这种状态下生成的tokenet其实就是1970年之后的3600秒平台一看这个token早过期了直接拒绝。解决办法很简单让设备先通过NTP获取正确时间再生成token。// ESP32 Arduino环境下 #include time.h configTime(8 * 3600, 0, ntp.aliyun.com, pool.ntp.org); // 等待时间同步成功 time_t now time(nullptr); while (now 100000) { delay(500); now time(nullptr); }注意一点configTime里的第一个参数是时区偏移这里写8 * 3600是北京时间。但Token签名的et用的是Unix时间戳它是全球统一的绝对时间不受时区影响。所以设备上时区设不设对并不影响token是否有效只影响你人眼看到的本地时间。这个区别我建议在心里记清楚排查时能少绕很多弯。3.3 第三步打印明文签名串和平台期望对表时间同步好了token还是会失效。这时候我学到的下一个教训是别盯着十六进制的MD5值看要看完整的明文签名串。我在ESP32上用一个演示代码说明。设备端把et、res、api_key拼起来之后先打印这个原始字符串再打印最终的token。日志看起来像这样[DEBUG] sign_src1735689600products/1234567890abcdef [DEBUG] tokenversion2018-10-31resproducts/1234567890abcdefet1735689600methodmd5signxxxx...你亲手打印之后才能发现一个非常经典的问题有些语言在拼接数字和字符串的时候会偷偷塞进一个空格。比如String(et) res在某种库实现下中间可能多出一个空格这个空格肉眼几乎看不出来但MD5结果完全不同。打印出来对照一下这个问题立刻就暴露了。另一个经典问题是先编码后签名。有人为了让token能被URL正确解析先把整个res做了URL编码再拿去签名。结果URL是正常了平台那边却拿解码后的products/123456去重算签名两边sign永远对不上。这事的根源就是编码层和签名层概念混在了一起。签名必须在原始字符串上进行URL编码只发生在最终传输阶段。3.4 第四步确认请求入口和平台环境匹配如果上面几步都没问题那就要看看你用的请求入口对不对了。OneNET有几种接入方式MQTT、HTTP、LwM2M等。HTTP API里可能还有不同版本的接口地址不同接口对token的传递位置也可能不一样有些要求放在query参数里有些要求放在请求头里有些要求作为MQTT的password传输。我自己就踩过一次把HTTP接口用的token带进了MQTT连接的password字段结果自然是认证失败。后来我把请求入口、请求头、端口号、传输方式全部核对了一遍才意识到不是token错而是token被用在了错误的地方。建议你直接从控制台里复制平台提供的示例链接不要用旧教程里写的接口地址。平台升级后老接口是否还支持、是否还需要额外的签名参数这些都是未知数。4. 换到ESP8266/ESP32上Token的问题会翻倍暴增4.1 板载环境最大的三个变量MD5库、时间源、字符串处理同样的逻辑在PC上跑得好好的挪到单片机上就可能出幺蛾子。我总结了三个最容易出问题的地方第一个MD5库。PC的Python自带hashlib一行搞定单片机上你需要依赖板级库。有的库返回的是大写十六进制有的库返回的小写还有的库需要你自己把二进制摘要转成字符串。如果最终拿到的sign跟你用Python算的不一致优先检查这里。OneNET验签要求的是小写十六进制不能带0x前缀。第二个时间源。前面已经提到了单片机默认时间是1970年必须NTP同步。第三个字符串拼接。C/C环境里int转String或者char[]时有各种隐性细节。比如有的环境下直接把long型时间戳拼进String可能变成科学计数法字符串签名算出来自然不对。稳妥的写法是显式格式化char etBuf[16]; snprintf(etBuf, sizeof(etBuf), %ld, (long)et); String signSrc String(etBuf) res api_key;4.2 设备时间必须从NTP来本地计时器绝对不可靠有人会想既然Token有3600秒过期我能不能在设备上电时手动设置一个固定的et基准时间然后自己计时比如et 1700000000 3600然后靠millis()来判断过期这个思路在离线设备上勉强能用但在线设备千万别这么干。因为平台校验的是绝对时间它拿自己的当前时间跟你token里的et做比较。你的设备如果基准时间本身就差好几个小时那token一出生就已经过期了。所以我的建议是只要设备能联网就老老实实做一次NTP同步。NTP地址建议用国内的公共NTP服务器比如ntp.aliyun.com响应速度比国际服务器快不少。同步完再打印一次time(nullptr)确认数值是当前的Unix时间戳再谈token生成。4.3 ESP32上稳定生成Token的逻辑骨架下面这段代码是逻辑示意核心是演示整个流程的顺序同步时间、拼原始串、算MD5、拼token。实际使用时请以你在用的板级库为准#include time.h #include mbedtls/md.h String md5Hex(const String data) { unsigned char out[16]; mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_MD5), (const unsigned char*)data.c_str(), data.length(), out); String ret; for (int i 0; i 16; i) { char tmp[3]; snprintf(tmp, sizeof(tmp), %02x, out[i]); // 强制小写hex ret tmp; } return ret; } String buildOneNetToken(const String res, const String apiKey, long expireSeconds) { time_t now time(nullptr); long et (long)now expireSeconds; char etBuf[16]; snprintf(etBuf, sizeof(etBuf), %ld, et); String signSrc String(etBuf) res apiKey; String sign md5Hex(signSrc); String token version2018-10-31res res et String(etBuf) methodmd5sign sign; return token; }如果你用的是W5500这类以太网模块思路完全一样只是NTP请求走的是以太网通道。核心照样是先把系统时间同步好再生成token。以太网方案没有WiFi的配置步骤反而少了一个变量但NTP同步这个环节省不掉。5. 让Token机制稳定运转的几条实操经验5.1 Token生成函数独立封装别埋在请求代码里这是一种编程习惯但在给设备写联网代码时尤其重要。我自己早期喜欢在HTTP请求函数里顺手拼token结果每次排错都要把整个请求流程重新翻一遍。现在我要求自己必须把Token生成拆成独立函数或独立脚本。在PC端甚至可以做成一个定时任务每30分钟生成一个新的token写入文件设备启动时只负责读取不负责计算。这种做法的好处是如果token出问题你可以直接换文件再试不用重新烧固件。当然设备端如果每次都做NTP、再现场算token也不是不行就是多花几毫秒而已。但我倾向于在树莓派或者服务器这类强设备上生成好让ESP8266这种弱设备只做很轻的计算尽量减少运行时的不确定因素。5.2 请把日志打完整明文、过期时间、返回体这是我踩过无数次坑以后总结出的最实用经验。很多人在设备上只打印HTTP状态码比如HTTP 401然后就没有然后了。401能告诉你的信息非常有限它可能是token过期也可能是签名错误甚至可能是请求头拼错。正确的日志姿势是在发送请求前打印这几样[INFO] tokenversion2018-10-31resproducts/xxxet1735689600methodmd5signxxxx... [INFO] remain_seconds3520 [ERR] response_body{code:440,message:token invalid}remain_seconds这个字段尤其好用。它等于et减去当前时间戳如果打出来是个负数就说明设备本地时间根本没同步成功或者token已经过期了。如果这个数是正的但平台还是拒绝你再去检查签名和URL编码也不迟。5.3 多设备多API场景下的权限和刷新节奏如果你管理的不止一个设备那Token的坑还得再深一层。我的经验是不同设备的res不能混用。你用一个设备的token去请求另一个设备的数据平台验签时发现资源和签名对不上直接拒绝。还有一点容易被忽略OneNET的API Key / AccessKey如果在后台重置了那么所有基于这个key生成的token会立刻全部失效。这不是bug是安全机制。我有一回在控制台不小心点了个重置结果手底下好几台设备同时开始报token校验失败排查了半天才发现源头在这里。所以我现在的习惯是API Key权限设置尽量小只绑定当前项目和必要设备但凡修改过key就同步跑一遍所有设备端的token刷新脚本避免一台一台去坑里捞。我个人现在做联调第一步永远是打开日志看两样东西signature明文和剩余有效秒数。这两样确认没问题再去查网络和设备连接。Token其实是最容易排查的一环因为它完全由几个参数决定参数列清楚对应关系对得上平台卡你的地方也就那几个。后来我又接了几个不同品牌的云平台发现这套打印明文签名串、对比时间戳、检查编码环节的思路完全是共通的建议大家把这种排错姿势固定下来能省很多无头苍蝇式的折腾。