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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-27 02:14:37 -04:00
parent b27d355763
commit bbd043e97b
24 changed files with 499 additions and 34 deletions

View File

@@ -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<Session> 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

View File

@@ -20,6 +20,23 @@ dotnet run -- <command> [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

View File

@@ -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<T>` 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<UserCredential>` | `[]` | 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": []
}
}
```

View File

@@ -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\`:

View File

@@ -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`.

View File

@@ -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.
/// </summary>
public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration();
/// <summary>
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Authentication and role-based access control settings for the OPC UA server.
/// </summary>
public class AuthenticationConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the list of username/password pairs accepted for authenticated access.
/// </summary>
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
}
/// <summary>
/// A username/password pair for OPC UA user authentication.
/// </summary>
public class UserCredential
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = "";
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = "";
}
}

View File

@@ -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
{
/// <summary>
/// Validates credentials against a static list from appsettings.json configuration.
/// </summary>
public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider
{
private readonly Dictionary<string, string> _users;
public ConfigUserAuthenticationProvider(List<UserCredential> 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;
}
}
}

View File

@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, etc.).
/// </summary>
public interface IUserAuthenticationProvider
{
/// <summary>
/// Validates a username/password combination.
/// </summary>
bool ValidateCredentials(string username, string password);
}
}

View File

@@ -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;

View File

@@ -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
{
/// <summary>
/// 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)
/// </summary>
public class LmxOpcUaServer : StandardServer
{
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
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;
/// <summary>
@@ -36,34 +43,73 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Initializes a custom OPC UA server for the specified Galaxy namespace.
/// </summary>
/// <param name="galaxyName">The Galaxy name used to construct the namespace URI and product URI.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live data access.</param>
/// <param name="metrics">The metrics collector shared with the node manager.</param>
/// <param name="historianDataSource">The optional historian adapter used when clients issue OPC UA history reads.</param>
/// <param name="alarmTrackingEnabled">Enables alarm condition tracking for alarm-capable Galaxy attributes.</param>
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;
}
/// <inheritdoc />
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<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
/// <inheritdoc />
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> { 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> { Role.AuthenticatedUser });
Log.Information("User {Username} authenticated", userNameToken.UserName);
return;
}
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{

View File

@@ -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
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
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;
}
/// <summary>
@@ -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;
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>

View File

@@ -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;

View File

@@ -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;",

View File

@@ -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<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "op123").ShouldBeTrue();
}
[Fact]
public void WrongPassword_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "wrong").ShouldBeFalse();
}
[Fact]
public void UnknownUsername_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("unknown", "op123").ShouldBeFalse();
}
[Fact]
public void Username_IsCaseInsensitive()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
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<UserCredential>());
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
}
[Fact]
public void AuthenticationConfiguration_Defaults()
{
var config = new AuthenticationConfiguration();
config.AllowAnonymous.ShouldBeTrue();
config.AnonymousCanWrite.ShouldBeTrue();
config.Users.ShouldBeEmpty();
}
}
}

View File

@@ -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; }
/// <summary>
/// Gets the node to subscribe to for event notifications, typically a source object or the server node.
/// </summary>
@@ -39,7 +45,7 @@ public class AlarmsCommand : ICommand
/// <param name="console">The CLI console used for cancellation and alarm-event output.</param>
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

View File

@@ -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; }
/// <summary>
/// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder.
/// </summary>
@@ -39,7 +45,7 @@ public class BrowseCommand : ICommand
/// <param name="console">The console used to emit browse output.</param>
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

View File

@@ -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; }
/// <summary>
/// Connects to the OPC UA endpoint and prints the resolved server metadata.
/// </summary>
/// <param name="console">The console used to report connection results.</param>
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}");

View File

@@ -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; }
/// <summary>
/// Gets the node identifier for the historized variable to query.
/// </summary>
@@ -57,7 +63,7 @@ public class HistoryReadCommand : ICommand
/// <param name="console">The CLI console used for output, errors, and cancellation handling.</param>
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();

View File

@@ -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; }
/// <summary>
/// Gets the node identifier whose value should be read.
/// </summary>
@@ -27,7 +33,7 @@ public class ReadCommand : ICommand
/// <param name="console">The console used to report the read result.</param>
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);

View File

@@ -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; }
/// <summary>
/// Gets the node identifier to monitor for value changes.
/// </summary>
@@ -33,7 +39,7 @@ public class SubscribeCommand : ICommand
/// <param name="console">The console used to display subscription updates.</param>
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)
{

View File

@@ -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; }
/// <summary>
/// Gets the node identifier that should receive the write.
/// </summary>
@@ -33,7 +39,7 @@ public class WriteCommand : ICommand
/// <param name="console">The console used to report the write result.</param>
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);

View File

@@ -11,7 +11,7 @@ public static class OpcUaHelper
/// </summary>
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
/// <returns>An active OPC UA client session.</returns>
public static async Task<Session> ConnectAsync(string endpointUrl)
public static async Task<Session> 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;

View File

@@ -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