Add LDAP authentication with role-based OPC UA permissions

Replace static user list with GLAuth LDAP authentication. Group
membership (ReadOnly, ReadWrite, AlarmAck) maps to granular OPC UA
permissions for write and alarm-ack operations. Anonymous can still
browse and read but not write.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 18:57:30 -04:00
parent 9d3599fbb6
commit 74107ea95e
16 changed files with 726 additions and 17 deletions

View File

@@ -85,6 +85,10 @@ The server supports configurable OPC UA transport security via the `Security` se
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and MXAccess runtime but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
## LDAP Authentication
The server supports LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). The `IUserAuthenticationProvider` interface is pluggable — `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink

View File

@@ -116,11 +116,62 @@ Controls user authentication and write authorization for the OPC UA server. Defi
| `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).
Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string). The `Users` list is ignored when `Ldap.Enabled` is `true`.
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.
#### LDAP Authentication
Example configuration:
When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions. The `Users` list is ignored.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Ldap.Enabled` | `bool` | `false` | Enables LDAP authentication |
| `Ldap.Host` | `string` | `localhost` | LDAP server hostname |
| `Ldap.Port` | `int` | `3893` | LDAP server port |
| `Ldap.BaseDN` | `string` | `dc=lmxopcua,dc=local` | Base DN for LDAP operations |
| `Ldap.BindDnTemplate` | `string` | `cn={username},dc=lmxopcua,dc=local` | Bind DN template (`{username}` is replaced) |
| `Ldap.ServiceAccountDn` | `string` | `""` | Service account DN for group lookups |
| `Ldap.ServiceAccountPassword` | `string` | `""` | Service account password |
| `Ldap.TimeoutSeconds` | `int` | `5` | Connection timeout |
| `Ldap.ReadOnlyGroup` | `string` | `ReadOnly` | LDAP group granting read-only access |
| `Ldap.ReadWriteGroup` | `string` | `ReadWrite` | LDAP group granting read-write access |
| `Ldap.AlarmAckGroup` | `string` | `AlarmAck` | LDAP group granting alarm acknowledgment |
#### Permission Model
When LDAP is enabled, authenticated users receive permissions based on their LDAP group membership:
| LDAP Group | Permission |
|---|---|
| ReadOnly | Browse and read nodes |
| ReadWrite | Browse, read, and write tag values |
| AlarmAck | Acknowledge alarms |
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups.
Example with LDAP authentication:
```json
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Users": [],
"Ldap": {
"Enabled": true,
"Host": "localhost",
"Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite",
"AlarmAckGroup": "AlarmAck"
}
}
```
Example with static user list (no LDAP):
```json
"Authentication": {

View File

@@ -257,3 +257,41 @@ The CLI tool auto-generates its own client certificate on first use (stored unde
**Resolution:**
- Regenerate the client certificate using SHA-256 or stronger (recommended).
- Alternatively, set `RejectSHA1Certificates` to `false` in the server configuration (not recommended for production).
---
## LDAP Authentication
The server supports LDAP-based user authentication via GLAuth (or any standard LDAP server). When enabled, OPC UA `UserName` token credentials are validated by LDAP bind, and LDAP group membership controls what operations each user can perform.
### Architecture
```
OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials)
→ LDAP Search (resolve group membership)
→ Role assignment → Permission enforcement
```
### LDAP Groups and OPC UA Permissions
| LDAP Group | OPC UA Permission |
|---|---|
| ReadOnly | Browse and read nodes |
| ReadWrite | Read and write tag values |
| AlarmAck | Acknowledge alarms |
Users can belong to multiple groups. A user with all three groups has full access.
### GLAuth Setup
The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP server, installed at `C:\publish\glauth\`. See `C:\publish\glauth\auth.md` for the complete user/group reference and service management commands.
### Configuration
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
### Security Considerations
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
- The GLAuth LDAP server itself listens on plain LDAP (port 3893). Enable LDAPS in `glauth.cfg` for environments where LDAP traffic crosses network boundaries.
- The service account password is stored in `appsettings.json`. Protect this file with appropriate filesystem permissions.

View File

@@ -89,6 +89,34 @@ opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
Both instances report the same `ServerUriArray` and expose the same Galaxy namespace (`urn:ZB:LmxOpcUa`).
## LDAP Authentication Update
Updated: `2026-03-28`
Both instances updated to use LDAP authentication via GLAuth.
Configuration changes (both instances):
- `Authentication.AllowAnonymous`: `true` (anonymous can browse/read)
- `Authentication.AnonymousCanWrite`: `false` (anonymous writes blocked)
- `Authentication.Ldap.Enabled`: `true`
- `Authentication.Ldap.Host`: `localhost`
- `Authentication.Ldap.Port`: `3893`
- `Authentication.Ldap.BaseDN`: `dc=lmxopcua,dc=local`
LDAP server: GLAuth v2.4.0 at `C:\publish\glauth\` (Windows service: `GLAuth`)
Permission verification (instance1, port 4840):
```
anonymous read → allowed
anonymous write → denied (BadUserAccessDenied)
readonly read → allowed
readonly write → denied (BadUserAccessDenied)
readwrite write → allowed
admin write → allowed
alarmack write → denied (BadUserAccessDenied)
bad password → denied (connection rejected)
```
## Notes
The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation.

View File

@@ -20,8 +20,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// <summary>
/// Gets or sets the list of username/password pairs accepted for authenticated access.
/// Ignored when Ldap.Enabled is true.
/// </summary>
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
/// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
/// credentials are validated against the LDAP server and the Users list is ignored.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
}
/// <summary>

View File

@@ -104,6 +104,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
}
// Authentication
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
if (config.Authentication.Ldap.Enabled)
{
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, ReadWrite={ReadWrite}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.ReadWriteGroup, config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
{
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
}
if (config.Authentication.Users.Count > 0)
{
Log.Warning("Authentication.Users list is ignored when Ldap is enabled");
}
}
else if (config.Authentication.Users.Count > 0)
{
Log.Information("Authentication.Users configured: {Count} user(s)", config.Authentication.Users.Count);
}
// Redundancy
if (config.OpcUa.ApplicationUri != null)
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// LDAP authentication and group-to-role mapping settings.
/// </summary>
public class LdapConfiguration
{
/// <summary>
/// Gets or sets whether LDAP authentication is enabled.
/// When true, user credentials are validated against the configured LDAP server
/// and group membership determines OPC UA permissions.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the LDAP server hostname or IP address.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the LDAP server port.
/// </summary>
public int Port { get; set; } = 3893;
/// <summary>
/// Gets or sets the base DN for LDAP operations.
/// </summary>
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// </summary>
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// </summary>
public string ServiceAccountDn { get; set; } = "";
/// <summary>
/// Gets or sets the service account password.
/// </summary>
public string ServiceAccountPassword { get; set; } = "";
/// <summary>
/// Gets or sets the LDAP connection timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the LDAP group name that grants read-only access.
/// </summary>
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants read-write access.
/// </summary>
public string ReadWriteGroup { get; set; } = "ReadWrite";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// </summary>
public string AlarmAckGroup { get; set; } = "AlarmAck";
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
@@ -10,4 +12,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
bool ValidateCredentials(string username, string password);
}
/// <summary>
/// Extended interface for providers that can resolve application-level roles for authenticated users.
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
/// to control write and alarm-ack permissions.
/// </summary>
public interface IRoleProvider
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// </summary>
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
private readonly LdapConfiguration _config;
private readonly Dictionary<string, string> _groupToRole;
public LdapAuthenticationProvider(LdapConfiguration config)
{
_config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, "ReadOnly" },
{ config.ReadWriteGroup, "ReadWrite" },
{ config.AlarmAckGroup, "AlarmAck" }
};
}
public bool ValidateCredentials(string username, string password)
{
try
{
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
using (var connection = CreateConnection())
{
connection.Bind(new NetworkCredential(bindDn, password));
}
Log.Debug("LDAP bind succeeded for {Username}", username);
return true;
}
catch (LdapException ex)
{
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
return false;
}
catch (Exception ex)
{
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
return false;
}
}
public IReadOnlyList<string> GetUserRoles(string username)
{
try
{
using (var connection = CreateConnection())
{
// Bind with service account to search
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
var request = new SearchRequest(
_config.BaseDN,
$"(cn={EscapeLdapFilter(username)})",
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)connection.SendRequest(request);
if (response.Entries.Count == 0)
{
Log.Warning("LDAP search returned no entries for {Username}", username);
return new[] { "ReadOnly" }; // safe fallback
}
var entry = response.Entries[0];
var memberOf = entry.Attributes["memberOf"];
if (memberOf == null || memberOf.Count == 0)
{
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
}
var roles = new List<string>();
for (int i = 0; i < memberOf.Count; i++)
{
var dn = memberOf[i]?.ToString() ?? "";
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
var groupName = ExtractGroupName(dn);
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role))
{
roles.Add(role);
}
}
if (roles.Count == 0)
{
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
roles.Add("ReadOnly");
}
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
return roles;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
}
}
private LdapConnection CreateConnection()
{
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
var connection = new LdapConnection(identifier)
{
AuthType = AuthType.Basic,
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
};
connection.SessionOptions.ProtocolVersion = 3;
return connection;
}
private static string? ExtractGroupName(string dn)
{
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
if (string.IsNullOrEmpty(dn)) return null;
var parts = dn.Split(',');
if (parts.Length == 0) return null;
var first = parts[0].Trim();
var eqIdx = first.IndexOf('=');
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
}
private static string EscapeLdapFilter(string input)
{
return input
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
}
}

View File

@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly HistorianDataSource? _historianDataSource;
private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite;
private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup;
private readonly bool _ldapRolesEnabled;
private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution
@@ -192,7 +194,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true)
bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null,
bool ldapRolesEnabled = false)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
@@ -201,6 +205,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup;
_ldapRolesEnabled = ldapRolesEnabled;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -467,6 +473,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private ServiceResult OnAlarmAcknowledge(
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
{
if (!HasAlarmAckPermission(context))
return new ServiceResult(StatusCodes.BadUserAccessDenied);
var alarmInfo = _alarmInAlarmTags.Values
.FirstOrDefault(a => a.ConditionNode == condition);
if (alarmInfo == null)
@@ -1103,9 +1112,8 @@ 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))
// Enforce role-based write access
if (!HasWritePermission(context))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
@@ -1155,6 +1163,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
private bool HasWritePermission(OperationContext context)
{
// When LDAP roles are active, check for ReadWrite role
if (_ldapRolesEnabled && _appRoleLookup != null)
{
var username = context.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup(username);
if (roles == null) return false; // unknown user
return roles.Contains("ReadWrite");
}
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
return false;
}
return true;
}
private bool HasAlarmAckPermission(ISystemContext context)
{
if (!_ldapRolesEnabled || _appRoleLookup == null)
return true; // no LDAP restrictions without LDAP roles
var opContext = context as SystemContext;
var username = opContext?.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup(username);
if (roles == null) return false;
return roles.Contains("AlarmAck");
}
private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)
{
updatedArray = null!;

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using Opc.Ua;
@@ -29,6 +30,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly RedundancyConfiguration _redundancyConfig;
private readonly string? _applicationUri;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
private readonly ConcurrentDictionary<string, IReadOnlyList<string>> _userAppRoles = new ConcurrentDictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
private LmxNodeManager? _nodeManager;
/// <summary>
@@ -36,6 +38,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public LmxNodeManager? NodeManager => _nodeManager;
/// <summary>
/// Returns the application-level roles cached for the given username, or null if no roles are stored.
/// Called by LmxNodeManager to enforce per-role write and alarm-ack permissions.
/// </summary>
public IReadOnlyList<string>? GetUserAppRoles(string? username)
{
if (username != null && _userAppRoles.TryGetValue(username, out var roles))
return roles;
return null;
}
/// <summary>
/// Gets whether LDAP role-based access control is active.
/// </summary>
public bool LdapRolesEnabled => _authProvider is Domain.IRoleProvider;
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
@@ -69,7 +87,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite);
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null,
LdapRolesEnabled);
var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
@@ -206,10 +226,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken),
new List<Role> { Role.AuthenticatedUser });
var roles = new List<Role> { Role.AuthenticatedUser };
// Resolve LDAP-based roles when the provider supports it
if (_authProvider is Domain.IRoleProvider roleProvider)
{
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
_userAppRoles[userNameToken.UserName] = appRoles;
Log.Information("User {Username} authenticated with roles [{Roles}]",
userNameToken.UserName, string.Join(", ", appRoles));
}
else
{
Log.Information("User {Username} authenticated", userNameToken.UserName);
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken), roles);
return;
}

View File

@@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Users.Count > 0)
if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0)

View File

@@ -161,9 +161,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
var historianDataSource = _config.Historian.Enabled
? new Historian.HistorianDataSource(_config.Historian)
: null;
var authProvider = _config.Authentication.Users.Count > 0
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null;
Domain.IUserAuthenticationProvider? authProvider;
if (_config.Authentication.Ldap.Enabled)
{
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.BaseDN);
}
else if (_config.Authentication.Users.Count > 0)
{
authProvider = new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users);
}
else
{
authProvider = null;
}
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider, _config.Security, _config.Redundancy);

View File

@@ -34,6 +34,11 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<!-- LDAP authentication -->
<Reference Include="System.DirectoryServices.Protocols" />
</ItemGroup>
<ItemGroup>
<!-- MXAccess COM interop -->
<Reference Include="ArchestrA.MxAccess">

View File

@@ -35,8 +35,21 @@
},
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
"AnonymousCanWrite": false,
"Users": [],
"Ldap": {
"Enabled": false,
"Host": "localhost",
"Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite",
"AlarmAckGroup": "AlarmAck"
}
},
"Security": {
"Profiles": ["None"],

View File

@@ -70,5 +70,224 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
config.AnonymousCanWrite.ShouldBeTrue();
config.Users.ShouldBeEmpty();
}
[Fact]
public void AuthenticationConfiguration_LdapDefaults()
{
var config = new AuthenticationConfiguration();
config.Ldap.ShouldNotBeNull();
config.Ldap.Enabled.ShouldBeFalse();
config.Ldap.Host.ShouldBe("localhost");
config.Ldap.Port.ShouldBe(3893);
config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local");
config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly");
config.Ldap.ReadWriteGroup.ShouldBe("ReadWrite");
config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck");
config.Ldap.TimeoutSeconds.ShouldBe(5);
}
[Fact]
public void LdapConfiguration_BindDnTemplate_Default()
{
var config = new LdapConfiguration();
config.BindDnTemplate.ShouldBe("cn={username},dc=lmxopcua,dc=local");
}
[Fact]
public void LdapAuthenticationProvider_ValidBind_ReturnsTrue()
{
// This test requires GLAuth running on localhost:3893
// Skip if not available
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
}
catch (System.Exception)
{
// GLAuth not running - skip gracefully
return;
}
}
[Fact]
public void LdapAuthenticationProvider_InvalidPassword_ReturnsFalse()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "wrongpassword").ShouldBeFalse();
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_UnknownUser_ReturnsFalse()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("nonexistent", "anything").ShouldBeFalse();
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_ReadOnlyUser_HasReadOnlyRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
var roles = provider.GetUserRoles("readonly");
roles.ShouldContain("ReadOnly");
roles.ShouldNotContain("ReadWrite");
roles.ShouldNotContain("AlarmAck");
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_ReadWriteUser_HasReadWriteRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("readwrite", "readwrite123").ShouldBeTrue();
var roles = provider.GetUserRoles("readwrite");
roles.ShouldContain("ReadWrite");
roles.ShouldNotContain("AlarmAck");
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_AlarmAckUser_HasAlarmAckRole()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue();
var roles = provider.GetUserRoles("alarmack");
roles.ShouldContain("AlarmAck");
roles.ShouldNotContain("ReadWrite");
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_AdminUser_HasAllRoles()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
try
{
provider.ValidateCredentials("admin", "admin123").ShouldBeTrue();
var roles = provider.GetUserRoles("admin");
roles.ShouldContain("ReadOnly");
roles.ShouldContain("ReadWrite");
roles.ShouldContain("AlarmAck");
}
catch (System.Exception)
{
return; // GLAuth not running
}
}
[Fact]
public void LdapAuthenticationProvider_ImplementsIRoleProvider()
{
var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig);
(provider is IRoleProvider).ShouldBeTrue();
}
[Fact]
public void ConfigUserAuthenticationProvider_DoesNotImplementIRoleProvider()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>());
(provider is IRoleProvider).ShouldBeFalse();
}
[Fact]
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
{
var ldapConfig = new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 19999, // no server here
TimeoutSeconds = 1
};
var provider = new LdapAuthenticationProvider(ldapConfig);
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
}
[Fact]
public void LdapAuthenticationProvider_ConnectionFailure_GetUserRoles_FallsBackToReadOnly()
{
var ldapConfig = new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 19999, // no server here
TimeoutSeconds = 1,
ServiceAccountDn = "cn=svc,dc=test",
ServiceAccountPassword = "test"
};
var provider = new LdapAuthenticationProvider(ldapConfig);
var roles = provider.GetUserRoles("anyone");
roles.ShouldContain("ReadOnly");
}
private static LdapConfiguration CreateGlAuthConfig()
{
return new LdapConfiguration
{
Enabled = true,
Host = "localhost",
Port = 3893,
BaseDN = "dc=lmxopcua,dc=local",
BindDnTemplate = "cn={username},dc=lmxopcua,dc=local",
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
ServiceAccountPassword = "serviceaccount123",
TimeoutSeconds = 5,
ReadOnlyGroup = "ReadOnly",
ReadWriteGroup = "ReadWrite",
AlarmAckGroup = "AlarmAck"
};
}
}
}