From 4b3dc7c7e5c05fc8b4aa7d1aae63887e156a0da0 Mon Sep 17 00:00:00 2001 From: keyldev Date: Sun, 31 May 2026 23:36:26 +0300 Subject: [PATCH] fix(onvif): authenticate ONVIF over HTTP Basic - onvif_simple_server gates device service behind HTTP Basic; Onvif.Core's factory only does WS-UsernameToken over Anonymous transport, so probe 401s - add OnvifClientBuilder: builds clients via the internal ctor and injects a preemptive Authorization header alongside the existing WS-UsernameToken - HttpBasicAuthBehavior adds the header at the message layer (binding untouched) - credential-less endpoints keep prior behavior (no Basic header) --- .../Onvif/HttpBasicAuthBehavior.cs | 58 +++++ .../Onvif/OnvifClientBuilder.cs | 201 ++++++++++++++++++ .../Onvif/OnvifCoreClient.cs | 14 +- 3 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 src/OpenIPC.Viewer.Devices/Onvif/HttpBasicAuthBehavior.cs create mode 100644 src/OpenIPC.Viewer.Devices/Onvif/OnvifClientBuilder.cs diff --git a/src/OpenIPC.Viewer.Devices/Onvif/HttpBasicAuthBehavior.cs b/src/OpenIPC.Viewer.Devices/Onvif/HttpBasicAuthBehavior.cs new file mode 100644 index 0000000..b118146 --- /dev/null +++ b/src/OpenIPC.Viewer.Devices/Onvif/HttpBasicAuthBehavior.cs @@ -0,0 +1,58 @@ +using System; +using System.Net; +using System.ServiceModel; +using System.ServiceModel.Channels; +using System.ServiceModel.Description; +using System.ServiceModel.Dispatcher; +using System.Text; + +namespace OpenIPC.Viewer.Devices.Onvif; + +// Injects a preemptive HTTP "Authorization: Basic …" header on every outgoing SOAP +// request. ONVIF servers like OpenIPC's onvif_simple_server gate the device service +// behind HTTP Basic (401 "Basic realm=Authentication"); WCF's default Anonymous +// transport never sends credentials, so we add them at the message layer instead of +// reconfiguring the binding. Cameras that don't require it simply ignore the header. +internal sealed class HttpBasicAuthBehavior : IEndpointBehavior +{ + private readonly string _headerValue; + + public HttpBasicAuthBehavior(string username, string password) + { + var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + _headerValue = "Basic " + token; + } + + public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime) => + clientRuntime.ClientMessageInspectors.Add(new Inspector(_headerValue)); + + public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { } + public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { } + public void Validate(ServiceEndpoint endpoint) { } + + private sealed class Inspector : IClientMessageInspector + { + private readonly string _headerValue; + + public Inspector(string headerValue) => _headerValue = headerValue; + + public object? BeforeSendRequest(ref Message request, IClientChannel channel) + { + HttpRequestMessageProperty http; + if (request.Properties.TryGetValue(HttpRequestMessageProperty.Name, out var existing)) + { + http = (HttpRequestMessageProperty)existing; + } + else + { + http = new HttpRequestMessageProperty(); + request.Properties.Add(HttpRequestMessageProperty.Name, http); + } + + http.Headers[HttpRequestHeader.Authorization] = _headerValue; + return null; + } + + public void AfterReceiveReply(ref Message reply, object? correlationState) { } + } +} diff --git a/src/OpenIPC.Viewer.Devices/Onvif/OnvifClientBuilder.cs b/src/OpenIPC.Viewer.Devices/Onvif/OnvifClientBuilder.cs new file mode 100644 index 0000000..ab0ac31 --- /dev/null +++ b/src/OpenIPC.Viewer.Devices/Onvif/OnvifClientBuilder.cs @@ -0,0 +1,201 @@ +using System; +using System.Reflection; +using System.ServiceModel; +using System.ServiceModel.Channels; +using System.ServiceModel.Description; +using System.Threading.Tasks; +using Onvif.Core.Client; +using Onvif.Core.Client.Common; +using Onvif.Core.Client.Device; +using Onvif.Core.Client.Media; +using Onvif.Core.Client.Ptz; +using Onvif.Core.Client.Security; +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Devices.Onvif; + +// Replacement for Onvif.Core's OnvifClientFactory. The upstream factory builds an +// HTTP binding with AuthenticationScheme=Anonymous and only authenticates via the +// SOAP WS-UsernameToken header. OpenIPC's onvif_simple_server enforces HTTP Basic +// at the transport (401 "Basic realm=Authentication"), so the factory's first +// GetSystemDateAndTime call dies before any WS header is in play. +// +// We keep the upstream binding + WS-UsernameToken flow (so cameras that only want +// the SOAP header keep working) and additionally inject a preemptive HTTP Basic +// "Authorization" header on every request via a message inspector. A camera that +// doesn't need it ignores the extra header; OpenIPC accepts it. No transport-scheme +// negotiation, no extra round-trips. +// +// The generated DeviceClient/MediaClient/PTZClient only expose an *internal* +// (Binding, EndpointAddress) constructor, so we invoke it reflectively — the upstream +// factory is the only public way to build them and it can't be made to do HTTP auth. +internal static class OnvifClientBuilder +{ + public static async Task CreateDeviceClientAsync(OnvifEndpoint ep) + { + if (ep.Credentials is null) + return await CreatePreAuthDeviceAsync(ep.DeviceServiceUri).ConfigureAwait(false); + + var creds = CameraCredentialsView.From(ep.Credentials); + var (device, _) = await OpenAuthedDeviceAsync(ep.DeviceServiceUri, creds).ConfigureAwait(false); + return device; + } + + public static async Task CreateMediaClientAsync(OnvifEndpoint ep) + { + var creds = CameraCredentialsView.From(ep.Credentials); + var (device, shift) = await OpenAuthedDeviceAsync(ep.DeviceServiceUri, creds).ConfigureAwait(false); + try + { + var caps = await device.GetCapabilitiesAsync(new[] { CapabilityCategory.Media }).ConfigureAwait(false); + var media = Build(new Uri(caps.Capabilities.Media.XAddr), creds, shift); + await media.OpenAsync().ConfigureAwait(false); + return media; + } + finally + { + CloseQuietly(device); + } + } + + public static async Task CreatePtzClientAsync(OnvifEndpoint ep) + { + var creds = CameraCredentialsView.From(ep.Credentials); + var (device, shift) = await OpenAuthedDeviceAsync(ep.DeviceServiceUri, creds).ConfigureAwait(false); + try + { + var caps = await device.GetCapabilitiesAsync(new[] { CapabilityCategory.PTZ }).ConfigureAwait(false); + var ptz = Build(new Uri(caps.Capabilities.PTZ.XAddr), creds, shift); + await ptz.OpenAsync().ConfigureAwait(false); + return ptz; + } + finally + { + CloseQuietly(device); + } + } + + private static async Task CreatePreAuthDeviceAsync(Uri uri) + { + var device = Construct(CreateBinding(), uri); + device.ChannelFactory.Endpoint.EndpointBehaviors.Clear(); + await device.OpenAsync().ConfigureAwait(false); + return device; + } + + // Probes GetSystemDateAndTime (Basic header injected, no WS header yet) to get the + // clock offset the WS-UsernameToken digest needs, then builds the real device client + // carrying both auth forms. Mirrors the upstream two-step time-shift dance. + private static async Task<(DeviceClient device, TimeSpan shift)> OpenAuthedDeviceAsync(Uri uri, CameraCredentialsView creds) + { + var probe = Construct(CreateBinding(), uri); + probe.ChannelFactory.Endpoint.EndpointBehaviors.Clear(); + AddHttpBasic(probe.ChannelFactory.Endpoint, creds); + + TimeSpan shift; + try + { + shift = await probe.GetDeviceTimeShift().ConfigureAwait(false); + } + finally + { + CloseQuietly(probe); + } + + var device = Build(uri, creds, shift); + await device.OpenAsync().ConfigureAwait(false); + return (device, shift); + } + + // Constructs a client and attaches both auth behaviors: preemptive HTTP Basic on the + // transport plus WS-UsernameToken in the SOAP header. + private static T Build(Uri uri, CameraCredentialsView creds, TimeSpan shift) + where T : class + { + var client = Construct(CreateBinding(), uri); + var endpoint = EndpointOf(client); + endpoint.EndpointBehaviors.Clear(); + AddHttpBasic(endpoint, creds); + endpoint.EndpointBehaviors.Add(new SoapSecurityHeaderBehavior(creds.Username, creds.Password, shift)); + return client; + } + + // Preemptive HTTP Basic only when a username is set — credential-less endpoints keep + // their previous behavior (anonymous transport, empty WS header) untouched. + private static void AddHttpBasic(ServiceEndpoint endpoint, CameraCredentialsView creds) + { + if (!string.IsNullOrEmpty(creds.Username)) + endpoint.EndpointBehaviors.Add(new HttpBasicAuthBehavior(creds.Username, creds.Password)); + } + + // ClientBase.ChannelFactory is typed ChannelFactory; its non-generic base + // exposes Endpoint. Reached reflectively so Build stays channel-agnostic. + private static ServiceEndpoint EndpointOf(object client) + { + var factory = (ChannelFactory)client.GetType() + .GetProperty("ChannelFactory", BindingFlags.Instance | BindingFlags.Public)! + .GetValue(client)!; + return factory.Endpoint; + } + + private static Binding CreateBinding() + { + var binding = new CustomBinding(); + binding.Elements.Add(new TextMessageEncodingBindingElement + { + MessageVersion = MessageVersion.CreateVersion(EnvelopeVersion.Soap12, AddressingVersion.None), + }); + binding.Elements.Add(new HttpTransportBindingElement + { + AllowCookies = true, + MaxBufferSize = int.MaxValue, + MaxReceivedMessageSize = int.MaxValue, + }); + return binding; + } + + // The generated clients only expose an internal (Binding, EndpointAddress) ctor. + private static T Construct(Binding binding, Uri uri) where T : class + { + var ctor = typeof(T).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + new[] { typeof(Binding), typeof(EndpointAddress) }, + modifiers: null); + if (ctor is null) + throw new InvalidOperationException($"{typeof(T).Name} has no (Binding, EndpointAddress) constructor"); + return (T)ctor.Invoke(new object[] { binding, new EndpointAddress(uri) }); + } + + private static void CloseQuietly(ICommunicationObject co) + { + try + { + if (co.State == CommunicationState.Faulted) + co.Abort(); + else + co.Close(); + } + catch + { + try { co.Abort(); } catch { /* swallow */ } + } + } + + // Flattens nullable credentials to non-null strings (empty == anonymous, matching the + // upstream factory's "" / "" calls for credential-less endpoints). + private readonly struct CameraCredentialsView + { + public string Username { get; } + public string Password { get; } + + private CameraCredentialsView(string username, string password) + { + Username = username; + Password = password; + } + + public static CameraCredentialsView From(Core.Entities.CameraCredentials? creds) => + new(creds?.Username ?? "", creds?.Password ?? ""); + } +} diff --git a/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs index ef8144e..88c90a0 100644 --- a/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs +++ b/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs @@ -194,21 +194,13 @@ public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, } private static Task OpenDeviceAsync(OnvifEndpoint ep) => - ep.Credentials is null - ? OnvifClientFactory.CreatePreAuthDeviceClientAsync(ep.DeviceServiceUri) - : OnvifClientFactory.CreateDeviceClientAsync(ep.DeviceServiceUri, ep.Credentials.Username, ep.Credentials.Password); + OnvifClientBuilder.CreateDeviceClientAsync(ep); private static Task OpenMediaAsync(OnvifEndpoint ep) => - OnvifClientFactory.CreateMediaClientAsync(HostString(ep), ep.Credentials?.Username ?? "", ep.Credentials?.Password ?? ""); + OnvifClientBuilder.CreateMediaClientAsync(ep); private static Task OpenPtzAsync(OnvifEndpoint ep) => - OnvifClientFactory.CreatePTZClientAsync(HostString(ep), ep.Credentials?.Username ?? "", ep.Credentials?.Password ?? ""); - - private static string HostString(OnvifEndpoint ep) - { - var uri = ep.DeviceServiceUri; - return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}"; - } + OnvifClientBuilder.CreatePtzClientAsync(ep); private static Uri? TryUri(string? value) => string.IsNullOrWhiteSpace(value) ? null : new Uri(value, UriKind.Absolute);