一、onvif 相机鉴权
1、概要
ONVIF 协议相机使用 1、基于 HTTP 级的摘要式访问身份验证方案(Digest Access Authentication Scheme)或 2、Web Server 级的 WS-Security (WSS) 进行身份验证。onvif 设备必须至少支持其中一个方案。验证流程如下:
2、Digest Access Authentication Scheme 摘要标头规范
此鉴权框架下有 3 个 header 行。WWW-Authenticat、Authorization、Authentication-Info。
2.1、WWW-Authenticate
服务端返回,告知客户端如何加密。形式如下:
WWW-Authenticate header = "Digest" digest-challenge
digest-challenge = ( realm | [ domain ] | nonce | [ opaque ] |[ stale ] | [ algorithm ] |
[ qop-options ] | [auth-param] )
domain = "domain" "=" <"> URI ( 1*SP URI ) <">
URI = absoluteURI | abs_path
nonce = "nonce" "=" nonce-value
nonce-value = quoted-string
opaque = "opaque" "=" quoted-string
stale = "stale" "=" ( "true" | "false" )
algorithm = "algorithm" "=" ( "MD5" | "MD5-sess" |token )
qop-options = "qop" "=" <"> 1#qop-value <">
qop-value = "auth" | "auth-int" | token
响应示例:
(WWW-Authenticate: Digest realm="testrealm@host.com",
qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
opaque="5ccc069c403ebaf9f0171e9517f40e41")
realm:要显示给用户的字符串,以便用户知道要使用哪个用户名和密码。此字符串应至少包含执行身份验证的主机的名称,并可能另外指示可能具有访问权限的用户的集合。例 “registered_users@gotham.news.com".
nonce:当响应 401 时,服务端生成的唯一的 base64 字符串
opaque:由服务器指定的数据字符串,客户端应在具有相同保护空间中 URI 的后续请求的 Authorization 标头中原封不动地返回该字符串。建议此字符串为 base64 或十六进制数据。
stale:一个标志,表示由于 nonce 值已过时,来自客户端的上一个请求被拒绝。只有在收到一个请求带有过期的 nonce 并有它的有效摘要值时才设置 stale 为 true。其他情况为 false,表示用户名、密码无效。
algorithm:表示使用的摘要算法,默认为 md5。我司相机采用默认算法。
qop-options:可选指令,为向后兼容 RFC 2069 使用。只有两个值:
1、auth:身份验证。
2、auth-int:具有完整性保护的身份验证。
auth-param:忽略,拓展使用。
2.2、Authorization
客户端请求头使用。当上一个没有 Authorization 头的请求被拒绝后,客户端应根据上一消息的响应重试改请求。形式如下,
credentials = "Digest" <digest-response>
digest-response = ( username | realm | nonce | digest-uri | response | [ algorithm ] | [cnonce] | [opaque] |[message-qop] | [nonce-count] | [authparam] )
username = "username" "=" username-value
username-value = quoted-string
digest-uri = "uri" "=" digest-uri-value
digest-uri-value = request-uri ; As specified by HTTP/1.1
message-qop = "qop" "=" qop-value
cnonce = "cnonce" "=" cnonce-value
cnonce-value = nonce-value
nonce-count = "nc" "=" nc-value
nc-value = 8LHEX
response = "response" "=" request-digest
request-digest = <"> 32LHEX <">
LHEX = "0" | "1" | "2" | "3" |
"4" | "5" | "6" | "7" |
"8" | "9" | "a" | "b" |
"c" | "d" | "e" | "f"
请求示例:
(Authorization: Digest username="Mufasa",
realm="testrealm@host.com",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
uri="/dir/index.html",
qop=auth,
nc=00000001,
cnonce="0a4f113b",
response="6629fae49393a05397450978507c4ef1",
opaque="5ccc069c403ebaf9f0171e9517f40e41")
特别的,opaque、algorithm 必须是 WWW-Authenticate 头中指定的值。
username:请求接口对应的用户名。
realm:请求的 uri,客户端决定。
nonce:WWW-Authenticate 指定的一致。
uri: 请求的 uri
qop:和 WWW-Authenticate 中指定的字段一致,不存在则缺省。兼容 RFC 2069 使用,只有两个值 auth、auth-int。
cnonce:如果发送了 qop 指令(见上文),则必须指定此项;如果服务器未在 WWW-Authenticate 标头字段中发送 qop 指令,则不得指定此项。cnonce 值是客户端提供的不透明带引号的字符串值,客户端和服务器都使用它来避免选择的明文攻击,提供相互身份验证,并提供一些消息完整性保护。请参阅以下关于计算 response-digest 和 request-digest 值的说明。
nonce-count:如果发送了 qop 指令(见上文),则必须指定此项;如果服务器未在 WWW-Authenticate 标头字段中发送 qop 指令,则不得指定此项。nc 值是客户端使用此请求中的 nonce 值发送的请求数(包括当前请求)的十六进制计数。例如,在响应给定的 nonce 值而发送的第一个请求中,客户端发送“nc=00000001”。该指令的目的是允许服务器通过维护自己的该计数副本来检测请求重播——如果相同的 nc 值被看到两次,则该请求是重播。请参阅下面对请求摘要值的构造的描述。
response:按一定规则计算的 32 字节长度的字符串,证明用户知道密码。计算规则如下:
换算公式:
unq(X)代表去掉周围引号的字符串
concat(a,b,c,..)表示连接指定的字符串
H(data) = MD5(data)
KD(secret, data) = H(concat(secret, ":", data))
1、request-digest计算取决于 WWW-Authenticate 中是否携带 qop字段:
qop存在:
request-digest = <"> < KD ( H(A1), unq(nonce-value)
":" nc-value
":" unq(cnonce-value)
":" unq(qop-value)
":" H(A2)) <">
qop不存在:
request-digest = <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) ><">
2、A1值取决于algorithm:
algorithm=MD5(缺省为 MD5):
A1 = unq(username-value) ":" unq(realm-value) ":" passwd
algorithm= MD5-sess:
A1= H( unq(username-value) ":" unq(realm-value)":" passwd ) ":" unq(nonce-value) ":" unq(cnonce-value)
3、A2取决于qop:
qop="auth":
A2 = Method ":" digest-uri-value
qop="auth-int":
A2 = Method ":" digest-uri-value ":" H(entity-body)
2.3、Authentication-Info
服务器使用 Authentication Info 标头来传递有关响应中成功身份验证的一些信息。一般没啥用,不做介绍。
3、WS-Security (WSS)框架规范
3.1、请求报文
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<soapenv:Header>
<wsse:Security>
<wsse:UsernameToken wsu:Id="UsernameToken-1234567890">
<wsse:Username>
username
</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">
password_digest
</wsse:Password>
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">
nonce_value
</wsse:Nonce>
<wsu:Created>
2023-10-27T12:34:56Z
</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>
</soapenv:Header>
<soapenv:Body>
<!-- ONVIF 请求内容 -->
</soapenv:Body>
</soapenv:Envelope>
请求报文中,<wsse:Security> 元素包含了鉴权信息,包括用户名(<wsse:Username>)、密码摘要(<wsse:Password>)、随机数(<wsse:Nonce>)和创建时间(<wsu:Created>)。这些信息用于验证请求者的身份和请求的合法性。
3.2、响应报文
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ...>
<soapenv:Header>
<!-- 可能的安全令牌或其他头信息 -->
</soapenv:Header>
<soapenv:Body>
<!-- ONVIF 响应内容 -->
</soapenv:Body>
</soapenv:Envelope>
3.3、摘要计算公式
Password_Digest = Base64 ( SHA-1 ( nonce + created + password ) )
二、onvif 相机获取实况 uri 流程
1、流程
1.1、调用 onvif device 服务的 GetServices 接口获取 media 服务地址。
1.2、调用 media 服务的 GetProfiles接口获取码流的配置文件列表,从中获取 ProfileToken。几条实况码流就有几个 ProfileToken。
1.3、调用 media 服务的 GetStreamUri接口获取实况 uri。
2、python 示例代码
# -*- coding: utf-8 -*-
import requests
import xml.etree.ElementTree as ET
import pprint
import hashlib
interface_name_getDeviceInfo = "GetDeviceInformation"
interface_name_GetServices = "GetServices"
interface_name_GetProfiles = "GetProfiles"
#美化xml
def prettyXml(element, indent, newline, level = 0): # elemnt为传进来的Elment类,参数indent用于缩进,newline用于换行
if element: # 判断element是否有子元素
if element.text == None or element.text.isspace(): # 如果element的text没有内容
element.text = newline + indent * (level + 1)
else:
element.text = newline + indent * (level + 1) + element.text.strip() + newline + indent * (level + 1)
#else: # 此处两行如果把注释去掉,Element的text也会另起一行
#element.text = newline + indent * (level + 1) + element.text.strip() + newline + indent * level
temp = list(element) # 将elemnt转成list
for subelement in temp:
if temp.index(subelement) < (len(temp) - 1): # 如果不是list的最后一个元素,说明下一个行是同级别元素的起始,缩进应一致
subelement.tail = newline + indent * (level + 1)
else: # 如果是list的最后一个元素, 说明下一行是母元素的结束,缩进应该少一个
subelement.tail = newline + indent * level
prettyXml(subelement, indent, newline, level = level + 1) # 对子元素进行递归操作
return element
#md5计算
def get_md5_str(input_string):
# 创建一个md5 hash对象
md5_hash = hashlib.md5()
# 提供要哈希的数据,需要先转换为字节
md5_hash.update(input_string.encode('utf-8'))
# 获取16进制哈希值
return md5_hash.hexdigest()
#打印响应消息
def print_response(r, interface_name):
root = prettyXml(ET.fromstring(r.text), '\t', '\n') # 执行美化方法
print("======================= {} response xml ===========================".format(interface_name))
print(ET.tostring(root, encoding='utf8').decode('utf8'))
print("======================== {} http status code ==========================".format(interface_name))
print(r.status_code) # 打印HTTP状态码
print("======================== {} http response header ==========================".format(interface_name))
pprint.pprint(dict(r.headers)) # 打印响应头
print("======================== {} end ==========================\n".format(interface_name))
#需要请求的地址
device_uri = "http://172.20.133.164:80/onvif/device_service"
#请求onvif设备, GetDeviceInformation
xml_data_getDeviceInfomation = """
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<GetDeviceInformation xmlns="http://www.onvif.org/ver10/device/wsdl">
</GetDeviceInformation>
</s:Body>
</s:Envelope>
"""
# 设置请求的headers
headers = {
'Content-Type': 'application/xml', # 表明发送的是XML数据
'Accept': 'application/xml', # 表明期望接收的是XML数据
# 其他必要的headers,比如认证信息等
}
#发起请求
r = requests.post(device_uri, data=xml_data_getDeviceInfomation, headers=headers)
root = ET.fromstring(r.text) # 解析XML字符串
if r.status_code == 200:
pprint.pprint(ET.tostring(root, encoding='utf8').decode('utf8'))# 美观打印xml响应内容
print(r.status_code) # 打印HTTP状态码
print(r.headers) # 打印响应头
elif r.status_code != 401: #并不是密码错误,就不进行第二次尝试了
exit()
#获取响应headers中的WWW-Authenticate,后设置第二次请求头Authorization
WWW_Authenticate = r.headers["WWW-Authenticate"]
if WWW_Authenticate is not None:
#WWW-Authenticate中的值
qop = "auth"
realm = "48ea632fbf7a"
nonce = WWW_Authenticate[(WWW_Authenticate.find("nonce=")+7):-1]
cnonce = "48ea632fbf7a"
username = "admin"
nc = "00000001"
#计算Authorization中的response
A1 = "admin:" + realm + ":admin_123"
A2 = "POST:" + device_uri
Authorization_response = get_md5_str(get_md5_str(A1) + ":"
+ nonce + ":"
+ nc + ":"
+ cnonce + ":"
+ qop + ":"
+ get_md5_str(A2))
Authorization_value = "Digest username=\"" + username \
+ "\",realm=\"" + realm \
+ "\",nonce=\"" + nonce \
+ "\",uri=\"" + device_uri \
+ "\",qop=" + qop \
+ ",nc=" + nc \
+ ",cnonce=\"" + cnonce \
+ "\",response=\"" + Authorization_response + "\""
print("#qop = {}".format(qop))
print("#realm = {}".format(realm))
print("#nonce = {}".format(nonce))
print("#cnonce = {}".format(cnonce))
print("#username = {}".format(username))
print("#nc = {}".format(nc))
print("#A1 = {}".format(A1))
print("#A2 = {}".format(A2))
print("#Authorization_response = {}".format(Authorization_response))
print("#Authorization_value = {}".format(Authorization_value))
#设置鉴权信息后再请求
headers["Authorization"] = Authorization_value
r = requests.post(device_uri, data=xml_data_getDeviceInfomation, headers=headers)
print_response(r, interface_name_getDeviceInfo)
#调用ONVIF GetProfiles接口获取媒体配置文件(https://www.onvif.org/ver10/media/wsdl/media.wsdl 44)
############################ 获取相机rtsp_uri流程 #############################
#1、调用GetServer接口获取相机提供的media服务地址(https://www.onvif.org/ver10/media/wsdl/media.wsdl 47)
xml_data_getServices = """
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<GetServices xmlns="http://www.onvif.org/ver10/device/wsdl">
<IncludeCapability>
true
</IncludeCapability>
</GetServices>
</s:Body>
</s:Envelope>
"""
r = requests.post(device_uri, data=xml_data_getServices, headers=headers)
print_response(r, interface_name_GetServices)
#root = etree.fromstring(r.text.encode("utf-8"))
#onvif_media_service = root.xpath("//Envelope")
#print(onvif_media_service)
#解析出来的media服务地址。。。懒得解析了,直接使用抓包得到的
media_uri = "http://172.20.133.164:80/onvif/media"
#2 调用media服务getprofiles接口获取media的配置文件列表,每个profile有一个唯一的ProfileToken
xml_data_getProfiles = """
<?xml version="1.0" encoding="utf-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2" xmlns:xop="http://www.w3.org/2004/08/xop/include" xmlns:wsa5="http://www.w3.org/2005/08/addressing" xmlns:wsrf-bf="http://docs.oasis-open.org/wsrf/bf-2" xmlns:wstop="http://docs.oasis-open.org/wsn/t-1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<SOAP-ENV:Body>
<trt:GetProfiles></trt:GetProfiles>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
"""
r = requests.post(media_uri, data=xml_data_getProfiles, headers=headers)
print_response(r, interface_name_GetProfiles)
#3 解析出profile中携带的ProfileToken,直接使用抓包中得到的
ProfileToken1 = "media_profile1"
ProfileToken2 = "media_profile2"
#4 调用media服务的GetStreamUri接口获取码流uri (https://www.onvif.org/ver10/media/wsdl/media.wsdl 47)
xml_data_GetStreamUri = """
<?xml version="1.0" encoding="utf-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2" xmlns:xop="http://www.w3.org/2004/08/xop/include" xmlns:wsa5="http://www.w3.org/2005/08/addressing" xmlns:wsrf-bf="http://docs.oasis-open.org/wsrf/bf-2" xmlns:wstop="http://docs.oasis-open.org/wsn/t-1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<SOAP-ENV:Body>
<trt:GetStreamUri>
<trt:StreamSetup >
<trt:Stream>RTP-Unicast</trt:Stream>
<tt:Transport>
<trt:Protocol>RTSP</trt:Protocol>
</tt:Transport >
</trt:StreamSetup >
<trt:ProfileToken>media_profile1</trt:ProfileToken>
</trt:GetStreamUri>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
"""
#tips:
# StreamSetup标签的子标签Transport为啥命名空间需要使用tt,否则soap返回<Action Failed>,由于http://www.onvif.org/ver10/schema命名空间地址失效没有查到原因。
# 测试相机:UNIVIEW-HIC3531-IR@D-F20
r = requests.post(media_uri, data=xml_data_GetStreamUri, headers=headers)
print_response(r, "GetStreamUri")