diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs b/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs new file mode 100644 index 00000000..81d38e58 --- /dev/null +++ b/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs @@ -0,0 +1,58 @@ +using Multifactor.Core.Ldap; +using Multifactor.Core.Ldap.Name; +using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; + +namespace Multifactor.Radius.Adapter.v2.Application.Core.Extensions; + +public static class LdapGlobalCatalogExtensions +{ + private const int GlobalCatalogPort = 3268; + private const int GlobalCatalogSslPort = 3269; + private const int LdapPort = 389; + private const int LdapsPort = 636; + + /// + /// LDAP-сервер считается Global Catalog, если в connection-string указан порт 3268/3269 + /// + public static bool IsGlobalCatalog(this ILdapServerConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + return IsGlobalCatalogPort(new LdapConnectionString(config.ConnectionString).Port); + } + + public static bool IsGlobalCatalogPort(int port) => port is GlobalCatalogPort or GlobalCatalogSslPort; + + /// + /// Извлекает DNS-имя домена (например, "child.test.group") из DN пользователя, + /// найденного через GC (например, "CN=User1,OU=Users,DC=child,DC=test,DC=group"). + /// + public static string ExtractDomainDnsName(this DistinguishedName dn) + { + ArgumentNullException.ThrowIfNull(dn); + + var parts = dn.StringRepresentation + .Split(',') + .Select(p => p.Trim()) + .Where(p => p.StartsWith("DC=", StringComparison.OrdinalIgnoreCase)) + .Select(p => p[3..].Trim()); + + return string.Join(".", parts); + } + + /// + /// Строит connection-string для bind к контроллеру конкретного домена по его DNS-имени. + /// + public static string ToDomainControllerConnectionString(this LdapConnectionString globalCatalogConnectionString, string domainDnsName) + { + ArgumentNullException.ThrowIfNull(globalCatalogConnectionString); + ArgumentException.ThrowIfNullOrWhiteSpace(domainDnsName); + + var isSsl = globalCatalogConnectionString.Port == GlobalCatalogSslPort + || globalCatalogConnectionString.Scheme.Equals("ldaps", StringComparison.OrdinalIgnoreCase); + + var scheme = isSsl ? "ldaps" : "ldap"; + var port = isSsl ? LdapsPort : LdapPort; + + return $"{scheme}://{domainDnsName}:{port}"; + } +} diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Core/Models/RadiusPipelineContext.cs b/src/Multifactor.Radius.Adapter.v2.Application/Core/Models/RadiusPipelineContext.cs index 9dbd6a84..ebb4198d 100644 --- a/src/Multifactor.Radius.Adapter.v2.Application/Core/Models/RadiusPipelineContext.cs +++ b/src/Multifactor.Radius.Adapter.v2.Application/Core/Models/RadiusPipelineContext.cs @@ -16,6 +16,13 @@ public sealed class RadiusPipelineContext public IForestMetadata? ForestMetadata { get; set; } public ILdapProfile? LdapProfile { get; set; } public string MustChangePasswordDomain { get; set; } + + /// + /// Connection-string контроллера домена (389/636), вычисленный из DN пользователя + /// после поиска через Global Catalog. Если задан — используется для bind вместо + /// .ConnectionString и вместо эвристики по ForestMetadata. + /// + public string? ResolvedBindConnectionString { get; set; } public HashSet UserGroups { get; set; } = []; public RadiusPacket? ResponsePacket { get; set; } diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/FirstFactor/Processor/LdapFirstFactorProcessor.cs b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/FirstFactor/Processor/LdapFirstFactorProcessor.cs index d3eae43e..4a9df670 100644 --- a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/FirstFactor/Processor/LdapFirstFactorProcessor.cs +++ b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/FirstFactor/Processor/LdapFirstFactorProcessor.cs @@ -1,11 +1,9 @@ using Microsoft.Extensions.Logging; -using Multifactor.Core.Ldap; -using Multifactor.Core.Ldap.Name; using Multifactor.Radius.Adapter.v2.Application.Core.Enum; using Multifactor.Radius.Adapter.v2.Application.Core.Models; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.FirstFactor.Models; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.FirstFactor.Ports; -using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadLdapForest.Models; +using System.Diagnostics; using System.DirectoryServices.Protocols; using System.Text.RegularExpressions; @@ -63,8 +61,26 @@ public Task Execute(RadiusPipelineContext context) var userIdentity = new UserIdentity(context.RequestPacket.UserName); var domain = context.ForestMetadata?.DetermineForestDomain(userIdentity); var formatted = LdapBindNameFormatter.FormatName(context.RequestPacket.UserName!, context.LdapProfile!); - var connectionString = domain?.ConnectionString ?? context.LdapConfiguration!.ConnectionString; - var isValid = ValidateUserCredentials(context, formatted, passphrase.Password, connectionString, context.LdapConfiguration.BindTimeoutSeconds); + + // Приоритет источников connection-string для bind: + // 1. Домен, вычисленный из DN пользователя после поиска через Global Catalog; + // 2. Домен, определённый эвристикой по UPN/NetBIOS (механизм trusted domains); + // 3. Connection-string текущего LdapServer-блока. + var connectionString = context.ResolvedBindConnectionString + ?? domain?.ConnectionString + ?? context.LdapConfiguration!.ConnectionString; + + var bindStopwatch = Stopwatch.StartNew(); + var isValid = ValidateUserCredentials(context, + formatted, + passphrase.Password, + connectionString, + context.LdapConfiguration.BindTimeoutSeconds); + bindStopwatch.Stop(); + _logger.LogInformation( + "LDAP bind for user '{user:l}' to '{ldapUri:l}' took {ElapsedMs} ms. Success: {Success}", + radiusPacket.UserName, connectionString, bindStopwatch.ElapsedMilliseconds, isValid); + if (!isValid) { diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs index dac3cd60..4365c4d4 100644 --- a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs +++ b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs @@ -1,4 +1,5 @@ -using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; +using System.DirectoryServices.Protocols; +using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadProfile.Models; namespace Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadProfile.Ports; @@ -6,5 +7,25 @@ namespace Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCa public interface IProfileSearch { ILdapProfile? Execute(FindUserDto request); + + /// + /// Возвращает ВСЕ записи, подошедшие под фильтр поиска, а не только первую. + /// Нужен при поиске через Global Catalog, где sAMAccountName формально уникален + /// только в рамках домена (не леса). + /// + IReadOnlyList ExecuteMany(FindUserDto request); + + /// + /// Резолвит NetBIOS-имя домена в его DNS-имя + /// Нужен, чтобы явно указанный пользователем домен в DOMAIN\user не терялся при поиске + /// через Global Catalog. Возвращает null, если сопоставление не найдено. + /// + string? ResolveDomainDnsNameByNetBiosName( + string connectionString, + AuthType authType, + string userName, + string password, + int bindTimeoutInSeconds, + string netBiosName); } diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/ProfileLoadingStep.cs b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/ProfileLoadingStep.cs index 49b86e2c..6b3de810 100644 --- a/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/ProfileLoadingStep.cs +++ b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/ProfileLoadingStep.cs @@ -1,16 +1,17 @@ +using System.Diagnostics; using System.DirectoryServices.Protocols; using Microsoft.Extensions.Logging; using Multifactor.Core.Ldap; using Multifactor.Core.Ldap.Attributes; using Multifactor.Core.Ldap.Name; using Multifactor.Core.Ldap.Schema; +using Multifactor.Radius.Adapter.v2.Application.Core.Enum; +using Multifactor.Radius.Adapter.v2.Application.Core.Extensions; using Multifactor.Radius.Adapter.v2.Application.Core.Models; using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; -using Multifactor.Radius.Adapter.v2.Application.Core.Models.Dto; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadLdapForest.Models; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadProfile.Models; using Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadProfile.Ports; -using Multifactor.Radius.Adapter.v2.Application.SharedPorts; namespace Multifactor.Radius.Adapter.v2.Application.Features.PacketHandler.UseCases.LoadProfile; @@ -52,11 +53,30 @@ public async Task ExecuteAsync(RadiusPipelineContext context) var profile = await LoadUserProfileAsync(userIdentity, attributes, context); if (profile is null) { + if (context.IsTerminated) + { + // Причина уже подробно залогирована внутри GC-ветки — не дублируем. + return; + } + var searchBase = GetSearchBaseInfo(context); _logger.LogWarning( "Unable to load profile for user '{User}' from '{Domain}'", userIdentity.Identity, searchBase); + + if (context.LdapConfiguration!.IsGlobalCatalog()) + { + // Пользователь не найден во всём лесу одним запросом к GC — отказ + _logger.LogInformation( + "User '{User}' not found in Global Catalog. Rejecting immediately.", + userIdentity.Identity); + context.FirstFactorStatus = AuthenticationStatus.Reject; + context.SecondFactorStatus = AuthenticationStatus.Reject; + context.Terminate(); + return; + } + throw new InvalidOperationException($"Failed to load profile for user {userIdentity.Identity}"); } context.LdapProfile = profile; @@ -81,19 +101,251 @@ private void ValidateContext(RadiusPipelineContext context) throw new InvalidOperationException("Username is required"); } - private async Task LoadUserProfileAsync( + private Task LoadUserProfileAsync( UserIdentity userIdentity, List attributes, RadiusPipelineContext context) { + if (context.LdapConfiguration!.IsGlobalCatalog()) + { + return Task.FromResult(LoadUserProfileFromGlobalCatalog(userIdentity, attributes, context)); + } + var domainInfo = context.ForestMetadata?.DetermineForestDomain(userIdentity); if (domainInfo is not null) { - return await LoadProfileFromSpecificDomainAsync(userIdentity, attributes, context, domainInfo); + return LoadProfileFromSpecificDomainAsync(userIdentity, attributes, context, domainInfo); + } + + return Task.FromResult(TryGetUserProfile(userIdentity, attributes, context)); + } + + /// + /// Один поисковый запрос ко всему лесу через Global Catalog. + /// Из найденного DN вычисляется домен пользователя и connection-string для последующего + /// bind напрямую к контроллеру этого домена (см. ). + /// + private ILdapProfile? LoadUserProfileFromGlobalCatalog( + UserIdentity userIdentity, + List attributes, + RadiusPipelineContext context) + { + var stopwatch = Stopwatch.StartNew(); + var request = CreateFindUserRequest( + context.LdapConfiguration!.ConnectionString, + AuthType.Basic, + userIdentity, + DistinguishedName.Empty, + context.LdapSchema!, + attributes, + context.LdapConfiguration); + + var matches = _profileSearch.ExecuteMany(request); + stopwatch.Stop(); + + _logger.LogInformation( + "Global Catalog search for '{UserIdentity:l}' took {ElapsedMs} ms. Matches: {Count}", + userIdentity.Identity, stopwatch.ElapsedMilliseconds, matches.Count); + + // Если пользователь явно указал домен (DOMAIN\user), независимо от того, сколько совпадений вернул GC (даже одно), + // результат обязан принадлежать именно этому домену. + if (userIdentity.Format == UserIdentityFormat.NetBiosName) + { + matches = FilterByNetBiosDomain(userIdentity, matches, context); } - return TryGetUserProfile(userIdentity, attributes, context); + var profile = ResolveSingleMatch(userIdentity, matches, context); + if (profile is null) + { + return null; + } + + var globalCatalogConnectionString = new LdapConnectionString(context.LdapConfiguration.ConnectionString); + var domainDnsName = profile.Dn.ExtractDomainDnsName(); + context.ResolvedBindConnectionString = globalCatalogConnectionString.ToDomainControllerConnectionString(domainDnsName); + + _logger.LogDebug( + "User '{UserIdentity:l}' resolved to domain '{Domain:l}' via Global Catalog. Bind target: '{ConnectionString:l}'", + userIdentity.Identity, domainDnsName, context.ResolvedBindConnectionString); + + var fullProfileStopwatch = Stopwatch.StartNew(); + var fullProfile = LoadFullProfileFromDomainController(userIdentity, attributes, context, profile.Dn); + fullProfileStopwatch.Stop(); + + if (fullProfile is null) + { + _logger.LogWarning( + "Could not re-fetch full profile for '{UserIdentity:l}' from '{ConnectionString:l}' in {ElapsedMs} ms.", + userIdentity.Identity, context.ResolvedBindConnectionString, fullProfileStopwatch.ElapsedMilliseconds); + return profile; + } + + _logger.LogInformation( + "Re-fetched full profile for '{UserIdentity:l}' from '{ConnectionString:l}' in {ElapsedMs} ms (GC only returns a partial attribute set).", + userIdentity.Identity, context.ResolvedBindConnectionString, fullProfileStopwatch.ElapsedMilliseconds); + + return fullProfile; + } + + /// + /// Перечитывает профиль напрямую с DC, которому принадлежит пользователь — по его точному DN. + /// GC содержит только Partial Attribute Set, а MFA-группы, кастомные identity/phone/reply + /// атрибуты должны читаться из полной реплики. + /// + private ILdapProfile? LoadFullProfileFromDomainController( + UserIdentity userIdentity, + List attributes, + RadiusPipelineContext context, + DistinguishedName userDn) + { + try + { + var request = CreateFindUserRequest( + context.ResolvedBindConnectionString!, + AuthType.Basic, + userIdentity, + userDn, + context.LdapSchema!, + attributes, + context.LdapConfiguration!); + + return _profileSearch.Execute(request); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Error while re-fetching full profile for '{UserIdentity:l}' from '{ConnectionString:l}'.", + userIdentity.Identity, context.ResolvedBindConnectionString); + return null; + } + } + + /// + /// Резолвит NetBIOS-домен из логина в DNS-имя и отфильтровывает GC-совпадения, оставляя + /// только те, что принадлежат именно этому домену. Если домен не резолвится — считаем это + /// поводом отказать, а не поводом искать без учёта домена. + /// + private IReadOnlyList FilterByNetBiosDomain( + UserIdentity userIdentity, + IReadOnlyList matches, + RadiusPipelineContext context) + { + var index = userIdentity.Identity.IndexOf('\\'); + if (index <= 0) + { + return matches; + } + + var netBiosName = userIdentity.Identity[..index]; + + string? domainDns; + try + { + domainDns = _profileSearch.ResolveDomainDnsNameByNetBiosName( + context.LdapConfiguration!.ConnectionString, + AuthType.Basic, + context.LdapConfiguration.Username, + context.LdapConfiguration.Password, + context.LdapConfiguration.BindTimeoutSeconds, + netBiosName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve NetBIOS domain '{NetBiosName:l}' to a DNS domain name.", netBiosName); + domainDns = null; + } + + if (string.IsNullOrWhiteSpace(domainDns)) + { + _logger.LogWarning( + "Could not resolve NetBIOS domain '{NetBiosName:l}' specified in login '{UserIdentity:l}'. " + + "Treating as not found rather than searching without the domain the user explicitly specified.", + netBiosName, userIdentity.Identity); + return []; + } + + var filtered = matches.Where(m => IsSameOrSubDomain(m.Dn.ExtractDomainDnsName(), domainDns)).ToList(); + + if (filtered.Count != matches.Count) + { + _logger.LogInformation( + "Filtered Global Catalog matches for '{UserIdentity:l}' by explicit NetBIOS domain '{NetBiosName:l}' ('{DomainDns:l}'): {Before} -> {After}.", + userIdentity.Identity, netBiosName, domainDns, matches.Count, filtered.Count); + } + + return filtered; + } + + private static bool IsSameOrSubDomain(string domainDns, string expectedDomainDns) => + domainDns.Equals(expectedDomainDns, StringComparison.OrdinalIgnoreCase) + || domainDns.EndsWith("." + expectedDomainDns, StringComparison.OrdinalIgnoreCase); + + /// + /// Один и тот же логин может найтись сразу в нескольких доменах — sAMAccountName уникален + /// только в пределах домена, не всего леса. Если так и есть, пробуем определить нужный домен + /// по UPN. Не получилось — отказываем. + /// + private ILdapProfile? ResolveSingleMatch(UserIdentity userIdentity, IReadOnlyList matches, RadiusPipelineContext context) + { + if (matches.Count == 0) + { + return null; + } + + if (matches.Count == 1) + { + return matches[0]; + } + + var conflictingDns = matches.Select(m => m.Dn.StringRepresentation).ToList(); + _logger.LogWarning( + "User '{UserIdentity:l}' matched {Count} entries in Global Catalog, login is not unique across the forest: {Dns}", + userIdentity.Identity, matches.Count, conflictingDns); + + var match = TryFindMatchByUpnSuffix(userIdentity, matches); + if (match is not null) + { + _logger.LogInformation( + "Ambiguity for '{UserIdentity:l}' resolved by UPN suffix to '{Dn:l}'.", + userIdentity.Identity, match.Dn.StringRepresentation); + return match; + } + + _logger.LogError( + "User '{UserIdentity:l}' is ambiguous across {Count} domains and cannot be resolved automatically from the provided login. " + + "Rejecting authentication instead of guessing. Conflicting DNs: {Dns}", + userIdentity.Identity, matches.Count, conflictingDns); + + context.FirstFactorStatus = AuthenticationStatus.Reject; + context.SecondFactorStatus = AuthenticationStatus.Reject; + context.Terminate(); + return null; + } + + private static ILdapProfile? TryFindMatchByUpnSuffix(UserIdentity userIdentity, IReadOnlyList matches) + { + if (userIdentity.Format != UserIdentityFormat.UserPrincipalName) + { + return null; + } + + var suffix = userIdentity.GetUpnSuffix(); + if (string.IsNullOrWhiteSpace(suffix)) + { + return null; + } + + var candidates = matches + .Where(m => + { + var domainDns = m.Dn.ExtractDomainDnsName(); + return domainDns.Equals(suffix, StringComparison.OrdinalIgnoreCase) + || domainDns.EndsWith("." + suffix, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + + return candidates.Count == 1 ? candidates[0] : null; } private Task LoadProfileFromSpecificDomainAsync( diff --git a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs index e0259940..29e55316 100644 --- a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs +++ b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs @@ -1,4 +1,6 @@ +using Multifactor.Core.Ldap; using Multifactor.Core.Ldap.Name; +using Multifactor.Radius.Adapter.v2.Application.Core.Extensions; using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; using Multifactor.Radius.Adapter.v2.Infrastructure.Configurations.Exceptions; using Multifactor.Radius.Adapter.v2.Infrastructure.Configurations.Parser; @@ -34,6 +36,14 @@ public static LdapServerConfiguration FromConfiguration(LdapServerSection ldapSe if (ldapServerSection is { EnableTrustedDomains: true, RequiresUpn: false }) throw new InvalidConfigurationException($"Config name: '{fileName}', LDAP server: '{ldapServerSection.ConnectionString}'. To use trusted domains also set 'requires-upn' to 'true'."); + var isGlobalCatalogPort = LdapGlobalCatalogExtensions.IsGlobalCatalogPort( + new LdapConnectionString(ldapServerSection.ConnectionString).Port); + + if (isGlobalCatalogPort && ldapServerSection.EnableTrustedDomains) + throw new InvalidConfigurationException( + $"Config name: '{fileName}', LDAP server: '{ldapServerSection.ConnectionString}'. " + + "'enable-trusted-domains' cannot be used together with a Global Catalog connection-string (port 3268/3269)"); + if (!string.IsNullOrWhiteSpace(ldapServerSection.IncludedDomains) && !string.IsNullOrWhiteSpace(ldapServerSection.ExcludedDomains)) throw new InvalidConfigurationException($"Config name: '{fileName}', LDAP server: '{ldapServerSection.ConnectionString}'. Simultaneous use of 'included-domains' and 'excluded-domains' is not allowed."); diff --git a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/LoadProfile/LdapProfileSearch.cs b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/LoadProfile/LdapProfileSearch.cs index ef0f0c45..5f33ca55 100644 --- a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/LoadProfile/LdapProfileSearch.cs +++ b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/LoadProfile/LdapProfileSearch.cs @@ -1,9 +1,11 @@ using System.DirectoryServices.Protocols; using Microsoft.Extensions.Logging; using Multifactor.Core.Ldap; +using Multifactor.Core.Ldap.Attributes; using Multifactor.Core.Ldap.Connection; using Multifactor.Core.Ldap.Connection.LdapConnectionFactory; using Multifactor.Core.Ldap.Extensions; +using Multifactor.Core.Ldap.Name; using Multifactor.Core.Ldap.Schema; using Multifactor.Radius.Adapter.v2.Application.Core.Enum; using Multifactor.Radius.Adapter.v2.Application.Core.Models; @@ -30,6 +32,29 @@ public LdapProfileSearch(ILdapConnectionFactory connectionFactory, ILogger ExecuteMany(FindUserDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + _logger.LogDebug("Try to find all '{userIdentity:l}' matches at '{domain:l}'.", dto.UserIdentity.Identity, dto.SearchBase.StringRepresentation); + + var filter = BuildFilter(dto); + _logger.LogDebug("Search base = '{searchBase:l}'. Filter for search = '{filter:l}'", dto.SearchBase.StringRepresentation, filter); + using var connection = CreateConnection(dto); + var entries = connection.Find(dto.SearchBase, filter, SearchScope.Subtree, attributes: dto.AttributeNames ?? []); + + return entries.Select(entry => (ILdapProfile)new LdapProfile(entry, dto.LdapSchema)).ToList(); + } + + private static string BuildFilter(FindUserDto dto) + { var identityToSearch = dto.UserIdentity; if (dto.UserIdentity.Format == UserIdentityFormat.NetBiosName) { @@ -39,19 +64,20 @@ public LdapProfileSearch(ILdapConnectionFactory connectionFactory, ILogger schema.Uid, _ => throw new NotSupportedException("Unsupported user identity format") }; + + private const string ConfigurationNamingContextAttribute = "configurationNamingContext"; + private const string NetBiosNameAttribute = "nETBIOSName"; + private const string DnsRootAttribute = "dnsRoot"; + + public string? ResolveDomainDnsNameByNetBiosName( + string connectionString, + AuthType authType, + string userName, + string password, + int bindTimeoutInSeconds, + string netBiosName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(netBiosName); + + var options = new LdapConnectionOptions( + new LdapConnectionString(connectionString), + authType, + userName, + password, + TimeSpan.FromSeconds(bindTimeoutInSeconds)); + + using var connection = _connectionFactory.CreateConnection(options); + + var rootDse = connection.FindOne( + DistinguishedName.Empty, + "(objectClass=*)", + SearchScope.Base, + attributes: [new LdapAttributeName(ConfigurationNamingContextAttribute)]); + + var configurationNamingContext = rootDse?.Attributes + .SafeGetAttributeValues(new LdapAttributeName(ConfigurationNamingContextAttribute)) + .FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(configurationNamingContext)) + { + _logger.LogWarning( + "Could not determine Configuration naming context from RootDSE at '{ConnectionString:l}' while resolving NetBIOS domain '{NetBiosName:l}'.", + connectionString, netBiosName); + return null; + } + + var partitionsBase = new DistinguishedName($"CN=Partitions,{configurationNamingContext}"); + var filter = $"(&(objectClass=crossRef)({NetBiosNameAttribute}={netBiosName.EscapeCharacters()}))"; + + var entry = connection.FindOne( + partitionsBase, + filter, + SearchScope.OneLevel, + attributes: [new LdapAttributeName(DnsRootAttribute)]); + + var dnsRoot = entry?.Attributes.SafeGetAttributeValues(new LdapAttributeName(DnsRootAttribute)).FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(dnsRoot)) + { + _logger.LogWarning( + "NetBIOS domain '{NetBiosName:l}' was not found among crossRef objects under '{PartitionsBase:l}'.", + netBiosName, partitionsBase.StringRepresentation); + } + + return dnsRoot; + } } \ No newline at end of file