📹 第八章 ONVIF框架代码案例应用
📄 一句话概括

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 秒实验

  1. 把 S.token_header 的 created 参数改成昨天的时间,看会不会失败(本机会成功,真设备不会)。
  2. 把 nonce_raw 固定成同一个值连发两次,看模拟器的反应。
  3. 把 mode="Text" 的请求字节和 Digest 的比一下,算出差了几个字节。

常见误区

  1. 用 HTTP 状态码判断鉴权是否成功。 失败时也是 200,错误在 SOAP Fault 里,必须解析响应体。
  2. Nonce 生成一次就一直用。 它是唯一能防重放的输入,固定了等于把密码贴出去。
  3. 上线还留着明文密码模式。 调试时方便,但公网上等于把密码写在请求里。
自测三问(点开对答案) 1. 鉴权失败时 HTTP 状态码是多少? —— **200。** 错误在 SOAP `Fault` 里,**所以必须解析响应体才知道成不成功。** 2. 为什么 Nonce 不能固定? —— **它是 digest 三个输入里唯一能变的**。固定了 digest 就固定,抓到一次就能重放。 3. 抓包看到 `Password` 是 28 个字符,说明什么? —— **用的是 PasswordDigest**(20 字节摘要的 Base64)。如果是 8 个字符,那是明文。

下一节读取音视频流:ONVIF 只给一个地址,剩下全是 RTSP 的事。

相关笔记