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