feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4)
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||
|
||||
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
|
||||
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
||||
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a
|
||||
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded
|
||||
// GroupToRole map (GwAdmin -> Admin) means the admin principal must
|
||||
// carry Role=Admin alongside the raw LDAP-group claim. A regression in
|
||||
// MapGroupsToRoles (returning an empty list, missing the RDN fallback)
|
||||
// would silently pass without this assertion.
|
||||
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
|
||||
// ClaimTypes.Role claim resolved from the LDAP groups via the
|
||||
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
|
||||
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
|
||||
// claim. A regression in the group→role mapping would fail this assertion.
|
||||
Assert.Contains(result.Principal.Claims, claim =>
|
||||
claim.Type == ClaimTypes.Role
|
||||
&& claim.Value == DashboardRoles.Admin);
|
||||
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
||||
{
|
||||
// Exercises the LdapException branch: the user exists and the service
|
||||
// Exercises the user-bind-failure branch: the user exists and the service
|
||||
// account search succeeds, but the candidate bind is rejected.
|
||||
const string wrongPassword = "definitely-not-the-admin-password";
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||
{
|
||||
// Exercises the `candidate is null` branch: the service-account search
|
||||
// returns no entry, so no candidate bind is attempted.
|
||||
// Exercises the user-not-found branch: the service-account search returns no
|
||||
// entry, so no candidate bind is attempted.
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
|
||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||
{
|
||||
// Exercises the connect-failure path: a closed loopback port produces a
|
||||
// connection error that DashboardAuthenticator must absorb into a Fail
|
||||
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||
// result rather than propagating an exception to the dashboard.
|
||||
DashboardAuthenticator authenticator = new(
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
// 1 is a reserved port number that no LDAP server listens on.
|
||||
Port = 1,
|
||||
},
|
||||
}),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||
{
|
||||
// 1 is a reserved port number that no LDAP server listens on.
|
||||
Port = 1,
|
||||
});
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
|
||||
Assert.Null(result.Principal);
|
||||
}
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator()
|
||||
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
||||
{
|
||||
return new DashboardAuthenticator(
|
||||
Options.Create(new GatewayOptions
|
||||
GatewayOptions gatewayOptions = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
},
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return new DashboardAuthenticator(
|
||||
new LdapAuthService(ldapOptions),
|
||||
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
|
||||
/// default LDAP settings so the live tests exercise the same seeded directory the
|
||||
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
|
||||
/// </summary>
|
||||
private static LibraryLdapOptions LibraryOptions()
|
||||
{
|
||||
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
|
||||
return new LibraryLdapOptions
|
||||
{
|
||||
Enabled = gateway.Enabled,
|
||||
Server = gateway.Server,
|
||||
Port = gateway.Port,
|
||||
Transport = gateway.Transport,
|
||||
AllowInsecure = gateway.AllowInsecure,
|
||||
SearchBase = gateway.SearchBase,
|
||||
ServiceAccountDn = gateway.ServiceAccountDn,
|
||||
ServiceAccountPassword = gateway.ServiceAccountPassword,
|
||||
UserNameAttribute = gateway.UserNameAttribute,
|
||||
DisplayNameAttribute = gateway.DisplayNameAttribute,
|
||||
GroupAttribute = gateway.GroupAttribute,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
|
||||
bool Enabled,
|
||||
string Server,
|
||||
int Port,
|
||||
bool UseTls,
|
||||
bool AllowInsecureLdap,
|
||||
string Transport,
|
||||
bool AllowInsecure,
|
||||
string SearchBase,
|
||||
string ServiceAccountDn,
|
||||
string ServiceAccountPassword,
|
||||
|
||||
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
Enabled: value.Ldap.Enabled,
|
||||
Server: value.Ldap.Server,
|
||||
Port: value.Ldap.Port,
|
||||
UseTls: value.Ldap.UseTls,
|
||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
||||
Transport: value.Ldap.Transport.ToString(),
|
||||
AllowInsecure: value.Ldap.AllowInsecure,
|
||||
SearchBase: value.Ldap.SearchBase,
|
||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||
ServiceAccountPassword: RedactedValue,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
@@ -82,9 +83,9 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder);
|
||||
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||
{
|
||||
builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
||||
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class LdapOptions
|
||||
@@ -11,11 +13,18 @@ public sealed class LdapOptions
|
||||
/// <summary>Gets the LDAP server port.</summary>
|
||||
public int Port { get; init; } = 3893;
|
||||
|
||||
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
|
||||
public bool UseTls { get; init; }
|
||||
/// <summary>
|
||||
/// Gets the transport/TLS mode for the LDAP connection. Replaces the former
|
||||
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
|
||||
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
|
||||
/// a plaintext connection to TLS. Matches the shared
|
||||
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
|
||||
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
|
||||
/// </summary>
|
||||
public LdapTransport Transport { get; init; } = LdapTransport.None;
|
||||
|
||||
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
|
||||
public bool AllowInsecureLdap { get; init; } = true;
|
||||
/// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
|
||||
public bool AllowInsecure { get; init; } = true;
|
||||
|
||||
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
@@ -26,7 +26,7 @@ else
|
||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
||||
<tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
|
||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
||||
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
||||
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
|
||||
/// and never throws — returning the user's display name and LDAP groups on
|
||||
/// success. This class keeps the dashboard-specific policy: groups are resolved
|
||||
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
||||
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
||||
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
||||
/// </summary>
|
||||
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
||||
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
|
||||
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||
public sealed class DashboardAuthenticator(
|
||||
IOptions<GatewayOptions> options,
|
||||
ILdapAuthService ldapAuthService,
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||
{
|
||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||
@@ -19,202 +30,46 @@ public sealed class DashboardAuthenticator(
|
||||
string? password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LdapOptions ldapOptions = options.Value.Ldap;
|
||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
||||
if (!ldapOptions.Enabled
|
||||
|| string.IsNullOrWhiteSpace(username)
|
||||
|| string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string normalizedUsername = username.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
using LdapConnection connection = new();
|
||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? candidate = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Bind(candidate.Dn, password),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (authenticatedEntry is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
||||
?? normalizedUsername;
|
||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
||||
|
||||
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||
normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
groups,
|
||||
roles));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
||||
normalizedUsername,
|
||||
ex.ResultCode);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
||||
/// <param name="value">The string value to escape.</param>
|
||||
internal static string EscapeLdapFilter(string value)
|
||||
{
|
||||
StringBuilder builder = new(value.Length);
|
||||
foreach (char character in value)
|
||||
{
|
||||
builder.Append(character switch
|
||||
{
|
||||
'\\' => @"\5c",
|
||||
'*' => @"\2a",
|
||||
'(' => @"\28",
|
||||
')' => @"\29",
|
||||
'\0' => @"\00",
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||
/// an empty list when no group matches (caller rejects the login).
|
||||
/// Delegates to <see cref="DashboardGroupRoleMapping"/>, the single source
|
||||
/// of truth shared with <see cref="DashboardGroupRoleMapper"/>.
|
||||
/// </summary>
|
||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||
IEnumerable<string> groups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
=> DashboardGroupRoleMapping.MapGroupsToRoles(groups, groupToRole);
|
||||
|
||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
=> DashboardGroupRoleMapping.ExtractFirstRdnValue(distinguishedName);
|
||||
|
||||
private static Task BindServiceAccountAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(
|
||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<LdapEntry?> SearchUserAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
string username,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
ILdapSearchResults results = await Task.Run(
|
||||
() => connection.Search(
|
||||
ldapOptions.SearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attrs: null,
|
||||
typesOnly: false),
|
||||
cancellationToken)
|
||||
// The shared service owns connect/bind/search and the fail-closed contract:
|
||||
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
|
||||
// its startup validator, and never throws. We only translate its outcome into a
|
||||
// dashboard principal here.
|
||||
LdapAuthResult ldapResult = await ldapAuthService
|
||||
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LdapEntry? entry = null;
|
||||
while (results.HasMore())
|
||||
if (!ldapResult.Succeeded)
|
||||
{
|
||||
LdapEntry next = results.Next();
|
||||
if (entry is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entry = next;
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
GroupRoleMapping<string> mapping = await roleMapper
|
||||
.MapAsync(ldapResult.Groups, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
||||
}
|
||||
IReadOnlyList<string> roles = mapping.Roles;
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
// Preserve the long-standing "no roles matched -> login denied" rule.
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||
ldapResult.Username);
|
||||
|
||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
||||
{
|
||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
||||
return attribute?.StringValueArray ?? [];
|
||||
}
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return entry.GetAttribute(attributeName)
|
||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
ldapResult.Username,
|
||||
ldapResult.DisplayName,
|
||||
ldapResult.Groups,
|
||||
roles));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
@@ -16,8 +18,21 @@ public static class DashboardServiceCollectionExtensions
|
||||
/// Registers all dashboard services, authentication, and Razor components.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to register services.</param>
|
||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||
/// <param name="configuration">
|
||||
/// Application configuration, used to bind the shared LDAP provider's options
|
||||
/// from the <c>MxGateway:Ldap</c> section.
|
||||
/// </param>
|
||||
public static IServiceCollection AddGatewayDashboard(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Dashboard logins delegate bind/search to the shared ZB.MOM.WW.Auth.Ldap
|
||||
// provider. Its LdapOptions bind straight from MxGateway:Ldap (the gateway's
|
||||
// LdapOptions field names match the shared options: Transport / AllowInsecure /
|
||||
// SearchBase / ServiceAccount* / *Attribute). AddZbLdapAuth also adds a
|
||||
// ValidateOnStart() so an insecure-transport misconfiguration fails fast at boot.
|
||||
services.AddZbLdapAuth(configuration, "MxGateway:Ldap");
|
||||
|
||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
|
||||
@@ -91,7 +91,7 @@ public static class GatewayApplication
|
||||
builder.Services.AddWorkerProcessLauncher();
|
||||
builder.Services.AddGatewaySessions();
|
||||
builder.Services.AddGatewayAlarms();
|
||||
builder.Services.AddGatewayDashboard();
|
||||
builder.Services.AddGatewayDashboard(builder.Configuration);
|
||||
builder.Services.AddGalaxyRepository();
|
||||
|
||||
return builder;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
@@ -17,7 +19,6 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"Transport": "None",
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
|
||||
@@ -1,90 +1,74 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
|
||||
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
|
||||
/// (covered by its own tests); these tests fake the shared service to return known
|
||||
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
|
||||
/// resulting principal/claims keep their exact shape.
|
||||
/// </summary>
|
||||
public sealed class DashboardAuthenticatorTests
|
||||
{
|
||||
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
|
||||
[Fact]
|
||||
public void EscapeLdapFilter_EscapesSpecialCharacters()
|
||||
{
|
||||
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
|
||||
|
||||
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
|
||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
||||
/// <param name="expectedRole">The expected role or null if no match.</param>
|
||||
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
||||
[InlineData("OtherGroup", null)]
|
||||
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
|
||||
string ldapGroup,
|
||||
string? expectedRole)
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
|
||||
{
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
username,
|
||||
"password",
|
||||
CancellationToken.None);
|
||||
|
||||
if (expectedRole is null)
|
||||
{
|
||||
Assert.Empty(roles);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expectedRole, Assert.Single(roles));
|
||||
}
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.False(ldap.WasCalled);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
|
||||
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
|
||||
[Fact]
|
||||
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
|
||||
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
|
||||
{
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
|
||||
["GwAdmin", "GwReader"],
|
||||
mapping);
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
" ",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(DashboardRoles.Admin, roles);
|
||||
Assert.Contains(DashboardRoles.Viewer, roles);
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.False(ldap.WasCalled);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
|
||||
/// <summary>
|
||||
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
|
||||
/// maps to the generic dashboard failure without leaking the raw password.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(LdapAuthFailure.Disabled)]
|
||||
[InlineData(LdapAuthFailure.BadCredentials)]
|
||||
[InlineData(LdapAuthFailure.UserNotFound)]
|
||||
[InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
|
||||
public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
|
||||
LdapAuthFailure failure)
|
||||
{
|
||||
string result = DashboardAuthenticator.ExtractFirstRdnValue(
|
||||
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
|
||||
|
||||
Assert.Equal("Gateway Admins", result);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
},
|
||||
});
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
@@ -96,10 +80,161 @@ public sealed class DashboardAuthenticatorTests
|
||||
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
|
||||
/// <summary>
|
||||
/// On success the principal carries the resolved roles, the raw LDAP-group claims,
|
||||
/// the display name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier),
|
||||
/// under the dashboard authentication scheme — the exact shape produced before the cutover.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
|
||||
{
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||
username: "admin",
|
||||
displayName: "Administrator",
|
||||
groups: ["GwAdmin", "GwReader"]));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||
|
||||
Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||
Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);
|
||||
|
||||
// Identity is built with the dashboard scheme and Name/Role claim types so
|
||||
// IsInRole and Identity.Name keep working.
|
||||
ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
|
||||
Assert.Equal("Administrator", identity.Name);
|
||||
|
||||
// Both groups mapped → both roles present.
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
||||
|
||||
// Raw LDAP groups are surfaced under the dedicated group claim type.
|
||||
IReadOnlyList<string> groupClaims = principal.FindAll(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||
.Select(claim => claim.Value)
|
||||
.ToList();
|
||||
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the user authenticates but none of their groups map to a dashboard role,
|
||||
/// the login is denied (the long-standing "no roles matched → denied" rule).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
|
||||
{
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||
username: "nobody",
|
||||
displayName: "No Body",
|
||||
groups: ["SomeUnmappedGroup"]));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"nobody",
|
||||
"pw",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A group supplied as a full distinguished name still resolves to its role via the
|
||||
/// mapper's leading-RDN fallback, and the original DN is preserved on the group claim.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_GroupAsDistinguishedName_ResolvesRoleAndPreservesGroupClaim()
|
||||
{
|
||||
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||
username: "admin",
|
||||
displayName: "admin",
|
||||
groups: [groupDn]));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||
Assert.Contains(
|
||||
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
||||
claim => claim.Value == groupDn);
|
||||
}
|
||||
|
||||
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
|
||||
{
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||
username: "canonical",
|
||||
displayName: "Canonical User",
|
||||
groups: ["GwReader"]));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
" canonical ",
|
||||
"pw",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||
Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||
// The authenticator trims before delegating, so the provider sees the canonical value.
|
||||
Assert.Equal("canonical", ldap.LastUsername);
|
||||
}
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator(
|
||||
ILdapAuthService ldapAuthService,
|
||||
Dictionary<string, string> groupToRole)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToRole = groupToRole,
|
||||
},
|
||||
};
|
||||
|
||||
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
|
||||
|
||||
return new DashboardAuthenticator(
|
||||
Options.Create(options),
|
||||
ldapAuthService,
|
||||
roleMapper,
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
|
||||
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
|
||||
/// </summary>
|
||||
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
|
||||
{
|
||||
public bool WasCalled { get; private set; }
|
||||
|
||||
public string? LastUsername { get; private set; }
|
||||
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
WasCalled = true;
|
||||
LastUsername = username;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,9 +199,9 @@ public sealed class GatewayApplicationTests
|
||||
"BogusRole",
|
||||
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:AllowInsecureLdap",
|
||||
"MxGateway:Ldap:AllowInsecure",
|
||||
"false",
|
||||
"MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")]
|
||||
"MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")]
|
||||
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
||||
string key,
|
||||
string value,
|
||||
|
||||
Reference in New Issue
Block a user