Merge pull request #8 from kerberos-io/fix/authentication-mechanism

fix/authentication-mechanism
This commit is contained in:
Cédric Verstraeten
2026-07-07 16:11:37 +02:00
committed by GitHub
10 changed files with 490 additions and 67 deletions

51
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Pull Request Build
# Builds, vets and tests the Go library on every pull request.
# This repository is a Go library (no Dockerfile), so unlike the Docker-oriented
# uug-ai/workflows pr-build.yml it validates the module directly instead of
# building and pushing a container image.
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
build:
name: Build & Test (Go)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
cache-dependency-path: go.sum
- name: Build
run: go build -v ./...
- name: Vet (non-blocking)
continue-on-error: true
run: go vet ./...
- name: Gofmt check (non-blocking)
continue-on-error: true
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "The following files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: Test
# ws-discovery/TestDevicesFromProbeResponses is a network-dependent
# integration test that probes real ONVIF cameras on the LAN, so it
# cannot pass on hosted runners. Run it locally against real devices.
run: go test $(go list ./... | grep -v '/ws-discovery')

28
.github/workflows/release-bump.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Bump release
# Manually bump the semantic version: determines the next tag from the latest
# v* tag, creates a GitHub release with generated notes and exposes the new tag.
# Thin wrapper around the reusable uug-ai/workflows release-bump workflow.
on:
workflow_dispatch:
inputs:
bump:
description: "Which part of the version to bump"
required: true
default: patch
type: choice
options:
- major
- minor
- patch
permissions:
contents: write
jobs:
bump-release:
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
with:
bump: ${{ github.event.inputs.bump }}
secrets: inherit

36
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Release
# Validates the tagged code when a release is published (build + test).
# For a Go library a "release" is simply a git tag consumers depend on, so this
# workflow guards that a released tag builds and passes its tests rather than
# publishing a container image.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
name: Validate release (Go)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
cache-dependency-path: go.sum
- name: Build
run: go build -v ./...
- name: Test
# See pr-build.yml: the ws-discovery probe test needs real cameras.
run: go test $(go list ./... | grep -v '/ws-discovery')

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.idea
*.iml
.idea/
.vscode/

View File

@@ -280,21 +280,45 @@ func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Respo
soap.AddRootNamespaces(Xlmns)
soap.AddAction()
//Auth Handling
if dev.params.Username != "" && dev.params.Password != "" {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
return dev.sendSOAP(endpoint, soap)
}
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
if err != nil {
// Close server response body to reuse the connection
if servResp != nil {
servResp.Body.Close()
// sendSOAP dispatches an assembled SOAP message to the endpoint using the
// authentication mechanism selected through DeviceParams.AuthMode.
//
// Behaviour per AuthMode:
// - NoAuth ("none"): no authentication is added.
// - UsernameTokenAuth: WS-Security UsernameToken only; never falls
// back to HTTP digest, which keeps cameras that authenticate exclusively
// through WS-Security working.
// - DigestAuth ("digest"): HTTP digest only; no WS-Security header.
// - Both ("both") / unset (""): WS-Security credentials are added (when
// available) and HTTP digest is attempted only if the device answers with
// an authentication challenge (HTTP 401 Unauthorized). On that digest
// 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) {
hasCredentials := dev.params.Username != "" || dev.params.Password != ""
switch dev.params.AuthMode {
case NoAuth:
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
case UsernameTokenAuth:
if hasCredentials {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
}
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
return servResp, err
case DigestAuth:
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
default: // Both and the empty/unset default.
if hasCredentials {
soap.AddWSSecurity(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 {
@@ -405,7 +429,6 @@ func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...S
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 {

100
networking/digest_test.go Normal file
View File

@@ -0,0 +1,100 @@
package networking
import (
"strings"
"testing"
)
func TestParseDigestChallenge(t *testing.T) {
challenge := `Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41", algorithm=MD5`
parts := parseDigestChallenge(challenge)
cases := map[string]string{
"realm": "testrealm@host.com",
"qop": "auth,auth-int",
"nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093",
"opaque": "5ccc069c403ebaf9f0171e9517f40e41",
"algorithm": "MD5",
}
for key, want := range cases {
if got := parts[key]; got != want {
t.Errorf("parseDigestChallenge()[%q] = %q, want %q", key, got, want)
}
}
}
// TestMD5DigestVectors validates md5Hex and the response assembly order against
// the canonical RFC 2617 section 3.5 worked example.
func TestMD5DigestVectors(t *testing.T) {
ha1 := md5Hex("Mufasa:testrealm@host.com:Circle Of Life")
if want := "939e7578ed9e3c518a452acee763bce9"; ha1 != want {
t.Fatalf("HA1 = %q, want %q", ha1, want)
}
ha2 := md5Hex("GET:/dir/index.html")
if want := "39aff3a2bab6126f332b942af96d3366"; ha2 != want {
t.Fatalf("HA2 = %q, want %q", ha2, want)
}
response := md5Hex(strings.Join([]string{
ha1,
"dcd98b7102dd2f0e8b11d0f600bfb0c093", // nonce
"00000001", // nc
"0a4f113b", // cnonce
"auth", // qop
ha2,
}, ":"))
if want := "6629fae49393a05397450978507c4ef1"; response != want {
t.Fatalf("response = %q, want %q", response, want)
}
}
// TestNewDigestAuthorizationConsistency builds an Authorization header and then
// recomputes the response from the emitted cnonce/nc to confirm the header is
// internally consistent (correct field wiring and formula).
func TestNewDigestAuthorizationConsistency(t *testing.T) {
const (
username = "admin"
password = "s3cret"
realm = "IP Camera"
nonce = "0cc175b9c0f1b6a831c399e269772661"
)
challenge := `Digest realm="` + realm + `", nonce="` + nonce + `", qop="auth", algorithm=MD5`
header := newDigestAuthorization(challenge, "POST", "http://192.168.1.10/onvif/ptz_service", username, password)
if header == "" {
t.Fatal("newDigestAuthorization returned empty header")
}
if !strings.HasPrefix(header, "Digest ") {
t.Fatalf("header does not start with Digest scheme: %q", header)
}
fields := parseDigestChallenge(header)
if fields["username"] != username {
t.Errorf("username = %q, want %q", fields["username"], username)
}
if fields["uri"] != "/onvif/ptz_service" {
t.Errorf("uri = %q, want %q", fields["uri"], "/onvif/ptz_service")
}
if fields["qop"] != "auth" {
t.Errorf("qop = %q, want %q", fields["qop"], "auth")
}
ha1 := md5Hex(username + ":" + realm + ":" + password)
ha2 := md5Hex("POST:/onvif/ptz_service")
want := md5Hex(strings.Join([]string{ha1, nonce, fields["nc"], fields["cnonce"], "auth", ha2}, ":"))
if fields["response"] != want {
t.Errorf("response = %q, want %q", fields["response"], want)
}
}
// TestNewDigestAuthorizationRejectsUnsupported ensures an empty header is
// returned when required parameters are missing or the qop is unsupported.
func TestNewDigestAuthorizationRejectsUnsupported(t *testing.T) {
if got := newDigestAuthorization(`Digest realm="r"`, "POST", "http://host/x", "u", "p"); got != "" {
t.Errorf("expected empty header when nonce missing, got %q", got)
}
if got := newDigestAuthorization(`Digest realm="r", nonce="n", qop="auth-int"`, "POST", "http://host/x", "u", "p"); got != "" {
t.Errorf("expected empty header for unsupported qop, got %q", got)
}
}

View File

@@ -2,18 +2,24 @@ package networking
import (
"bytes"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/beevik/etree"
"github.com/icholy/digest"
"github.com/juju/errors"
)
const soapContentType = "application/soap+xml; charset=utf-8"
// SendSoap send soap message
func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response, error) {
resp, err := httpClient.Post(endpoint, "application/soap+xml; charset=utf-8", bytes.NewBufferString(message))
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return resp, errors.Annotate(err, "Post")
}
@@ -26,66 +32,180 @@ func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response
return resp, nil
}
// SendSoapWithDigest sends a soap message and, when the device answers with an
// HTTP 401 digest challenge, transparently retries the request with the
// computed HTTP digest Authorization header.
//
// Any wsse:Security header present in the message is stripped before sending:
// when a device requires HTTP digest the credentials travel in the
// Authorization header, so keeping the WS-Security UsernameToken in the body
// 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) {
doc := etree.NewDocument()
if err := doc.ReadFromString(message); err != nil {
return nil, err
if httpClient == nil {
httpClient = new(http.Client)
}
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
}
// Avoid sending the credentials twice (WS-Security + digest) on the retry.
message = stripWSSecurityHeader(message)
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(message))
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return nil, err
return resp, errors.Annotate(err, "Post")
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
resp, err := httpClient.Do(req)
// Only escalate to HTTP digest when the device explicitly asks for it.
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
challenge := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "digest") {
// Not a digest challenge (e.g. Basic) - nothing more we can do here.
return resp, nil
}
authorization := newDigestAuthorization(challenge, http.MethodPost, endpoint, username, password)
if authorization == "" {
return resp, errors.New("unsupported digest challenge")
}
// Release the challenge response before issuing the authenticated retry.
resp.Body.Close()
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(message))
if err != nil {
return nil, errors.Annotate(err, "new digest request")
}
req.Header.Set("Content-Type", soapContentType)
req.Header.Set("Authorization", authorization)
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
}
// 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+)=(?:"([^"]*)"|([^,]+))`)
// parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header.
func parseDigestChallenge(challenge string) map[string]string {
challenge = strings.TrimSpace(challenge)
if i := strings.IndexAny(challenge, " \t"); i >= 0 && strings.EqualFold(challenge[:i], "Digest") {
challenge = challenge[i+1:]
}
result := make(map[string]string)
for _, m := range digestParamRe.FindAllStringSubmatch(challenge, -1) {
value := m[2]
if value == "" {
value = m[3]
}
result[strings.ToLower(m[1])] = strings.TrimSpace(value)
}
return result
}
// newDigestAuthorization builds an RFC 2617 HTTP digest Authorization header
// value. It supports the MD5 and MD5-sess algorithms and the "auth" qop, which
// covers the vast majority of ONVIF devices. It returns an empty string when the
// challenge is missing required parameters or requires an unsupported qop.
func newDigestAuthorization(challenge, method, uri, username, password string) string {
parts := parseDigestChallenge(challenge)
realm := parts["realm"]
nonce := parts["nonce"]
if realm == "" || nonce == "" {
return ""
}
opaque := parts["opaque"]
algorithm := parts["algorithm"]
qop := ""
if rawQop, ok := parts["qop"]; ok {
for _, candidate := range strings.Split(rawQop, ",") {
if strings.TrimSpace(candidate) == "auth" {
qop = "auth"
break
}
}
// The server offered qop but none we support (e.g. auth-int only).
if qop == "" {
return ""
}
}
digestURI := uri
if u, err := url.Parse(uri); err == nil {
digestURI = u.RequestURI()
}
cnonce := randomCnonce()
const nc = "00000001"
ha1 := md5Hex(username + ":" + realm + ":" + password)
if strings.EqualFold(algorithm, "MD5-sess") {
ha1 = md5Hex(ha1 + ":" + nonce + ":" + cnonce)
}
ha2 := md5Hex(method + ":" + digestURI)
var response string
if qop == "auth" {
response = md5Hex(strings.Join([]string{ha1, nonce, nc, cnonce, qop, ha2}, ":"))
} else {
response = md5Hex(ha1 + ":" + nonce + ":" + ha2)
}
var b strings.Builder
fmt.Fprintf(&b, `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
username, realm, nonce, digestURI, response)
if qop == "auth" {
fmt.Fprintf(&b, `, qop=auth, nc=%s, cnonce="%s"`, nc, cnonce)
}
if algorithm != "" {
fmt.Fprintf(&b, `, algorithm=%s`, algorithm)
}
if opaque != "" {
fmt.Fprintf(&b, `, opaque="%s"`, opaque)
}
return b.String()
}
func md5Hex(s string) string {
sum := md5.Sum([]byte(s))
return hex.EncodeToString(sum[:])
}
func randomCnonce() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "00000000"
}
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,31 @@
// Code generated : DO NOT EDIT.
// Copyright (c) 2022 Jean-Francois SMIGIELSKI
// Distributed under the MIT License
package event
import (
"context"
"github.com/juju/errors"
"github.com/kerberos-io/onvif"
"github.com/kerberos-io/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.
func Call_PullMessages(ctx context.Context, dev *onvif.Device, request event.PullMessages) (event.PullMessagesResponse, error) {
type Envelope struct {
Header struct{}
Body struct {
PullMessagesResponse event.PullMessagesResponse
}
}
var reply Envelope
if httpReply, err := dev.CallMethod(request); err != nil {
return reply.Body.PullMessagesResponse, errors.Annotate(err, "call")
} else {
err = sdk.ReadAndParse(ctx, httpReply, &reply, "PullMessages")
return reply.Body.PullMessagesResponse, errors.Annotate(err, "reply")
}
}

8
sdk/event/event.go Normal file
View File

@@ -0,0 +1,8 @@
package event
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event CreatePullPointSubscription
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetEventProperties
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetServiceCapabilities
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Subscribe
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Unsubscribe
//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")
}