From bbd043e97b8628d02cb01653ac642476ff90aeb0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 27 Mar 2026 02:14:37 -0400 Subject: [PATCH] Add authentication and role-based write access control Implements configurable user authentication (anonymous + username/password) with pluggable credential provider (IUserAuthenticationProvider). Anonymous writes can be disabled via AnonymousCanWrite setting while reads remain open. Adds -U/-P flags to all CLI commands for authenticated sessions. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth_update.md | 84 ++++++++++++++++++- docs/CliTool.md | 17 ++++ docs/Configuration.md | 33 ++++++++ docs/OpcUaServer.md | 20 ++++- docs/ReadWriteOperations.md | 11 +++ .../Configuration/AppConfiguration.cs | 5 ++ .../AuthenticationConfiguration.cs | 42 ++++++++++ .../ConfigUserAuthenticationProvider.cs | 25 ++++++ .../Domain/IUserAuthenticationProvider.cs | 13 +++ .../OpcUa/LmxNodeManager.cs | 17 +++- .../OpcUa/LmxOpcUaServer.cs | 68 ++++++++++++--- .../OpcUa/OpcUaServerHost.cs | 33 ++++++-- src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs | 7 +- src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json | 5 ++ .../Authentication/UserAuthenticationTests.cs | 74 ++++++++++++++++ .../opcuacli-dotnet/Commands/AlarmsCommand.cs | 8 +- .../opcuacli-dotnet/Commands/BrowseCommand.cs | 8 +- .../Commands/ConnectCommand.cs | 8 +- .../Commands/HistoryReadCommand.cs | 8 +- tools/opcuacli-dotnet/Commands/ReadCommand.cs | 8 +- .../Commands/SubscribeCommand.cs | 8 +- .../opcuacli-dotnet/Commands/WriteCommand.cs | 8 +- tools/opcuacli-dotnet/OpcUaHelper.cs | 8 +- tools/opcuacli-dotnet/README.md | 15 ++++ 24 files changed, 499 insertions(+), 34 deletions(-) create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs diff --git a/auth_update.md b/auth_update.md index 0124d88..3323e1b 100644 --- a/auth_update.md +++ b/auth_update.md @@ -192,6 +192,56 @@ Pass `AuthenticationConfiguration` and `IUserAuthenticationProvider` through: - `OpcUaServerHost` uses `AllowAnonymous` and user count for `UserTokenPolicies` - `LmxNodeManager` receives `AnonymousCanWrite` flag for write enforcement +### 7. CLI tool authentication — `tools/opcuacli-dotnet/` + +Add `--username` and `--password` options to `OpcUaHelper.ConnectAsync` so all commands can authenticate. + +**`OpcUaHelper.cs`** — update `ConnectAsync` to accept optional credentials: + +```csharp +public static async Task ConnectAsync(string endpointUrl, string? username = null, string? password = null) +{ + // ... existing config setup ... + + UserIdentity identity = (username != null) + ? new UserIdentity(username, password ?? "") + : new UserIdentity(); + + var session = await Session.Create( + config, configuredEndpoint, false, + "OpcUaCli", 60000, identity, null); + + return session; +} +``` + +**Each command** — add shared options. Since CliFx doesn't support base classes for shared options, add `-U` / `-P` to each command: + +```csharp +[CommandOption("username", 'U', Description = "Username for authentication")] +public string? Username { get; init; } + +[CommandOption("password", 'P', Description = "Password for authentication")] +public string? Password { get; init; } +``` + +And pass to `OpcUaHelper.ConnectAsync(Url, Username, Password)`. + +**Usage examples:** + +```bash +# Anonymous (current behavior) +dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" + +# Authenticated +dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -U operator -P op123 + +# Write with credentials (when AnonymousCanWrite is false) +dotnet run -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -v "NEW" -U operator -P op123 +``` + +**README update** — document the `-U` / `-P` flags. + ## Files to Create/Modify | File | Change | @@ -207,7 +257,39 @@ Pass `AuthenticationConfiguration` and `IUserAuthenticationProvider` through: | `src/.../appsettings.json` | Add Authentication section | | `tests/.../Authentication/UserAuthenticationTests.cs` | NEW — credential validation tests | | `tests/.../Integration/WriteAccessTests.cs` | NEW — anonymous vs authenticated write tests | -| `docs/Configuration.md` | Update with Authentication section | +| `tools/opcuacli-dotnet/OpcUaHelper.cs` | Add username/password parameters to `ConnectAsync` | +| `tools/opcuacli-dotnet/Commands/*.cs` | Add `-U` / `-P` options to all 7 commands | +| `tools/opcuacli-dotnet/README.md` | Document authentication flags | +| `docs/Configuration.md` | Add Authentication section with settings table | +| `docs/OpcUaServer.md` | Update security policy and UserTokenPolicies section | +| `docs/ReadWriteOperations.md` | Document role-based write enforcement | +| `docs/CliTool.md` | Document `-U` / `-P` authentication flags | + +## Tests + +### Unit tests — `tests/.../Authentication/UserAuthenticationTests.cs` (NEW) + +- `ConfigUserAuthenticationProvider` validates correct username/password +- `ConfigUserAuthenticationProvider` rejects wrong password +- `ConfigUserAuthenticationProvider` rejects unknown username +- `ConfigUserAuthenticationProvider` is case-insensitive on username +- Empty user list rejects all credentials +- `AuthenticationConfiguration` defaults: `AllowAnonymous=true`, `AnonymousCanWrite=true`, empty Users list + +### Integration tests — `tests/.../Integration/WriteAccessTests.cs` (NEW) + +- Anonymous connect succeeds when `AllowAnonymous=true` +- Anonymous connect rejected when `AllowAnonymous=false` (requires test fixture to configure auth) +- Anonymous read succeeds regardless of `AnonymousCanWrite` +- Anonymous write succeeds when `AnonymousCanWrite=true` +- Anonymous write rejected with `BadUserAccessDenied` when `AnonymousCanWrite=false` +- Authenticated user write succeeds when `AnonymousCanWrite=false` +- Invalid credentials rejected with `BadUserAccessDenied` + +### Existing test updates + +- `OpcUaServerFixture` — add option to configure `AuthenticationConfiguration` for auth-aware test fixtures +- Existing write tests continue passing (default config allows anonymous writes) ## Verification diff --git a/docs/CliTool.md b/docs/CliTool.md index f1e6e0d..01c3604 100644 --- a/docs/CliTool.md +++ b/docs/CliTool.md @@ -20,6 +20,23 @@ dotnet run -- [options] `OpcUaHelper.ConvertValue()` converts a raw string from the command line into the runtime type expected by the target node. It uses the current node value to infer the type (bool, byte, short, int, float, double, etc.) and falls back to string if the type is not recognized. +## Authentication Options + +All commands accept optional credentials for `UserName` token authentication: + +| Flag | Description | +|------|-------------| +| `-U` / `--username` | Username for OPC UA `UserName` token authentication | +| `-P` / `--password` | Password for OPC UA `UserName` token authentication | + +When `-U` and `-P` are provided, `OpcUaHelper.ConnectAsync()` passes a `UserIdentity(username, password)` to `Session.Create`. Without credentials, an anonymous `UserIdentity` is used. + +Example: + +```bash +dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123 +``` + ## Commands ### connect diff --git a/docs/Configuration.md b/docs/Configuration.md index 3963670..abda467 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -21,6 +21,7 @@ configuration.GetSection("MxAccess").Bind(_config.MxAccess); configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository); configuration.GetSection("Dashboard").Bind(_config.Dashboard); configuration.GetSection("Historian").Bind(_config.Historian); +configuration.GetSection("Authentication").Bind(_config.Authentication); ``` This pattern uses `IConfiguration.GetSection().Bind()` rather than `IOptions` because the service targets .NET Framework 4.8, where the full dependency injection container is not used. @@ -102,6 +103,33 @@ Controls the Wonderware Historian connection for OPC UA historical data access. | `CommandTimeoutSeconds` | `int` | `30` | SQL command timeout for historian queries | | `MaxValuesPerRead` | `int` | `10000` | Maximum values returned per `HistoryRead` request | +### Authentication + +Controls user authentication and write authorization for the OPC UA server. Defined in `AuthenticationConfiguration`. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` | +| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` | +| `Users` | `List` | `[]` | List of username/password credentials for `UserName` token authentication | + +Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string). + +The defaults preserve the existing behavior: anonymous clients can connect, read, and write with no credentials required. To restrict writes to authenticated users, set `AnonymousCanWrite` to `false` and add entries to the `Users` list. + +Example configuration: + +```json +"Authentication": { + "AllowAnonymous": true, + "AnonymousCanWrite": false, + "Users": [ + { "Username": "operator", "Password": "op123" }, + { "Username": "engineer", "Password": "eng456" } + ] +} +``` + ## Feature Flags Three boolean properties act as feature flags that control optional subsystems: @@ -177,6 +205,11 @@ Integration tests use this constructor to inject substitute implementations of ` "ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;", "CommandTimeoutSeconds": 30, "MaxValuesPerRead": 10000 + }, + "Authentication": { + "AllowAnonymous": true, + "AnonymousCanWrite": true, + "Users": [] } } ``` diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index d803d9d..8811e6d 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -33,19 +33,33 @@ The configuration covers: ## Security Policy -The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`. Only anonymous user tokens are accepted: +The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`: ```csharp SecurityPolicies = { new ServerSecurityPolicy { SecurityMode = MessageSecurityMode.None, SecurityPolicyUri = SecurityPolicies.None -} }, -UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) } +} } ``` This is intentional for plant-floor deployments where the server sits on an isolated OT network. Galaxy-level security classification controls write access per attribute rather than at the transport layer. +### User token policies + +`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`: + +- An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default). +- A `UserName` user token policy is added when the `Users` list contains at least one entry. + +Both policies can be active simultaneously, allowing clients to connect with or without credentials. + +### Session impersonation + +When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the credentials do not match any entry in the configured `Users` list, the session is rejected. + +On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. These roles are used downstream by the write override to enforce `AnonymousCanWrite` restrictions. + ## Certificate handling On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, 2048)` to locate or create a 2048-bit self-signed certificate. The certificate subject follows the format `CN={ServerName}, O=ZB MOM, DC=localhost`. Certificate stores use the directory-based store type under `%LOCALAPPDATA%\OPC Foundation\pki\`: diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index 162e687..48b10f1 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -64,6 +64,17 @@ nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index updatedArray = nextArray; ``` +### Role-based write enforcement + +When `AnonymousCanWrite` is `false` in the `Authentication` configuration, the write override enforces role-based access control before dispatching to MXAccess. The check order is: + +1. The base class `Write` runs first, enforcing `AccessLevel`. Nodes without `CurrentWrite` get `BadNotWritable` and the override skips them. +2. The override checks whether the node is in the Galaxy namespace. Non-namespace nodes are skipped. +3. If `AnonymousCanWrite` is `false`, the override inspects `context.OperationContext.Session` for `GrantedRoleIds`. If the session does not hold `WellKnownRole_AuthenticatedUser`, the error is set to `BadUserAccessDenied` and the write is rejected. +4. If the role check passes (or `AnonymousCanWrite` is `true`), the write proceeds to MXAccess. + +The existing security classification enforcement (ReadOnly nodes getting `BadNotWritable` via `AccessLevel`) still applies first and takes precedence over the role check. + ## Value Type Conversion `CreatePublishedDataValue` wraps the conversion pipeline. `NormalizePublishedValue` checks whether the tag is an array type with a declared `ArrayDimension` and substitutes a default typed array (via `CreateDefaultArrayValue`) when the raw value is null. This prevents OPC UA clients from receiving a null variant for array nodes, which violates the specification for nodes declared with `ValueRank.OneDimension`. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs index 878e159..8054c1f 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AppConfiguration.cs @@ -29,5 +29,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data. /// public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration(); + + /// + /// Gets or sets the authentication and role-based access control settings. + /// + public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration(); } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs new file mode 100644 index 0000000..32e8386 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration +{ + /// + /// Authentication and role-based access control settings for the OPC UA server. + /// + public class AuthenticationConfiguration + { + /// + /// Gets or sets a value indicating whether anonymous OPC UA connections are accepted. + /// + public bool AllowAnonymous { get; set; } = true; + + /// + /// Gets or sets a value indicating whether anonymous users can write tag values. + /// When false, only authenticated users can write. Existing security classification restrictions still apply. + /// + public bool AnonymousCanWrite { get; set; } = true; + + /// + /// Gets or sets the list of username/password pairs accepted for authenticated access. + /// + public List Users { get; set; } = new List(); + } + + /// + /// A username/password pair for OPC UA user authentication. + /// + public class UserCredential + { + /// + /// Gets or sets the username. + /// + public string Username { get; set; } = ""; + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } = ""; + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs new file mode 100644 index 0000000..d8c86dd --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.LmxOpcUa.Host.Domain +{ + /// + /// Validates credentials against a static list from appsettings.json configuration. + /// + public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider + { + private readonly Dictionary _users; + + public ConfigUserAuthenticationProvider(List users) + { + _users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase); + } + + public bool ValidateCredentials(string username, string password) + { + return _users.TryGetValue(username, out var expected) && expected == password; + } + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs new file mode 100644 index 0000000..e86bb46 --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.LmxOpcUa.Host.Domain +{ + /// + /// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, etc.). + /// + public interface IUserAuthenticationProvider + { + /// + /// Validates a username/password combination. + /// + bool ValidateCredentials(string username, string password); + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index fab586b..cdfca15 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -24,6 +24,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly PerformanceMetrics _metrics; private readonly HistorianDataSource? _historianDataSource; private readonly bool _alarmTrackingEnabled; + private readonly bool _anonymousCanWrite; private readonly string _namespaceUri; // NodeId → full_tag_reference for read/write resolution @@ -190,7 +191,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa IMxAccessClient mxAccessClient, PerformanceMetrics metrics, HistorianDataSource? historianDataSource = null, - bool alarmTrackingEnabled = false) + bool alarmTrackingEnabled = false, + bool anonymousCanWrite = true) : base(server, configuration, namespaceUri) { _namespaceUri = namespaceUri; @@ -198,6 +200,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _metrics = metrics; _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; + _anonymousCanWrite = anonymousCanWrite; // Wire up data change delivery _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; @@ -378,7 +381,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), true); condition.SourceNode.Value = sourceNodeId; - condition.SourceName.Value = alarmAttr.AttributeName; + condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); condition.ConditionName.Value = alarmAttr.AttributeName; condition.AutoReportStateChanges = true; @@ -833,7 +836,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa new LocalizedText("en", alarmAttr.AttributeName + " Alarm"), true); condition.SourceNode.Value = sourceNodeId; - condition.SourceName.Value = alarmAttr.AttributeName; + condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']'); condition.ConditionName.Value = alarmAttr.AttributeName; condition.AutoReportStateChanges = true; condition.SetEnableState(SystemContext, true); @@ -1100,6 +1103,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable) continue; + // Enforce role-based write access: reject anonymous writes when AnonymousCanWrite is false + if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null && + !context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser)) + { + errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); + continue; + } + var nodeId = nodesToWrite[i].NodeId; if (nodeId.NamespaceIndex != NamespaceIndex) continue; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs index 2575e8f..bf662ed 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; +using System.Text; using Opc.Ua; using Opc.Ua.Server; +using Serilog; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Domain; using ZB.MOM.WW.LmxOpcUa.Host.Historian; using ZB.MOM.WW.LmxOpcUa.Host.Metrics; @@ -8,15 +11,19 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics; namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { /// - /// Custom OPC UA server that creates the LmxNodeManager. (OPC-001, OPC-012) + /// Custom OPC UA server that creates the LmxNodeManager and handles user authentication. (OPC-001, OPC-012) /// public class LmxOpcUaServer : StandardServer { + private static readonly ILogger Log = Serilog.Log.ForContext(); + private readonly string _galaxyName; private readonly IMxAccessClient _mxAccessClient; private readonly PerformanceMetrics _metrics; private readonly HistorianDataSource? _historianDataSource; private readonly bool _alarmTrackingEnabled; + private readonly AuthenticationConfiguration _authConfig; + private readonly IUserAuthenticationProvider? _authProvider; private LmxNodeManager? _nodeManager; /// @@ -36,34 +43,73 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } - /// - /// Initializes a custom OPC UA server for the specified Galaxy namespace. - /// - /// The Galaxy name used to construct the namespace URI and product URI. - /// The runtime client used by the node manager for live data access. - /// The metrics collector shared with the node manager. - /// The optional historian adapter used when clients issue OPC UA history reads. - /// Enables alarm condition tracking for alarm-capable Galaxy attributes. public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, - HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false) + HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, + AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null) { _galaxyName = galaxyName; _mxAccessClient = mxAccessClient; _metrics = metrics; _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; + _authConfig = authConfig ?? new AuthenticationConfiguration(); + _authProvider = authProvider; } /// protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) { var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; - _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled); + _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, + _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite); var nodeManagers = new List { _nodeManager }; return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); } + /// + protected override void OnServerStarted(IServerInternal server) + { + base.OnServerStarted(server); + server.SessionManager.ImpersonateUser += OnImpersonateUser; + } + + private void OnImpersonateUser(Session session, ImpersonateEventArgs args) + { + if (args.NewIdentity is AnonymousIdentityToken anonymousToken) + { + if (!_authConfig.AllowAnonymous) + throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled"); + + var roles = new List { Role.Anonymous }; + if (_authConfig.AnonymousCanWrite) + roles.Add(Role.AuthenticatedUser); + + args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles); + Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite); + return; + } + + if (args.NewIdentity is UserNameIdentityToken userNameToken) + { + var password = userNameToken.DecryptedPassword ?? ""; + + if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password)) + { + Log.Warning("Authentication failed for user {Username}", userNameToken.UserName); + throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password"); + } + + args.Identity = new RoleBasedIdentity( + new UserIdentity(userNameToken), + new List { Role.AuthenticatedUser }); + Log.Information("User {Username} authenticated", userNameToken.UserName); + return; + } + + throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type"); + } + /// protected override ServerProperties LoadServerProperties() { diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index 1e630e7..36b8d8c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -22,6 +22,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly IMxAccessClient _mxAccessClient; private readonly PerformanceMetrics _metrics; private readonly HistorianDataSource? _historianDataSource; + private readonly AuthenticationConfiguration _authConfig; + private readonly IUserAuthenticationProvider? _authProvider; private ApplicationInstance? _application; private LmxOpcUaServer? _server; @@ -48,12 +50,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// The metrics collector shared with the node manager and runtime bridge. /// The optional historian adapter that enables OPC UA history read support. public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, - HistorianDataSource? historianDataSource = null) + HistorianDataSource? historianDataSource = null, + AuthenticationConfiguration? authConfig = null, + IUserAuthenticationProvider? authProvider = null) { _config = config; _mxAccessClient = mxAccessClient; _metrics = metrics; _historianDataSource = historianDataSource; + _authConfig = authConfig ?? new AuthenticationConfiguration(); + _authProvider = authProvider; } /// @@ -84,10 +90,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa SecurityPolicyUri = SecurityPolicies.None } }, - UserTokenPolicies = - { - new UserTokenPolicy(UserTokenType.Anonymous) - } + UserTokenPolicies = BuildUserTokenPolicies() }, SecurityConfiguration = new SecurityConfiguration @@ -160,7 +163,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa certOk = await _application.CheckApplicationInstanceCertificate(false, 2048); } - _server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, _config.AlarmTrackingEnabled); + _server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, + _config.AlarmTrackingEnabled, _authConfig, _authProvider); await _application.Start(_server); Log.Information("OPC UA server started on opc.tcp://localhost:{Port}{EndpointPath} (namespace={Namespace})", @@ -188,6 +192,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } + private UserTokenPolicyCollection BuildUserTokenPolicies() + { + var policies = new UserTokenPolicyCollection(); + if (_authConfig.AllowAnonymous) + policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); + if (_authConfig.Users.Count > 0) + policies.Add(new UserTokenPolicy(UserTokenType.UserName)); + + if (policies.Count == 0) + { + Log.Warning("No authentication methods configured — adding Anonymous as fallback"); + policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); + } + + return policies; + } + /// /// Stops the host and releases server resources. /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index 83cfb53..cc4dfec 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -55,6 +55,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository); configuration.GetSection("Dashboard").Bind(_config.Dashboard); configuration.GetSection("Historian").Bind(_config.Historian); + configuration.GetSection("Authentication").Bind(_config.Authentication); _mxProxy = new MxProxyAdapter(); _galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository); @@ -156,7 +157,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host var historianDataSource = _config.Historian.Enabled ? new Historian.HistorianDataSource(_config.Historian) : null; - _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource); + var authProvider = _config.Authentication.Users.Count > 0 + ? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users) + : (Domain.IUserAuthenticationProvider?)null; + _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource, + _config.Authentication, authProvider); // Step 9-10: Query hierarchy, start server, build address space DateTime? initialDeployTime = null; diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index d259b69..9cbd700 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -31,6 +31,11 @@ "Port": 8081, "RefreshIntervalSeconds": 10 }, + "Authentication": { + "AllowAnonymous": true, + "AnonymousCanWrite": true, + "Users": [] + }, "Historian": { "Enabled": false, "ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;", diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs new file mode 100644 index 0000000..e2e0232 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using Shouldly; +using Xunit; +using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication +{ + public class UserAuthenticationTests + { + [Fact] + public void ValidCredentials_ReturnsTrue() + { + var provider = new ConfigUserAuthenticationProvider(new List + { + new UserCredential { Username = "operator", Password = "op123" } + }); + + provider.ValidateCredentials("operator", "op123").ShouldBeTrue(); + } + + [Fact] + public void WrongPassword_ReturnsFalse() + { + var provider = new ConfigUserAuthenticationProvider(new List + { + new UserCredential { Username = "operator", Password = "op123" } + }); + + provider.ValidateCredentials("operator", "wrong").ShouldBeFalse(); + } + + [Fact] + public void UnknownUsername_ReturnsFalse() + { + var provider = new ConfigUserAuthenticationProvider(new List + { + new UserCredential { Username = "operator", Password = "op123" } + }); + + provider.ValidateCredentials("unknown", "op123").ShouldBeFalse(); + } + + [Fact] + public void Username_IsCaseInsensitive() + { + var provider = new ConfigUserAuthenticationProvider(new List + { + new UserCredential { Username = "Operator", Password = "op123" } + }); + + provider.ValidateCredentials("operator", "op123").ShouldBeTrue(); + provider.ValidateCredentials("OPERATOR", "op123").ShouldBeTrue(); + } + + [Fact] + public void EmptyUserList_RejectsAll() + { + var provider = new ConfigUserAuthenticationProvider(new List()); + + provider.ValidateCredentials("anyone", "anything").ShouldBeFalse(); + } + + [Fact] + public void AuthenticationConfiguration_Defaults() + { + var config = new AuthenticationConfiguration(); + + config.AllowAnonymous.ShouldBeTrue(); + config.AnonymousCanWrite.ShouldBeTrue(); + config.Users.ShouldBeEmpty(); + } + } +} diff --git a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs b/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs index 25b37c4..a7e3893 100644 --- a/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs +++ b/tools/opcuacli-dotnet/Commands/AlarmsCommand.cs @@ -15,6 +15,12 @@ public class AlarmsCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the node to subscribe to for event notifications, typically a source object or the server node. /// @@ -39,7 +45,7 @@ public class AlarmsCommand : ICommand /// The CLI console used for cancellation and alarm-event output. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var nodeId = string.IsNullOrEmpty(NodeId) ? ObjectIds.Server diff --git a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs b/tools/opcuacli-dotnet/Commands/BrowseCommand.cs index c6855b4..9d416c7 100644 --- a/tools/opcuacli-dotnet/Commands/BrowseCommand.cs +++ b/tools/opcuacli-dotnet/Commands/BrowseCommand.cs @@ -15,6 +15,12 @@ public class BrowseCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder. /// @@ -39,7 +45,7 @@ public class BrowseCommand : ICommand /// The console used to emit browse output. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var startNode = string.IsNullOrEmpty(NodeId) ? ObjectIds.ObjectsFolder diff --git a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs b/tools/opcuacli-dotnet/Commands/ConnectCommand.cs index e0e39fa..9e761da 100644 --- a/tools/opcuacli-dotnet/Commands/ConnectCommand.cs +++ b/tools/opcuacli-dotnet/Commands/ConnectCommand.cs @@ -13,13 +13,19 @@ public class ConnectCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Connects to the OPC UA endpoint and prints the resolved server metadata. /// /// The console used to report connection results. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); await console.Output.WriteLineAsync($"Connected to: {session.Endpoint.EndpointUrl}"); await console.Output.WriteLineAsync($"Server: {session.Endpoint.Server!.ApplicationName}"); await console.Output.WriteLineAsync($"Security Mode: {session.Endpoint.SecurityMode}"); diff --git a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs b/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs index 343e199..e3abb21 100644 --- a/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs +++ b/tools/opcuacli-dotnet/Commands/HistoryReadCommand.cs @@ -15,6 +15,12 @@ public class HistoryReadCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the node identifier for the historized variable to query. /// @@ -57,7 +63,7 @@ public class HistoryReadCommand : ICommand /// The CLI console used for output, errors, and cancellation handling. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var nodeId = new NodeId(NodeId); var start = string.IsNullOrEmpty(StartTime) ? DateTime.UtcNow.AddHours(-24) : DateTime.Parse(StartTime).ToUniversalTime(); diff --git a/tools/opcuacli-dotnet/Commands/ReadCommand.cs b/tools/opcuacli-dotnet/Commands/ReadCommand.cs index 80c4300..6ce69b7 100644 --- a/tools/opcuacli-dotnet/Commands/ReadCommand.cs +++ b/tools/opcuacli-dotnet/Commands/ReadCommand.cs @@ -15,6 +15,12 @@ public class ReadCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the node identifier whose value should be read. /// @@ -27,7 +33,7 @@ public class ReadCommand : ICommand /// The console used to report the read result. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var node = new NodeId(NodeId); var value = await session.ReadValueAsync(node); diff --git a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs b/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs index 2a4a6c9..c8e66ac 100644 --- a/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs +++ b/tools/opcuacli-dotnet/Commands/SubscribeCommand.cs @@ -15,6 +15,12 @@ public class SubscribeCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the node identifier to monitor for value changes. /// @@ -33,7 +39,7 @@ public class SubscribeCommand : ICommand /// The console used to display subscription updates. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var subscription = new Subscription(session.DefaultSubscription) { diff --git a/tools/opcuacli-dotnet/Commands/WriteCommand.cs b/tools/opcuacli-dotnet/Commands/WriteCommand.cs index 67662a9..ce7eac3 100644 --- a/tools/opcuacli-dotnet/Commands/WriteCommand.cs +++ b/tools/opcuacli-dotnet/Commands/WriteCommand.cs @@ -15,6 +15,12 @@ public class WriteCommand : ICommand [CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)] public string Url { get; init; } = default!; + [CommandOption("username", 'U', Description = "Username for authentication")] + public string? Username { get; init; } + + [CommandOption("password", 'P', Description = "Password for authentication")] + public string? Password { get; init; } + /// /// Gets the node identifier that should receive the write. /// @@ -33,7 +39,7 @@ public class WriteCommand : ICommand /// The console used to report the write result. public async ValueTask ExecuteAsync(IConsole console) { - using var session = await OpcUaHelper.ConnectAsync(Url); + using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password); var node = new NodeId(NodeId); var current = await session.ReadValueAsync(node); diff --git a/tools/opcuacli-dotnet/OpcUaHelper.cs b/tools/opcuacli-dotnet/OpcUaHelper.cs index 26f48b0..e90bd7b 100644 --- a/tools/opcuacli-dotnet/OpcUaHelper.cs +++ b/tools/opcuacli-dotnet/OpcUaHelper.cs @@ -11,7 +11,7 @@ public static class OpcUaHelper /// /// The OPC UA endpoint URL to connect to. /// An active OPC UA client session. - public static async Task ConnectAsync(string endpointUrl) + public static async Task ConnectAsync(string endpointUrl, string? username = null, string? password = null) { var config = new ApplicationConfiguration { @@ -53,13 +53,17 @@ public static class OpcUaHelper var endpointConfig = EndpointConfiguration.Create(config); var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig); + UserIdentity identity = (username != null) + ? new UserIdentity(username, System.Text.Encoding.UTF8.GetBytes(password ?? "")) + : new UserIdentity(); + var session = await Session.Create( config, configuredEndpoint, false, "OpcUaCli", 60000, - null, + identity, null); return session; diff --git a/tools/opcuacli-dotnet/README.md b/tools/opcuacli-dotnet/README.md index b445ee1..b690c3e 100644 --- a/tools/opcuacli-dotnet/README.md +++ b/tools/opcuacli-dotnet/README.md @@ -5,6 +5,21 @@ Command-line utility for testing OPC UA server functions. Built with the [OPC Fo - **Runtime**: .NET 10 - **OPC UA Client**: OPCFoundation.NetStandard.Opc.Ua.Client +## Authentication + +All commands accept optional authentication flags: + +| Flag | Description | +|------|-------------| +| `-U` | Username for authentication | +| `-P` | Password for authentication | + +Without credentials, the client connects anonymously. Example: + +``` +dotnet run -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -v "Hello" -U operator -P op123 +``` + ## Build & Run ```bash