feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4)
This commit is contained in:
@@ -20,9 +20,9 @@
|
|||||||
<div class="panel-head">LDAP binding</div>
|
<div class="panel-head">LDAP binding</div>
|
||||||
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
||||||
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
|
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
|
||||||
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
|
<div class="kv"><span class="k">Transport</span><span class="v">@_options.Transport</span></div>
|
||||||
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
|
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
|
||||||
@if (!_options.UseTls && _options.AllowInsecureLdap)
|
@if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure)
|
||||||
{
|
{
|
||||||
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
|
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|||||||
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
|
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
|
||||||
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
|
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
|
||||||
/// intentionally not required — an empty pair selects the direct-bind path (see
|
/// intentionally not required — an empty pair selects the direct-bind path (see
|
||||||
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
|
/// <see cref="LdapOptions.ServiceAccountDn"/>). The plaintext-transport-without-AllowInsecure
|
||||||
|
/// guard is enforced at the auth boundary (<see cref="OtOpcUaLdapAuthService"/>) rather than here,
|
||||||
|
/// to preserve the bespoke service's behaviour of booting and failing closed at login (not at
|
||||||
|
/// startup) when a config selects insecure transport. Failure messages use <c>"Ldap:"</c> as a
|
||||||
/// human-readable field prefix — not the literal bound section path, which is
|
/// human-readable field prefix — not the literal bound section path, which is
|
||||||
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
|
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -8,15 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|||||||
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
||||||
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
||||||
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
||||||
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
|
/// session identities. Roles are resolved through the shared
|
||||||
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
|
||||||
|
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
|
||||||
|
/// session identity for the downstream data-plane ACL evaluator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
|
||||||
|
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
|
||||||
|
/// resolve the mapper so the singleton never captures a scoped dependency.
|
||||||
|
/// </remarks>
|
||||||
public sealed class LdapOpcUaUserAuthenticator(
|
public sealed class LdapOpcUaUserAuthenticator(
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<LdapOpcUaUserAuthenticator> logger)
|
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||||
: IOpcUaUserAuthenticator
|
: IOpcUaUserAuthenticator
|
||||||
{
|
{
|
||||||
/// <summary>Authenticates an OPC UA UserName token via LDAP.</summary>
|
/// <summary>Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.</summary>
|
||||||
/// <param name="username">The username to authenticate.</param>
|
/// <param name="username">The username to authenticate.</param>
|
||||||
/// <param name="password">The password to authenticate.</param>
|
/// <param name="password">The password to authenticate.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
@@ -29,7 +39,9 @@ public sealed class LdapOpcUaUserAuthenticator(
|
|||||||
{
|
{
|
||||||
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
||||||
}
|
}
|
||||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
|
|
||||||
|
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
|
||||||
|
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -37,4 +49,36 @@ public sealed class LdapOpcUaUserAuthenticator(
|
|||||||
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the user's roles from their LDAP groups via the scoped
|
||||||
|
/// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
|
||||||
|
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
|
||||||
|
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groups">The LDAP groups returned by the directory.</param>
|
||||||
|
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
|
||||||
|
/// <param name="username">The login name, for diagnostics.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
|
||||||
|
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
|
||||||
|
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var role in mapping.Roles)
|
||||||
|
roles.Add(role);
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex,
|
||||||
|
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
|
||||||
|
return preResolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security;
|
using ZB.MOM.WW.OtOpcUa.Security;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
@@ -101,9 +102,15 @@ if (hasDriver)
|
|||||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||||
|
|
||||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers this) ends up
|
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up
|
||||||
// with exactly one descriptor; on a driver-only node this is the sole registration.
|
// with exactly one descriptor; on a driver-only node these are the sole registrations.
|
||||||
builder.Services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
|
||||||
|
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
|
||||||
|
// <string> per call to turn the directory's groups into roles, so register it here for driver-
|
||||||
|
// only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it
|
||||||
|
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above.
|
||||||
|
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||||
|
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ public static class AuthEndpoints
|
|||||||
private static async Task<IResult> LoginAsync(
|
private static async Task<IResult> LoginAsync(
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
ILdapAuthService ldap,
|
ILdapAuthService ldap,
|
||||||
ILdapGroupRoleMappingService roleMappings,
|
IGroupRoleMapper<string> roleMapper,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var isForm = http.Request.HasFormContentType;
|
var isForm = http.Request.HasFormContentType;
|
||||||
@@ -87,18 +87,25 @@ public static class AuthEndpoints
|
|||||||
return Results.Redirect("/login" + qs);
|
return Results.Redirect("/login" + qs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role resolution now lives behind the shared IGroupRoleMapper<string> seam
|
||||||
|
// (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
|
||||||
|
// system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
|
||||||
|
// LDAP path (the library returns groups, not roles); it is only pre-populated on the
|
||||||
|
// DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
|
||||||
|
// survives the move to the mapper.
|
||||||
IReadOnlyList<string> roles = result.Roles;
|
IReadOnlyList<string> roles = result.Roles;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
|
var mapping = await roleMapper.MapAsync(result.Groups, ct);
|
||||||
roles = RoleMapper.Merge(result.Roles, dbRows);
|
roles = Union(result.Roles, mapping.Roles);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
|
// A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
|
||||||
|
// pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
|
||||||
http.RequestServices.GetService<ILoggerFactory>()?
|
http.RequestServices.GetService<ILoggerFactory>()?
|
||||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||||
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
|
.LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
@@ -119,6 +126,21 @@ public static class AuthEndpoints
|
|||||||
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
|
||||||
|
/// legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
|
||||||
|
/// FleetAdmin grant) into the mapper-resolved set.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="first">The first role set (pre-resolved baseline).</param>
|
||||||
|
/// <param name="second">The second role set (mapper output).</param>
|
||||||
|
private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
|
||||||
|
{
|
||||||
|
var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var role in second)
|
||||||
|
roles.Add(role);
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
|
|
||||||
private static IResult Ping(HttpContext http) =>
|
private static IResult Ping(HttpContext http) =>
|
||||||
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
|
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
|
||||||
|
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Novell.Directory.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
|
||||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
|
||||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
|
||||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
|
||||||
: ILdapAuthService
|
|
||||||
{
|
|
||||||
private readonly LdapOptions _options = options.Value;
|
|
||||||
|
|
||||||
/// <summary>Authenticates a user via LDAP bind and retrieves their group memberships and roles.</summary>
|
|
||||||
/// <param name="username">The username to authenticate.</param>
|
|
||||||
/// <param name="password">The password to validate against the LDAP directory.</param>
|
|
||||||
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
|
|
||||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
|
||||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
|
||||||
if (!_options.Enabled)
|
|
||||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(username))
|
|
||||||
return new(false, null, null, [], [], "Username is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(password))
|
|
||||||
return new(false, null, null, [], [], "Password is required");
|
|
||||||
|
|
||||||
if (_options.DevStubMode)
|
|
||||||
{
|
|
||||||
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
|
||||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
|
||||||
return new(false, null, username, [], [],
|
|
||||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var conn = new LdapConnection();
|
|
||||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
|
||||||
|
|
||||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
|
||||||
|
|
||||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
|
||||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
|
||||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
|
||||||
|
|
||||||
var displayName = username;
|
|
||||||
var groups = new List<string>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
|
||||||
var results = await Task.Run(() =>
|
|
||||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
|
||||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
|
||||||
typesOnly: false), ct);
|
|
||||||
|
|
||||||
while (results.HasMore())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var entry = results.Next();
|
|
||||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
|
||||||
if (name is not null) displayName = name.StringValue;
|
|
||||||
|
|
||||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
|
||||||
if (groupAttr is not null)
|
|
||||||
{
|
|
||||||
foreach (var groupDn in groupAttr.StringValueArray)
|
|
||||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
|
||||||
// directory doesn't populate memberOf (or populates it differently), the
|
|
||||||
// user's primary group name is recoverable from the second RDN of the DN.
|
|
||||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
|
||||||
{
|
|
||||||
var primary = ExtractOuSegment(entry.Dn);
|
|
||||||
if (primary is not null) groups.Add(primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (LdapException ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.Disconnect();
|
|
||||||
|
|
||||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
|
||||||
return new(true, displayName, username, groups, roles, null);
|
|
||||||
}
|
|
||||||
catch (LdapException ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
|
||||||
return new(false, null, username, [], [], "Invalid username or password");
|
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
|
||||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (username.Contains('=')) return username; // already a DN
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
|
||||||
{
|
|
||||||
await Task.Run(() =>
|
|
||||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
|
||||||
|
|
||||||
var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
|
||||||
var results = await Task.Run(() =>
|
|
||||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
|
||||||
|
|
||||||
if (results.HasMore())
|
|
||||||
return results.Next().Dn;
|
|
||||||
|
|
||||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
|
||||||
$"No entry for {filter}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
|
||||||
? $"cn={username}"
|
|
||||||
: $"cn={username},{_options.SearchBase}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Escapes special characters in an LDAP filter string according to RFC 4515.</summary>
|
|
||||||
/// <param name="input">The unescaped string to escape.</param>
|
|
||||||
/// <returns>The escaped LDAP filter string.</returns>
|
|
||||||
internal static string EscapeLdapFilter(string input) =>
|
|
||||||
input.Replace("\\", "\\5c")
|
|
||||||
.Replace("*", "\\2a")
|
|
||||||
.Replace("(", "\\28")
|
|
||||||
.Replace(")", "\\29")
|
|
||||||
.Replace("\0", "\\00");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
|
||||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
|
||||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="dn">The distinguished name to extract the OU from.</param>
|
|
||||||
/// <returns>The extracted OU value, or null if no OU segment is found.</returns>
|
|
||||||
internal static string? ExtractOuSegment(string dn)
|
|
||||||
{
|
|
||||||
var segments = dn.Split(',');
|
|
||||||
foreach (var segment in segments)
|
|
||||||
{
|
|
||||||
var trimmed = segment.Trim();
|
|
||||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return trimmed[3..];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Extracts the value portion of the first RDN (relative distinguished name) from a DN.</summary>
|
|
||||||
/// <param name="dn">The distinguished name to extract from.</param>
|
|
||||||
/// <returns>The value of the first RDN.</returns>
|
|
||||||
internal static string ExtractFirstRdnValue(string dn)
|
|
||||||
{
|
|
||||||
var equalsIdx = dn.IndexOf('=');
|
|
||||||
if (equalsIdx < 0) return dn;
|
|
||||||
|
|
||||||
var valueStart = equalsIdx + 1;
|
|
||||||
var commaIdx = dn.IndexOf(',', valueStart);
|
|
||||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using LibLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,6 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|||||||
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||||
/// <c>C:\publish\glauth\auth.md</c>).
|
/// <c>C:\publish\glauth\auth.md</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carries both the wire fields the shared <c>ZB.MOM.WW.Auth.Ldap</c> directory client needs
|
||||||
|
/// (<see cref="Server"/>/<see cref="Port"/>/<see cref="Transport"/>/…) AND the app-only concerns
|
||||||
|
/// the shared library has no notion of (<see cref="Enabled"/> master switch,
|
||||||
|
/// <see cref="DevStubMode"/> dev bypass, <see cref="GroupToRole"/> appsettings role baseline).
|
||||||
|
/// The app wrapper (<c>OtOpcUaLdapAuthService</c>) projects this onto the library's
|
||||||
|
/// <see cref="LibLdapOptions"/> at construction; see <see cref="ToLibraryOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Security:Ldap";
|
public const string SectionName = "Security:Ldap";
|
||||||
@@ -18,14 +29,21 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Gets or sets the LDAP server port.</summary>
|
/// <summary>Gets or sets the LDAP server port.</summary>
|
||||||
public int Port { get; set; } = 3893;
|
public int Port { get; set; } = 3893;
|
||||||
|
|
||||||
/// <summary>Gets or sets a value indicating whether to use TLS for LDAP connection.</summary>
|
/// <summary>
|
||||||
public bool UseTls { get; set; }
|
/// Transport security for the LDAP connection — <see cref="LdapTransport.Ldaps"/> (implicit
|
||||||
|
/// TLS), <see cref="LdapTransport.StartTls"/> (upgrade), or <see cref="LdapTransport.None"/>
|
||||||
|
/// (plaintext, dev/test only — requires <see cref="AllowInsecure"/>). Replaces the former
|
||||||
|
/// <c>UseTls</c> bool (Task 1.4): <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
|
||||||
|
/// <c>false</c>→<see cref="LdapTransport.None"/>.
|
||||||
|
/// </summary>
|
||||||
|
public LdapTransport Transport { get; set; } = LdapTransport.None;
|
||||||
|
|
||||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
/// <summary>Dev-only escape hatch — must be <c>false</c> in production. Maps to the shared
|
||||||
public bool AllowInsecureLdap { get; set; }
|
/// library's <see cref="LibLdapOptions.AllowInsecure"/> (renamed from <c>AllowInsecureLdap</c>).</summary>
|
||||||
|
public bool AllowInsecure { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
/// Dev-only stub: when <c>true</c>, <see cref="OtOpcUaLdapAuthService"/> bypasses the real LDAP
|
||||||
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||||
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,4 +80,26 @@ public sealed class LdapOptions
|
|||||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
||||||
|
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
|
||||||
|
/// (<see cref="DevStubMode"/>, <see cref="GroupToRole"/>) have no library counterpart and are
|
||||||
|
/// handled by the app wrapper around the library service; <see cref="Enabled"/> is carried
|
||||||
|
/// through so the library's own feature gate stays consistent with the app master switch.
|
||||||
|
/// </summary>
|
||||||
|
public LibLdapOptions ToLibraryOptions() => new()
|
||||||
|
{
|
||||||
|
Enabled = Enabled,
|
||||||
|
Server = Server,
|
||||||
|
Port = Port,
|
||||||
|
Transport = Transport,
|
||||||
|
AllowInsecure = AllowInsecure,
|
||||||
|
SearchBase = SearchBase,
|
||||||
|
ServiceAccountDn = ServiceAccountDn,
|
||||||
|
ServiceAccountPassword = ServiceAccountPassword,
|
||||||
|
UserNameAttribute = UserNameAttribute,
|
||||||
|
DisplayNameAttribute = DisplayNameAttribute,
|
||||||
|
GroupAttribute = GroupAttribute,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||||
|
using LibLdapAuthService = ZB.MOM.WW.Auth.Ldap.LdapAuthService;
|
||||||
|
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OtOpcUa's application <see cref="ILdapAuthService"/> — a thin wrapper around the shared
|
||||||
|
/// <c>ZB.MOM.WW.Auth.Ldap</c> directory client that adds the two app-only concerns the shared
|
||||||
|
/// library deliberately does not model:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
|
||||||
|
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants a FleetAdmin
|
||||||
|
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
|
||||||
|
/// against a machine with no directory.</item>
|
||||||
|
/// </list>
|
||||||
|
/// On the real path it delegates to the library <see cref="LibLdapAuthService"/> and adapts the
|
||||||
|
/// library result (which returns <em>groups</em>, never roles) back onto the app's
|
||||||
|
/// <see cref="LdapAuthResult"/> shape. Role resolution itself now lives downstream in
|
||||||
|
/// <see cref="OtOpcUaGroupRoleMapper"/> (the <c>IGroupRoleMapper<string></c> seam), which
|
||||||
|
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
|
||||||
|
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
|
||||||
|
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
|
||||||
|
/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
|
||||||
|
/// DevStub result mirrors the legacy bespoke service exactly (group <c>"dev"</c>, role
|
||||||
|
/// <c>"FleetAdmin"</c>) so behaviour is preserved bit-for-bit on dev nodes.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||||
|
{
|
||||||
|
private readonly LdapOptions _options;
|
||||||
|
private readonly LibILdapAuthService _inner;
|
||||||
|
private readonly ILogger<OtOpcUaLdapAuthService> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production constructor: builds the shared-library directory client from the wire fields
|
||||||
|
/// of the bound app <see cref="LdapOptions"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The app LDAP options (wire fields + app-only concerns).</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public OtOpcUaLdapAuthService(IOptions<LdapOptions> options, ILogger<OtOpcUaLdapAuthService> logger)
|
||||||
|
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seam constructor: accepts an injected library <see cref="LibILdapAuthService"/> so the
|
||||||
|
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The app LDAP options.</param>
|
||||||
|
/// <param name="inner">The shared-library directory client to delegate the real path to.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger<OtOpcUaLdapAuthService> logger)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_inner = inner;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||||
|
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
return new(false, null, null, [], [], "Username is required");
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
return new(false, null, username, [], [], "Password is required");
|
||||||
|
|
||||||
|
if (_options.DevStubMode)
|
||||||
|
{
|
||||||
|
// Dev bypass: accept any non-empty credentials and grant FleetAdmin WITHOUT a real bind.
|
||||||
|
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
|
||||||
|
// survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||||
|
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
|
||||||
|
// enforced this at login (not startup), so the host still boots with an insecure-by-default
|
||||||
|
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
|
||||||
|
// migration (Task 1.4). The shared library's directory client does not re-check this.
|
||||||
|
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
|
||||||
|
return new(false, null, username, [], [],
|
||||||
|
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
|
||||||
|
|
||||||
|
var libResult = await _inner.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||||
|
return Adapt(libResult, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the shared-library <see cref="LibLdapAuthResult"/> onto the app's
|
||||||
|
/// <see cref="LdapAuthResult"/>. The library returns groups (never roles) on success, so
|
||||||
|
/// <see cref="LdapAuthResult.Roles"/> is left empty on the delegated path — role resolution
|
||||||
|
/// happens downstream in the mapper. Library <see cref="LdapAuthFailure"/> codes are folded
|
||||||
|
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">The library authentication result.</param>
|
||||||
|
/// <param name="username">The login name, used to populate the app result's Username field.</param>
|
||||||
|
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
|
||||||
|
{
|
||||||
|
if (result.Succeeded)
|
||||||
|
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
|
||||||
|
|
||||||
|
return new(false, null, username, [], [], FailureToError(result.Failure));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
||||||
|
/// <param name="failure">The library failure code (null defensively treated as a generic error).</param>
|
||||||
|
private static string FailureToError(LdapAuthFailure? failure) => failure switch
|
||||||
|
{
|
||||||
|
// The directory found no single matching user, or the password did not verify — both
|
||||||
|
// surface as the same opaque message so a probe cannot distinguish "unknown user" from
|
||||||
|
// "wrong password".
|
||||||
|
LdapAuthFailure.BadCredentials => "Invalid username or password",
|
||||||
|
LdapAuthFailure.UserNotFound => "Invalid username or password",
|
||||||
|
LdapAuthFailure.AmbiguousUser => "Invalid username or password",
|
||||||
|
LdapAuthFailure.GroupLookupFailed => "Invalid username or password",
|
||||||
|
// System-side faults (directory unreachable / service-account misconfiguration) — kept as a
|
||||||
|
// generic backend message rather than leaking the cause to the caller.
|
||||||
|
LdapAuthFailure.ServiceAccountBindFailed => "Unexpected authentication error",
|
||||||
|
LdapAuthFailure.Disabled => "LDAP authentication is disabled.",
|
||||||
|
_ => "Unexpected authentication error",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,11 +36,13 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
|
||||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
|
||||||
// TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
// LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it
|
||||||
|
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
|
||||||
|
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
||||||
// driver path) ends up with exactly one descriptor regardless of registration order.
|
// driver path) ends up with exactly one descriptor regardless of registration order.
|
||||||
services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||||
|
|
||||||
// Shared ZB.MOM.WW.Auth group→role mapper seam (Task 1.1, additive). Wraps the existing
|
// Shared ZB.MOM.WW.Auth group→role mapper seam (Task 1.1, additive). Wraps the existing
|
||||||
// RoleMapper.Map + RoleMapper.Merge logic; the login flow is rewired to consume it in a
|
// RoleMapper.Map + RoleMapper.Merge logic; the login flow is rewired to consume it in a
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+72
-11
@@ -1,24 +1,32 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
|
||||||
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
|
||||||
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
|
||||||
|
/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather
|
||||||
|
/// than letting them escape into the SDK.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that successful LDAP authentication returns Allow result with user roles.</summary>
|
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
|
||||||
|
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
|
||||||
{
|
{
|
||||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
|
||||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
// group "configeditor" -> "ConfigEditor".
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||||
|
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||||
|
|
||||||
@@ -27,12 +35,45 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
|||||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are
|
||||||
|
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
|
||||||
|
{
|
||||||
|
// DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to
|
||||||
|
// nothing, so the union is exactly {FleetAdmin}.
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||||
|
var mapper = new FakeMapper(_ => Array.Empty<string>());
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
|
||||||
|
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
|
||||||
|
{
|
||||||
|
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||||
|
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||||
{
|
{
|
||||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
var mapper = new FakeMapper(g => g.ToArray());
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||||
|
|
||||||
@@ -45,7 +86,8 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
|||||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||||
{
|
{
|
||||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
var mapper = new FakeMapper(g => g.ToArray());
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||||
|
|
||||||
@@ -58,8 +100,9 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||||
{
|
{
|
||||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
|
||||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
var mapper = new FakeMapper(g => g.ToArray());
|
||||||
|
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||||
|
|
||||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||||
|
|
||||||
@@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
|||||||
result.DisplayName.ShouldBe("alice");
|
result.DisplayName.ShouldBe("alice");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
|
||||||
|
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped(_ => mapper);
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Test fake implementation of LDAP authentication service.</summary>
|
/// <summary>Test fake implementation of LDAP authentication service.</summary>
|
||||||
private sealed class FakeLdap : ILdapAuthService
|
private sealed class FakeLdap : ILdapAuthService
|
||||||
{
|
{
|
||||||
@@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
|||||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_handler(username));
|
=> Task.FromResult(_handler(username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Test fake group→role mapper driven by a delegate over the supplied groups.</summary>
|
||||||
|
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
|
||||||
|
{
|
||||||
|
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
|
||||||
|
/// <param name="groups">The LDAP groups to map.</param>
|
||||||
|
/// <param name="ct">The cancellation token.</param>
|
||||||
|
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||||
|
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,14 +178,16 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
|||||||
|
|
||||||
if (harness.Mode.UseRealLdap)
|
if (harness.Mode.UseRealLdap)
|
||||||
{
|
{
|
||||||
configOverrides["Authentication:Ldap:Enabled"] = "true";
|
// Bound section is Security:Ldap (see LdapOptions.SectionName); Transport replaces the
|
||||||
configOverrides["Authentication:Ldap:Server"] = "localhost";
|
// old UseTls bool and AllowInsecure replaces AllowInsecureLdap (Task 1.4).
|
||||||
configOverrides["Authentication:Ldap:Port"] = "3894";
|
configOverrides["Security:Ldap:Enabled"] = "true";
|
||||||
configOverrides["Authentication:Ldap:UseTls"] = "false";
|
configOverrides["Security:Ldap:Server"] = "localhost";
|
||||||
configOverrides["Authentication:Ldap:AllowInsecureLdap"] = "true";
|
configOverrides["Security:Ldap:Port"] = "3894";
|
||||||
configOverrides["Authentication:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
|
configOverrides["Security:Ldap:Transport"] = "None";
|
||||||
configOverrides["Authentication:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
|
configOverrides["Security:Ldap:AllowInsecure"] = "true";
|
||||||
configOverrides["Authentication:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
configOverrides["Security:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
|
||||||
|
configOverrides["Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
|
||||||
|
configOverrides["Security:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Configuration.AddInMemoryCollection(configOverrides);
|
builder.Configuration.AddInMemoryCollection(configOverrides);
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
|
||||||
|
|
||||||
public sealed class LdapHelperTests
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
|
|
||||||
/// <param name="input">The input string.</param>
|
|
||||||
/// <param name="expected">The expected escaped output.</param>
|
|
||||||
[Theory]
|
|
||||||
[InlineData("joe", "joe")]
|
|
||||||
[InlineData("jo*e", "jo\\2ae")]
|
|
||||||
[InlineData("jo(e", "jo\\28e")]
|
|
||||||
[InlineData("jo)e", "jo\\29e")]
|
|
||||||
[InlineData("jo\\e", "jo\\5ce")]
|
|
||||||
public void EscapeLdapFilter_escapes_special_chars(string input, string expected)
|
|
||||||
{
|
|
||||||
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
|
|
||||||
/// <param name="dn">The distinguished name.</param>
|
|
||||||
/// <param name="expected">The expected organizational unit value.</param>
|
|
||||||
[Theory]
|
|
||||||
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
|
||||||
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
|
|
||||||
[InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
|
||||||
public void ExtractOuSegment_returns_first_ou(string dn, string? expected)
|
|
||||||
{
|
|
||||||
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
|
|
||||||
/// <param name="dn">The distinguished name.</param>
|
|
||||||
/// <param name="expected">The expected RDN value.</param>
|
|
||||||
[Theory]
|
|
||||||
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
|
|
||||||
[InlineData("cn=Admins", "Admins")]
|
|
||||||
[InlineData("Admins", "Admins")]
|
|
||||||
public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected)
|
|
||||||
{
|
|
||||||
LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||||
|
using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure;
|
||||||
|
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||||
|
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 1.2 — proves <see cref="OtOpcUaLdapAuthService"/> (the app's ILdapAuthService wrapper over
|
||||||
|
/// the shared <c>ZB.MOM.WW.Auth.Ldap</c> service) preserves the two app-only concerns the library
|
||||||
|
/// does not model: the <c>Enabled</c> master switch and the <c>DevStubMode</c> bypass. Both must
|
||||||
|
/// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result
|
||||||
|
/// (groups, never roles) onto the app result shape with roles left for the downstream mapper.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OtOpcUaLdapAuthServiceTests
|
||||||
|
{
|
||||||
|
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
|
||||||
|
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||||
|
|
||||||
|
/// <summary>DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
|
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.Username.ShouldBe("anyone");
|
||||||
|
result.Groups.ShouldBe(new[] { "dev" });
|
||||||
|
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||||
|
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||||
|
var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe("LDAP authentication is disabled.");
|
||||||
|
inner.Called.ShouldBeFalse("a disabled provider must never touch the network");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
|
||||||
|
/// downstream mapper (the library returns groups, not roles).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(
|
||||||
|
LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" }));
|
||||||
|
var sut = Build(
|
||||||
|
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||||
|
inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||||
|
|
||||||
|
inner.Called.ShouldBeTrue();
|
||||||
|
result.Success.ShouldBeTrue();
|
||||||
|
result.Username.ShouldBe("alice");
|
||||||
|
result.DisplayName.ShouldBe("Alice User");
|
||||||
|
result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" });
|
||||||
|
result.Roles.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Real_path_failure_folds_into_error()
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||||
|
var sut = Build(
|
||||||
|
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||||
|
inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
||||||
|
|
||||||
|
inner.Called.ShouldBeTrue();
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldBe("Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
|
||||||
|
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||||
|
var sut = Build(
|
||||||
|
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false },
|
||||||
|
inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error!.ShouldContain("Insecure LDAP is disabled");
|
||||||
|
inner.Called.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empty username/password are rejected up front without a library call.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "pw")]
|
||||||
|
[InlineData("user", "")]
|
||||||
|
public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw)
|
||||||
|
{
|
||||||
|
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||||
|
var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner);
|
||||||
|
|
||||||
|
var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
inner.Called.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
|
||||||
|
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
|
||||||
|
{
|
||||||
|
public bool Called { get; private set; }
|
||||||
|
|
||||||
|
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Called = true;
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user