ONVIF 的鉴权失败时 HTTP 状态码仍然是 200,错误放在 SOAP Fault 里。 所以「看 HTTP 码判断成功」这套经验在这里完全不适用——这也是新人最常踩的坑。
08.01.05 讲了「生成期怎么埋鉴权结构」,这一节是「运行期怎么填」。 两者是同一件事的两半:没有前半,后半无处可写;没有后半,前半是摆设。
一、令牌放哪儿、密码怎么传
| 问题 | 答案 | 能不能变 |
|---|---|---|
| 令牌放哪儿 | s:Header 里的 wsse:Security |
不能,固定 |
| 密码怎么传 | 明文或摘要 | 能,两种模式 |
| 用户名放哪儿 | wsse:UsernameToken |
不能 |
第一行那个「固定」是 SOAP 的规矩:
Header 里放「消息元信息」,Body 里放「业务内容」——鉴权属于元信息,所以必须在 Header。
第二行那个「两种模式」就是 PasswordText 与 PasswordDigest:
前者直接把密码写进去,后者写 Base64(SHA1(nonce+Created+密码))。
明文模式在公网上等于把密码贴出来,
但在内网调试时它有个好处:设备日志里能直接看到密码对不对。
所以「调试用明文、上线用摘要」是个合理的工作方式——前提是上线前真的改回来。
二、四种情况的实测结果
| 情况 | 请求字节 | 结果 |
|---|---|---|
| 不带令牌 | 424 | Fault |
| 带令牌但密码错 | 1113 | Fault |
| 明文密码 | 1091 | 成功 |
| 摘要密码 | 1113 | 成功 |
第一行和第二行都是 Fault,但原因不同:前者是「没令牌」,后者是「令牌不对」。
而设备返回的 Reason 往往都是同一句 Sender not authorized——
所以「Fault 里看不出是哪种」是常态,只能靠客户端自己记录「我这次发了什么」。
第三行和第四行的字节差 22:
明文写 8 个字符(admin123),摘要写 28 个字符——
差 20 个字符,加上 Type 属性的长度差,正好 22 字节。
这个差值可以直接用来判断「我发的是哪种模式」,比看代码快。
三、失败也回 200 这件事
这一节最重要的一个知识点,值得单独一节。 ONVIF 的错误分两层:HTTP 层(传输是否成功)与SOAP 层(业务是否成功)。
| 层 | 成功 | 失败 |
|---|---|---|
| HTTP | 200 | 500 才表示「SOAP 都发不出去」 |
| SOAP | 没有 Fault |
s:Fault 里带 Code 与 Reason |
第一行那个「500」是 SOAP 1.2 的规定:只有「信封本身无法处理」才用 500,
而「业务失败」是「信封处理成功了,只是结果是拒绝」——所以仍然是 200。
第二行是判断依据:解析响应体,看有没有 Fault 元素。
有就是失败,没有才是成功。
这个设计在网页开发里很少见(那边习惯用 401/403),所以从 Web 转过来的开发者一定会踩一次。
典型表现是「请求全都「成功」,但拿到的数据全是空的」。
四、Nonce 必须每次换
digest 里那三个输入的组合,决定了「重放」能不能成功:
| 输入 | 谁生成 | 能重复吗 |
|---|---|---|
| Nonce | 客户端随机 | 绝对不能 |
Created |
客户端取当前 UTC | 必然重复(同一秒内) |
| 密码 | 用户配置 | 不变 |
第一行是唯一能防重放的输入:如果 Nonce 固定,那 digest 就固定, 别人抓到一次请求就能重放一辈子。 第二行那个「必然重复」说明:光靠时间戳防不住重放,因为同一秒内可以发很多次。 所以设备端的防重放机制一定是「记录最近用过的 Nonce」,而「记多久」是个工程取舍: 记太短防不住、记太长占内存,常见做法是记最近几分钟的。 我的模拟器没有做这个检查(同一个 Nonce 连发两次都会成功), 这一点在 Demo 里明说了——真实设备会拒绝,所以客户端必须每次生成新的 Nonce。
五、动手 Demo:鉴权的四种结果
Demo 把四种情况都试一遍:不带、密码错、明文、摘要。 重点看第二、三列——请求字节的差就是令牌的体积,结果列全是 HTTP 200 下的判断。
# 四种情况都发一遍,只看响应体里有没有 Fault
cases = [("不带令牌", ""),
("密码错", S.token_header(pwd="wrong")),
("明文", S.token_header(mode="Text")),
("摘要", S.token_header(mode="Digest"))]
for name, hdr in cases:
_a, b, n = S.call_sec(port, "device",
"GetDeviceInformation", header=hdr)
print(name, n, "Fault" not in b)
实跑输出:
--------------------------------------------------------------------
鉴权的四种结果
--------------------------------------------------------------------
**鉴权这件事最容易被写成一团**,其实只有两个问题:
**令牌放哪儿、密码怎么传。**
**放哪儿是固定的**:`s:Header` 里的 `wsse:Security`。
**怎么传有两种**:明文(`PasswordText`)和摘要(`PasswordDigest`)。
情况 请求字节 结果 耗时
--------------------------------------------------------
不带令牌 424 **Fault** 0.95 毫秒
带令牌但密码错 1113 **Fault** 1.64 毫秒
明文密码 1091 **成功** 0.99 毫秒
摘要密码 1113 **成功** 0.99 毫秒
**第二列从 400 多涨到 1100 多,多出来的全是令牌**:
`wsse:Security` 这一段单独就有 689 字节,**比整条业务请求还大两倍**。
**所以鉴权不是「加几行」,是把请求体积乘以 2.6**——
**在成百上千次轮询的场景里,这个开销要算进带宽预算。**
**第三列有个必须说清的点**:**失败时 HTTP 状态码仍然是 200。**
**错误放在 SOAP Fault 里**,`s:Code/s:Value` 是 `s:Sender`。
**所以客户端不能靠 HTTP 码判断成功**,
**必须解析响应体里有没有 `Fault`**——
**这一点和网页开发的经验完全相反**,是新人最常踩的坑。
**还有一个安全上的细节:Nonce 每次都要换。**
**我这里的模拟器不检查重放**(同一个 Nonce 连发两次都会成功),
**但真实设备会记录最近用过的 Nonce**,重复的直接拒绝。
**所以客户端必须每次生成新的随机 Nonce**,
**不能「启动时算一次 digest 一直用」**——
**那样在真设备上第二次请求就会被踢。**
这段代码里最关键的是 "Fault" not in b——成功判据就是「响应体里没有 Fault 元素」。
注意它完全没有看 HTTP 状态码:因为 S.call_sec 根本不返回状态码(模拟器固定回 200)。
这不是偷懒,是真实情况就是如此。
第二列的 424 与 1113 差 689 字节,
正好是 token_header() 返回的那段 Security 的长度——
所以「鉴权让请求变成 2.6 倍」这个结论是可以逐字节解释的。
最后那段「模拟器不查重放」必须写在正文里:
如果不写,读者会以为「Nonce 固定也没事」——而在真设备上,第二次请求就会被踢。
30 秒实验
- 把
S.token_header的created参数改成昨天的时间,看会不会失败(本机会成功,真设备不会)。 - 把
nonce_raw固定成同一个值连发两次,看模拟器的反应。 - 把
mode="Text"的请求字节和Digest的比一下,算出差了几个字节。
常见误区
- 用 HTTP 状态码判断鉴权是否成功。 失败时也是 200,错误在 SOAP Fault 里,必须解析响应体。
- Nonce 生成一次就一直用。 它是唯一能防重放的输入,固定了等于把密码贴出去。
- 上线还留着明文密码模式。 调试时方便,但公网上等于把密码写在请求里。
自测三问(点开对答案)
1. 鉴权失败时 HTTP 状态码是多少? —— **200。** 错误在 SOAP `Fault` 里,**所以必须解析响应体才知道成不成功。** 2. 为什么 Nonce 不能固定? —— **它是 digest 三个输入里唯一能变的**。固定了 digest 就固定,抓到一次就能重放。 3. 抓包看到 `Password` 是 28 个字符,说明什么? —— **用的是 PasswordDigest**(20 字节摘要的 Base64)。如果是 8 个字符,那是明文。下一节读取音视频流:ONVIF 只给一个地址,剩下全是 RTSP 的事。
- ☐ (本节完,下一节 08.05 读取音视频流)