161 lines
6.6 KiB
C#
161 lines
6.6 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
|
|
|
/// <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;
|
|
|
|
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
|
{
|
|
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.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 = $"(uid={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 uid={username}");
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
|
? $"cn={username}"
|
|
: $"cn={username},{_options.SearchBase}";
|
|
}
|
|
|
|
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>
|
|
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;
|
|
}
|
|
|
|
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..];
|
|
}
|
|
}
|