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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user