feat: enhance authentication mechanism and improve event handling

- Added EndpointRefAddress to DeviceParams for better endpoint management.
- Updated FilterType to use pointers for TopicExpression and MessageContent.
- Refactored SendSoapWithDigest to strip WS-Security headers to avoid credential duplication.
- Updated package imports to reflect new repository structure.
- Introduced ReadAndParse function for improved HTTP response handling in SDK.
This commit is contained in:
Cédric Verstraeten
2026-07-07 12:49:51 +00:00
parent 2fb619deda
commit 96918255a9
6 changed files with 310 additions and 122 deletions

282
Device.go
View File

@@ -94,11 +94,12 @@ type Device struct {
} }
type DeviceParams struct { type DeviceParams struct {
Xaddr string Xaddr string
Username string EndpointRefAddress string
Password string Username string
HttpClient *http.Client Password string
AuthMode string HttpClient *http.Client
AuthMode string
} }
// GetServices return available endpoints // GetServices return available endpoints
@@ -106,30 +107,21 @@ func (dev *Device) GetServices() map[string]string {
return dev.endpoints return dev.endpoints
} }
// GetDeviceInfo return available endpoints // GetServices return available endpoints
func (dev *Device) GetDeviceInfo() DeviceInfo { func (dev *Device) GetDeviceInfo() DeviceInfo {
return dev.info return dev.info
} }
// GetDeviceParams return available endpoints // SetDeviceInfoFromScopes goes through the scopes and sets the device info fields for supported categories (currently name and hardware).
func (dev *Device) GetDeviceParams() DeviceParams { // See 7.3.2.2 Scopes in the ONVIF Core Specification (https://www.onvif.org/specs/core/ONVIF-Core-Specification.pdf).
return dev.params func (dev *Device) SetDeviceInfoFromScopes(scopes []string) {
} newInfo := dev.info
supportedScopes := []struct {
func readResponse(resp *http.Response) string { category string
b, err := ioutil.ReadAll(resp.Body) setField func(s string)
if err != nil { }{
panic(err) {category: "name", setField: func(s string) { newInfo.Name = s }},
} {category: "hardware", setField: func(s string) { newInfo.Model = s }},
return string(b)
}
// GetAvailableDevicesAtSpecificEthernetInterface ...
func GetAvailableDevicesAtSpecificEthernetInterface(interfaceName string) ([]Device, error) {
// Call a ws-discovery Probe Message to Discover NVT type Devices
devices, err := wsdiscovery.SendProbe(interfaceName, nil, []string{"dn:" + NVT.String()}, map[string]string{"dn": "http://www.onvif.org/ver10/network/wsdl"})
if err != nil {
return nil, err
} }
for _, s := range scopes { for _, s := range scopes {
@@ -240,7 +232,7 @@ func (dev *Device) buildMethodSOAP(msg string) (gosoap.SoapMessage, error) {
} }
// getEndpoint functions get the target service endpoint in a better way // getEndpoint functions get the target service endpoint in a better way
func (dev Device) getEndpoint(endpoint string) (string, error) { func (dev *Device) getEndpoint(endpoint string) (string, error) {
// common condition, endpointMark in map we use this. // common condition, endpointMark in map we use this.
if endpointURL, bFound := dev.endpoints[endpoint]; bFound { if endpointURL, bFound := dev.endpoints[endpoint]; bFound {
@@ -291,20 +283,6 @@ func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Respo
return dev.sendSOAP(endpoint, soap) return dev.sendSOAP(endpoint, soap)
} }
// Authentication modes selectable through DeviceParams.AuthMode.
const (
// NoAuth disables authentication entirely.
NoAuth = "none"
// DigestAuth uses HTTP digest only, without a WS-Security header.
DigestAuth = "digest"
// UsernameTokenAuth uses a WS-Security UsernameToken header only and never
// falls back to HTTP digest.
UsernameTokenAuth = "usernametoken"
// Both adds a WS-Security header (when credentials exist) and additionally
// answers an HTTP digest challenge if the device requests one.
Both = "both"
)
// sendSOAP dispatches an assembled SOAP message to the endpoint using the // sendSOAP dispatches an assembled SOAP message to the endpoint using the
// authentication mechanism selected through DeviceParams.AuthMode. // authentication mechanism selected through DeviceParams.AuthMode.
// //
@@ -316,8 +294,9 @@ const (
// - DigestAuth ("digest"): HTTP digest only; no WS-Security header. // - DigestAuth ("digest"): HTTP digest only; no WS-Security header.
// - Both ("both") / unset (""): WS-Security credentials are added (when // - Both ("both") / unset (""): WS-Security credentials are added (when
// available) and HTTP digest is attempted only if the device answers with // available) and HTTP digest is attempted only if the device answers with
// an authentication challenge (HTTP 401 Unauthorized). The WS-Security // an authentication challenge (HTTP 401 Unauthorized). On that digest
// header is never stripped. // retry the WS-Security header is dropped so the credentials are not sent
// twice.
func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Response, error) { func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Response, error) {
hasCredentials := dev.params.Username != "" || dev.params.Password != "" hasCredentials := dev.params.Username != "" || dev.params.Password != ""
@@ -341,3 +320,222 @@ func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Resp
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password) return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
} }
} }
func (dev *Device) GetDeviceParams() DeviceParams {
return dev.params
}
func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string, error) {
pkgPath := strings.Split(reflect.TypeOf(requestStruct).Elem().PkgPath(), "/")
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
endpoint, err := dev.getEndpoint(pkg)
if err != nil {
return "", err
}
return endpoint, err
}
/*func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.Response, err error) {
soap := gosoap.NewEmptySOAP()
soap.AddStringBodyContent(xmlRequestBody)
soap.AddRootNamespaces(Xlmns)
if dev.params.AuthMode == UsernameTokenAuth || dev.params.AuthMode == Both {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
resp, err = dev.digestClient.Do(http.MethodPost, endpoint, soap.String())
} else {
var req *http.Request
req, err = createHttpRequest(http.MethodPost, endpoint, soap.String())
if err != nil {
return nil, err
}
resp, err = dev.params.HttpClient.Do(req)
}
return resp, err
}*/
// SendSoap POSTs the given body wrapped in a SOAP envelope.
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
return dev.SendSoapWithOptions(endpoint, xmlRequestBody)
}
// SendSoapWithHeader is SendSoap plus arbitrary inner-Header XML —
// needed to echo WS-Addressing ReferenceParameters (with
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
// identify pull-point subscriptions through them rather than the URL.
//
// xmlHeaderContent must be well-formed XML representing one or more
// SOAP Header child elements (siblings are supported; the spec lets
// each reference parameter be its own header block). Malformed or
// element-free content errors before any request is made.
//
// SECURITY: do not pass content sourced from untrusted clients. The
// API assumes the caller is authoritative for the envelope. Header
// content forwards verbatim — among others, <wsse:Security> overrides
// auth, <wsa:Action> overrides intent, <wsa:To>/<wsa:ReplyTo>/
// <wsa:FaultTo> redirect responses, <wsa:MessageID> enables replay-
// token forgery, and <wsu:Timestamp> bypasses freshness checks.
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent))
}
// SendSoapOption tweaks a single SendSoapWithOptions call. New options
// (per-call timeout, context, custom envelope namespaces, ...) should
// be added as WithX constructors here rather than as new method
// variants on Device.
type SendSoapOption func(*soapConfig)
type soapConfig struct {
headerContent string
}
// WithSOAPHeader adds inner-Header XML to the envelope. See
// SendSoapWithHeader for the content contract.
func WithSOAPHeader(headerContent string) SendSoapOption {
return func(c *soapConfig) { c.headerContent = headerContent }
}
// SendSoapWithOptions is the workhorse behind SendSoap and
// SendSoapWithHeader; call it directly when you need to combine
// options or pass options not surfaced by the convenience wrappers.
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SendSoapOption) (*http.Response, error) {
var cfg soapConfig
for _, o := range opts {
o(&cfg)
}
soap := gosoap.NewEmptySOAP()
soap.AddStringBodyContent(xmlRequestBody)
soap.AddRootNamespaces(Xlmns)
soap.AddAction()
if cfg.headerContent != "" {
if err := soap.AddStringHeaderContents(cfg.headerContent); err != nil {
return nil, fmt.Errorf("add header content: %w", err)
}
}
if dev.params.Username != "" && dev.params.Password != "" {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
if err != nil {
if servResp != nil {
servResp.Body.Close()
}
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
}
return servResp, err
}
func createHttpRequest(httpMethod string, endpoint string, soap string) (req *http.Request, err error) {
req, err = http.NewRequest(httpMethod, endpoint, bytes.NewBufferString(soap))
if err != nil {
return nil, err
}
req.Header.Set(ContentType, "application/soap+xml; charset=utf-8")
return req, nil
}
func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []byte) (interface{}, error) {
function, err := FunctionByServiceAndFunctionName(serviceName, functionName)
if err != nil {
return nil, err
}
request, err := createRequest(function, data)
if err != nil {
return nil, fmt.Errorf("fail to create '%s' request for the web service '%s', %v", functionName, serviceName, err)
}
endpoint, err := dev.GetEndpointByRequestStruct(request)
if err != nil {
return nil, err
}
requestBody, err := xml.Marshal(request)
if err != nil {
return nil, err
}
xmlRequestBody := string(requestBody)
servResp, err := dev.SendSoapWithOptions(endpoint, xmlRequestBody)
if err != nil {
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, err)
}
defer servResp.Body.Close()
rsp, err := ioutil.ReadAll(servResp.Body)
if err != nil {
return nil, err
}
responseEnvelope, err := createResponse(function, rsp)
if err != nil {
return nil, fmt.Errorf("fail to create '%s' response for the web service '%s', %v", functionName, serviceName, err)
}
if servResp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("fail to verify the authentication for the function '%s' of web service '%s'. Onvif error: %s",
functionName, serviceName, responseEnvelope.Body.Fault.String())
} else if servResp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("invalid request for the function '%s' of web service '%s'. Onvif error: %s",
functionName, serviceName, responseEnvelope.Body.Fault.String())
} else if servResp.StatusCode > http.StatusNoContent {
return nil, fmt.Errorf("fail to execute the request for the function '%s' of web service '%s'. Onvif error: %s",
functionName, serviceName, responseEnvelope.Body.Fault.String())
}
return responseEnvelope.Body.Content, nil
}
func createRequest(function Function, data []byte) (interface{}, error) {
request := function.Request()
if len(data) > 0 {
err := json.Unmarshal(data, request)
if err != nil {
return nil, err
}
}
return request, nil
}
func createResponse(function Function, data []byte) (*gosoap.SOAPEnvelope, error) {
response := function.Response()
responseEnvelope := gosoap.NewSOAPEnvelope(response)
err := xml.Unmarshal(data, responseEnvelope)
if err != nil {
return nil, err
}
return responseEnvelope, nil
}
// SendGetSnapshotRequest sends the Get request to retrieve the snapshot from the Onvif camera
// The parameter url is come from the "GetSnapshotURI" command.
func (dev *Device) SendGetSnapshotRequest(url string) (resp *http.Response, err error) {
soap := gosoap.NewEmptySOAP()
soap.AddRootNamespaces(Xlmns)
if dev.params.AuthMode == UsernameTokenAuth {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
var req *http.Request
req, err = createHttpRequest(http.MethodGet, url, soap.String())
if err != nil {
return nil, err
}
// Basic auth might work for some camera
req.SetBasicAuth(dev.params.Username, dev.params.Password)
resp, err = dev.params.HttpClient.Do(req)
} else if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
resp, err = dev.digestClient.Do(http.MethodGet, url, soap.String())
} else {
var req *http.Request
req, err = createHttpRequest(http.MethodGet, url, soap.String())
if err != nil {
return nil, err
}
resp, err = dev.params.HttpClient.Do(req)
}
return resp, err
}

View File

@@ -52,8 +52,8 @@ type EndpointReferenceType struct { //wsa http://www.w3.org/2005/08/addressing/w
// FilterType struct // FilterType struct
type FilterType struct { type FilterType struct {
TopicExpression TopicExpressionType `xml:"wsnt:TopicExpression"` TopicExpression *TopicExpressionType `xml:"wsnt:TopicExpression,omitempty"`
MessageContent *QueryExpressionType `xml:"wsnt:MessageContent"` MessageContent *QueryExpressionType `xml:"wsnt:MessageContent,omitempty"`
} }
// EndpointReference alias // EndpointReference alias

View File

@@ -12,7 +12,6 @@ import (
"strings" "strings"
"github.com/beevik/etree" "github.com/beevik/etree"
"github.com/icholy/digest"
"github.com/juju/errors" "github.com/juju/errors"
) )
@@ -33,83 +32,24 @@ func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response
return resp, nil return resp, nil
} }
func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) {
doc := etree.NewDocument()
if err := doc.ReadFromString(message); err != nil {
return nil, err
}
e := doc.FindElement("./Envelope/Header/Security")
if e != nil {
bodyTag := doc.Root().SelectElement("Header")
bodyTag.RemoveChild(e)
data, err := doc.WriteToString()
if err != nil {
return nil, err
}
message = data
}
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(message))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return resp, errors.Annotate(err, "Post with digest")
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, err
}
wwwAuth := resp.Header.Get("WWW-Authenticate")
chal, err := digest.ParseChallenge(wwwAuth)
if err != nil {
return resp, fmt.Errorf("fail to parse challenge: %w", err)
}
cred, err := digest.Digest(chal, digest.Options{
Method: "POST",
URI: req.URL.RequestURI(),
Username: username,
Password: password,
})
if err != nil {
return resp, fmt.Errorf("fail to build digest: %w", err)
}
// Readout body to close the connection
resp.Body.Close()
req.Header.Add("Authorization", cred.String())
req.Body = io.NopCloser((bytes.NewBufferString(message)))
resp, err = httpClient.Do(req)
if err != nil {
return nil, errors.Annotate(err, "Post with digest")
}
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
return resp, errors.Errorf("Post with digest error: %d: %s", resp.StatusCode, resp.Status)
}
return resp, nil
}
// SendSoapWithDigest sends a soap message and, when the device answers with an // SendSoapWithDigest sends a soap message and, when the device answers with an
// HTTP 401 digest challenge, transparently retries the request with the // HTTP 401 digest challenge, transparently retries the request with the
// computed HTTP digest Authorization header. // computed HTTP digest Authorization header.
// //
// The initial request is sent as-is, so any WS-Security header already present // Any wsse:Security header present in the message is stripped before sending:
// in the message is preserved. HTTP digest is only attempted when the device // when a device requires HTTP digest the credentials travel in the
// explicitly requests it, which keeps WS-Security-only cameras working while // Authorization header, so keeping the WS-Security UsernameToken in the body
// also supporting cameras that require HTTP digest for authenticated calls. // would put the credentials on the wire twice. Other SOAP header blocks (for
// example WS-Addressing reference parameters) are preserved so vendor-specific
// routing keeps working across the retry.
func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) { func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) {
if httpClient == nil { if httpClient == nil {
httpClient = new(http.Client) httpClient = new(http.Client)
} }
// Avoid sending the credentials twice (WS-Security + digest) on the retry.
message = stripWSSecurityHeader(message)
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message)) resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil { if err != nil {
return resp, errors.Annotate(err, "Post") return resp, errors.Annotate(err, "Post")
@@ -149,6 +89,30 @@ func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, pa
return resp, nil return resp, nil
} }
// stripWSSecurityHeader removes the wsse:Security header block from a SOAP
// envelope, leaving all other header blocks intact. The message is returned
// unchanged if it cannot be parsed as XML or has no such header.
func stripWSSecurityHeader(message string) string {
doc := etree.NewDocument()
if err := doc.ReadFromString(message); err != nil {
return message
}
security := doc.FindElement("./Envelope/Header/Security")
if security == nil {
return message
}
header := doc.Root().SelectElement("Header")
if header == nil {
return message
}
header.RemoveChild(security)
data, err := doc.WriteToString()
if err != nil {
return message
}
return data
}
var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`) var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`)
// parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header. // parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header.

View File

@@ -6,10 +6,11 @@ package event
import ( import (
"context" "context"
"github.com/juju/errors" "github.com/juju/errors"
"github.com/use-go/onvif" "github.com/kerberos-io/onvif"
"github.com/use-go/onvif/sdk" "github.com/kerberos-io/onvif/event"
"github.com/use-go/onvif/event" "github.com/kerberos-io/onvif/sdk"
) )
// Call_PullMessages forwards the call to dev.CallMethod() then parses the payload of the reply as a PullMessagesResponse. // Call_PullMessages forwards the call to dev.CallMethod() then parses the payload of the reply as a PullMessagesResponse.

View File

@@ -1,8 +1,8 @@
package event package event
//go:generate go run github.com/use-go/onvif/sdk/codegen event event CreatePullPointSubscription //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event CreatePullPointSubscription
//go:generate go run github.com/use-go/onvif/sdk/codegen event event GetEventProperties //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetEventProperties
//go:generate go run github.com/use-go/onvif/sdk/codegen event event GetServiceCapabilities //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetServiceCapabilities
//go:generate go run github.com/use-go/onvif/sdk/codegen event event Subscribe //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Subscribe
//go:generate go run github.com/use-go/onvif/sdk/codegen event event Unsubscribe //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Unsubscribe
//go:generate go run github.com/use-go/onvif/sdk/codegen event event PullMessages //go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event PullMessages

25
sdk/sdk.go Normal file
View File

@@ -0,0 +1,25 @@
package sdk
import (
"context"
"encoding/xml"
"io"
"net/http"
"github.com/juju/errors"
)
// ReadAndParse reads the body of the given HTTP reply and unmarshals it into
// reply. The tag identifies the ONVIF action for diagnostic purposes.
func ReadAndParse(ctx context.Context, httpReply *http.Response, reply interface{}, tag string) error {
// TODO(jfsmig): extract the deadline from ctx.Deadline() and apply it on the reply reading
b, err := io.ReadAll(httpReply.Body)
if err != nil {
return errors.Annotate(err, "read")
}
httpReply.Body.Close()
err = xml.Unmarshal(b, reply)
return errors.Annotate(err, "decode")
}