Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs
T

143 lines
8.2 KiB
C#

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 an Administrator
/// 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&lt;string&gt;</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 Administrator 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 grants the canonical <c>"Administrator"</c> control-plane role (group
/// <c>"dev"</c>) so the dev session can navigate the full Admin UI (renamed from the prior
/// <c>"FleetAdmin"</c> to the canonical <c>"Administrator"</c>).
/// </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 Administrator WITHOUT a real bind.
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
// survives the move to IGroupRoleMapper. (The role string was canonicalized from the
// prior "FleetAdmin" to "Administrator".)
_logger.LogWarning(
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
return new(true, username, username, ["dev"], ["Administrator"], 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. 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);
// ServiceAccountBindFailed is the library's directory-unreachable / service-account-misconfigured
// bucket (its enum has no dedicated DirectoryUnavailable) — flag it as system-side so the OPC UA
// authenticator's outage circuit can distinguish an outage from a user-credential deny (03/S2).
return new(false, null, username, [], [], FailureToError(result.Failure))
{
IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed,
};
}
/// <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",
};
}