From ecdc70d30303da1102941158d4ce9cd6fe571f9c Mon Sep 17 00:00:00 2001 From: "aleksey.trofimov" Date: Fri, 17 Jul 2026 15:06:34 +0300 Subject: [PATCH 1/3] =?UTF-8?q?DEV-1565=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20Global=20Catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/LdapGlobalCatalogExtensions.cs | 58 ++++ .../Core/Models/RadiusPipelineContext.cs | 7 + .../Processor/LdapFirstFactorProcessor.cs | 26 +- .../LoadProfile/Ports/IProfileSearch.cs | 23 +- .../LoadProfile/ProfileLoadingStep.cs | 262 +++++++++++++++++- .../Models/LdapServerConfiguration.cs | 10 + .../UseCases/LoadProfile/LdapProfileSearch.cs | 100 ++++++- 7 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs 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 From c6d942e2b6a8307d4fbc3dcdc2656e83ca618cca Mon Sep 17 00:00:00 2001 From: "aleksey.trofimov" Date: Tue, 21 Jul 2026 16:05:55 +0300 Subject: [PATCH 2/3] =?UTF-8?q?DEV-1565=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/LdapGlobalCatalogExtensions.cs | 58 --- .../LoadProfile/Ports/IProfileSearch.cs | 39 +- .../LoadProfile/ProfileLoadingStep.cs | 339 ++---------------- ...actor.Radius.Adapter.v2.Application.csproj | 2 +- .../Models/LdapServerConfiguration.cs | 4 +- .../UseCases/LoadProfile/LdapProfileSearch.cs | 314 ++++++++++++---- 6 files changed, 311 insertions(+), 445 deletions(-) delete mode 100644 src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs b/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs deleted file mode 100644 index 81d38e58..00000000 --- a/src/Multifactor.Radius.Adapter.v2.Application/Core/Extensions/LdapGlobalCatalogExtensions.cs +++ /dev/null @@ -1,58 +0,0 @@ -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/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs b/src/Multifactor.Radius.Adapter.v2.Application/Features/PacketHandler/UseCases/LoadProfile/Ports/IProfileSearch.cs index 4365c4d4..26d6c228 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,31 +1,36 @@ -using System.DirectoryServices.Protocols; -using Multifactor.Radius.Adapter.v2.Application.Core.Models.Abstractions; +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; public interface IProfileSearch { - ILdapProfile? Execute(FindUserDto request); + /// + /// Ищет профиль пользователя (обычный поиск по одному домену, + /// поиск через Global Catalog со снятием неоднозначности, повторное чтение полного + /// профиля с найденного DC). + /// + FindUserResult Execute(FindUserDto request); +} +public record FindUserResult +{ /// - /// Возвращает ВСЕ записи, подошедшие под фильтр поиска, а не только первую. - /// Нужен при поиске через Global Catalog, где sAMAccountName формально уникален - /// только в рамках домена (не леса). + /// Профиль найден и однозначно определён. /// - IReadOnlyList ExecuteMany(FindUserDto request); + /// Профиль пользователя (полный, а не частично реплицированный). + /// + /// Connection-string, к которому нужно обращаться для bind этого пользователя. + /// + public sealed record Found(ILdapProfile Profile, string BindConnectionString) : FindUserResult; /// - /// Резолвит NetBIOS-имя домена в его DNS-имя - /// Нужен, чтобы явно указанный пользователем домен в DOMAIN\user не терялся при поиске - /// через Global Catalog. Возвращает null, если сопоставление не найдено. + /// Профиль не найден (или найден неоднозначно и не смог быть уточнён). /// - string? ResolveDomainDnsNameByNetBiosName( - string connectionString, - AuthType authType, - string userName, - string password, - int bindTimeoutInSeconds, - string netBiosName); + /// + /// true — результат окончательный - сразу отказываем в аутентификации.
+ /// false — можно попробовать следующий настроенный LDAP-сервер. + /// + public sealed record NotFound(bool IsFinal) : FindUserResult; } 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 6b3de810..dab2a896 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,15 +1,11 @@ -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.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; @@ -50,40 +46,38 @@ public async Task ExecuteAsync(RadiusPipelineContext context) var userIdentity = new UserIdentity(context.RequestPacket.UserName); var attributes = GetAttributes(context); - var profile = await LoadUserProfileAsync(userIdentity, attributes, context); - if (profile is null) + var result = await LoadUserProfileAsync(userIdentity, attributes, context); + + switch (result) { - if (context.IsTerminated) - { - // Причина уже подробно залогирована внутри GC-ветки — не дублируем. + case FindUserResult.Found found: + context.LdapProfile = found.Profile; + context.ResolvedBindConnectionString = found.BindConnectionString; + _logger.LogInformation( + "Successfully found '{UserIdentity}' profile at '{Domain}'.", + userIdentity.Identity, + GetProfileLocation(found.Profile, context)); return; - } - var searchBase = GetSearchBaseInfo(context); - _logger.LogWarning( - "Unable to load profile for user '{User}' from '{Domain}'", - userIdentity.Identity, - searchBase); - - if (context.LdapConfiguration!.IsGlobalCatalog()) - { - // Пользователь не найден во всём лесу одним запросом к GC — отказ + case FindUserResult.NotFound { IsFinal: true }: + // Поиск уже был окончательным - отказываем сразу. _logger.LogInformation( - "User '{User}' not found in Global Catalog. Rejecting immediately.", + "User '{User}' not found. 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}"); + case FindUserResult.NotFound { IsFinal: false }: + // Пользователя нет именно в этом домене - попробуем следующий настроенный LDAP-сервер. + var searchBase = GetSearchBaseInfo(context); + _logger.LogWarning( + "Unable to load profile for user '{User}' from '{Domain}'", + userIdentity.Identity, + searchBase); + throw new InvalidOperationException($"Failed to load profile for user {userIdentity.Identity}"); } - context.LdapProfile = profile; - _logger.LogInformation( - "Successfully found '{UserIdentity}' profile at '{Domain}'.", - userIdentity.Identity, - GetProfileLocation(profile, context)); } private void ValidateContext(RadiusPipelineContext context) @@ -101,277 +95,37 @@ private void ValidateContext(RadiusPipelineContext context) throw new InvalidOperationException("Username is required"); } - private 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 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); - } - - 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, + var trustedDomainRequest = CreateFindUserRequest( + domainInfo.ConnectionString, + domainInfo?.GetAuthType() ?? AuthType.Basic, userIdentity, - userDn, - context.LdapSchema!, + new DistinguishedName(domainInfo.DistinguishedName), + domainInfo.Schema, 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; + return Task.FromResult(_profileSearch.Execute(trustedDomainRequest)); } - } - - /// - /// Резолвит 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( - UserIdentity userIdentity, - List attributes, - RadiusPipelineContext context, - DomainInfo domainInfo) - { - var searchBase = new DistinguishedName(domainInfo.DistinguishedName); var request = CreateFindUserRequest( - domainInfo.ConnectionString, - domainInfo?.GetAuthType() ?? AuthType.Basic, + context.LdapConfiguration!.ConnectionString, + AuthType.Basic, userIdentity, - searchBase, - domainInfo.Schema, + context.LdapSchema!.NamingContext, + context.LdapSchema, attributes, context.LdapConfiguration); - var profile = _profileSearch.Execute(request); - - if (profile is not null) - { - _logger.LogDebug("Found profile in specific domain '{Domain}'", searchBase.StringRepresentation); - } - - return Task.FromResult(profile); + return Task.FromResult(_profileSearch.Execute(request)); } private static FindUserDto CreateFindUserRequest( @@ -402,33 +156,6 @@ private static FindUserDto CreateFindUserRequest( }; } - private ILdapProfile? TryGetUserProfile( - UserIdentity userIdentity, - List attributes, - RadiusPipelineContext context) - { - var request = CreateFindUserRequest( - context.LdapConfiguration!.ConnectionString, - AuthType.Basic, - userIdentity, - context.LdapSchema.NamingContext, - context.LdapSchema, - attributes, - context.LdapConfiguration); - - var profile = _profileSearch.Execute(request); - - if (profile is not null) - { - _logger.LogDebug( - "'{UserIdentity}' profile at '{Domain}' was found.", - userIdentity.Identity, - context.LdapSchema.NamingContext.StringRepresentation); - } - - return profile; - } - private static string GetProfileLocation(ILdapProfile profile, RadiusPipelineContext context) { if (!string.IsNullOrWhiteSpace(profile.Dn?.StringRepresentation)) diff --git a/src/Multifactor.Radius.Adapter.v2.Application/Multifactor.Radius.Adapter.v2.Application.csproj b/src/Multifactor.Radius.Adapter.v2.Application/Multifactor.Radius.Adapter.v2.Application.csproj index f32264a6..64f5e5c6 100644 --- a/src/Multifactor.Radius.Adapter.v2.Application/Multifactor.Radius.Adapter.v2.Application.csproj +++ b/src/Multifactor.Radius.Adapter.v2.Application/Multifactor.Radius.Adapter.v2.Application.csproj @@ -12,7 +12,7 @@ - + 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 29e55316..c77fa558 100644 --- a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs +++ b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Configurations/Models/LdapServerConfiguration.cs @@ -1,6 +1,5 @@ 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; @@ -36,8 +35,7 @@ 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); + var isGlobalCatalogPort = new LdapConnectionString(ldapServerSection.ConnectionString).IsGlobalCatalog; if (isGlobalCatalogPort && ldapServerSection.EnableTrustedDomains) throw new InvalidConfigurationException( 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 5f33ca55..2681233f 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,4 +1,5 @@ -using System.DirectoryServices.Protocols; +using System.Diagnostics; +using System.DirectoryServices.Protocols; using Microsoft.Extensions.Logging; using Multifactor.Core.Ldap; using Multifactor.Core.Ldap.Attributes; @@ -19,106 +20,255 @@ namespace Multifactor.Radius.Adapter.v2.Infrastructure.Features.PacketHandler.Us internal sealed class LdapProfileSearch : IProfileSearch { + private const string ConfigurationNamingContextAttribute = "configurationNamingContext"; + private const string NetBiosNameAttribute = "nETBIOSName"; + private const string DnsRootAttribute = "dnsRoot"; + private readonly ILdapConnectionFactory _connectionFactory; private readonly ILogger _logger; + public LdapProfileSearch(ILdapConnectionFactory connectionFactory, ILogger logger) { _logger = logger; _connectionFactory = connectionFactory; } - public ILdapProfile? Execute(FindUserDto dto) + public FindUserResult Execute(FindUserDto dto) { ArgumentNullException.ThrowIfNull(dto); + + var connectionString = new LdapConnectionString(dto.ConnectionString); + + return connectionString.IsGlobalCatalog + ? ExecuteViaGlobalCatalog(dto, connectionString) + : ExecuteSingleDomain(dto); + } + + private FindUserResult ExecuteSingleDomain(FindUserDto dto) + { _logger.LogDebug("Try to find '{userIdentity}' profile at '{domain}'.", 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 entry = connection.FindOne(dto.SearchBase, filter, SearchScope.Subtree, attributes: dto.AttributeNames ?? []); - return entry is null ? null : new LdapProfile(entry, dto.LdapSchema); + if (entry is null) + { + return new FindUserResult.NotFound(IsFinal: false); + } + + _logger.LogDebug("'{userIdentity:l}' profile at '{domain:l}' was found.", dto.UserIdentity.Identity, dto.SearchBase.StringRepresentation); + var profile = new LdapProfile(entry, dto.LdapSchema); + return new FindUserResult.Found(profile, dto.ConnectionString); } - public IReadOnlyList ExecuteMany(FindUserDto dto) + /// + /// Один запрос ко всему лесу. Из найденного DN вычисляется домен пользователя + /// и connection-string для bind напрямую к контроллеру этого домена — на Global Catalog bind не делается. + /// + private FindUserResult ExecuteViaGlobalCatalog(FindUserDto dto, LdapConnectionString globalCatalogConnectionString) { - ArgumentNullException.ThrowIfNull(dto); - _logger.LogDebug("Try to find all '{userIdentity:l}' matches at '{domain:l}'.", dto.UserIdentity.Identity, dto.SearchBase.StringRepresentation); - + var stopwatch = Stopwatch.StartNew(); 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 ?? []); + var entries = connection.Find(DistinguishedName.Empty, filter, SearchScope.Subtree, attributes: dto.AttributeNames ?? []); + stopwatch.Stop(); - return entries.Select(entry => (ILdapProfile)new LdapProfile(entry, dto.LdapSchema)).ToList(); - } + var matches = entries.Select(entry => (ILdapProfile)new LdapProfile(entry, dto.LdapSchema)).ToList(); - private static string BuildFilter(FindUserDto dto) - { - var identityToSearch = dto.UserIdentity; + _logger.LogInformation( + "Global Catalog search for '{UserIdentity:l}' took {ElapsedMs} ms. Matches: {Count}", + dto.UserIdentity.Identity, stopwatch.ElapsedMilliseconds, matches.Count); + + // Если пользователь явно указал домен (DOMAIN\user), результат обязан принадлежать + // именно этому домену, независимо от того, сколько совпадений вернул GC. if (dto.UserIdentity.Format == UserIdentityFormat.NetBiosName) { - var index = dto.UserIdentity.Identity.IndexOf('\\'); - if (index <= 0) - throw new ArgumentException($"Invalid NetBIOS identity: {dto.UserIdentity.Identity}"); - var userName = dto.UserIdentity.Identity[(index + 1)..]; - identityToSearch = new UserIdentity(userName); + matches = FilterByNetBiosDomain(dto, matches); } - var filter = GetFilter(identityToSearch, dto.LdapSchema); - return filter; + var profile = ResolveSingleMatch(dto.UserIdentity, matches); + if (profile is null) + { + return new FindUserResult.NotFound(IsFinal: true); + } + + var domainDnsName = profile.Dn.GetDomainDnsName(); + var bindConnectionString = globalCatalogConnectionString.ToDomainController(domainDnsName); + + if (bindConnectionString.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Resolved bind target '{ConnectionString:l}' uses LDAPS. Domain controller certificates are usually " + + "issued for the DC's own FQDN, not the bare domain DNS name '{Domain:l}'", + bindConnectionString, domainDnsName); + } + + _logger.LogDebug( + "User '{UserIdentity:l}' resolved to domain '{Domain:l}' via Global Catalog. Bind target: '{ConnectionString:l}'", + dto.UserIdentity.Identity, domainDnsName, bindConnectionString); + + var fullProfileStopwatch = Stopwatch.StartNew(); + var fullProfile = LoadFullProfileFromDomainController(dto, bindConnectionString, 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. ", + dto.UserIdentity.Identity, bindConnectionString, fullProfileStopwatch.ElapsedMilliseconds); + return new FindUserResult.Found(profile, bindConnectionString); + } + + _logger.LogInformation( + "Re-fetched full profile for '{UserIdentity:l}' from '{ConnectionString:l}' in {ElapsedMs} ms (GC only returns a partial attribute set).", + dto.UserIdentity.Identity, bindConnectionString, fullProfileStopwatch.ElapsedMilliseconds); + + return new FindUserResult.Found(fullProfile, bindConnectionString); } - private ILdapConnection CreateConnection(FindUserDto dto) + /// + /// Перечитывает профиль напрямую с DC, которому принадлежит пользователь — по его точному DN. + /// + private ILdapProfile? LoadFullProfileFromDomainController(FindUserDto dto, string bindConnectionString, DistinguishedName userDn) { - var connectionString = new LdapConnectionString(dto.ConnectionString); - var options = new LdapConnectionOptions(connectionString, - dto.AuthType, - dto.UserName, - dto.Password, - TimeSpan.FromSeconds(dto.BindTimeoutInSeconds)); - return _connectionFactory.CreateConnection(options); + try + { + var refetchDto = dto with + { + ConnectionString = bindConnectionString, + SearchBase = userDn, + AuthType = AuthType.Basic + }; + + var filter = BuildFilter(refetchDto); + using var connection = CreateConnection(refetchDto); + var entry = connection.FindOne(userDn, filter, SearchScope.Subtree, attributes: dto.AttributeNames ?? []); + + return entry is null ? null : new LdapProfile(entry, dto.LdapSchema); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Error while re-fetching full profile for '{UserIdentity:l}' from '{ConnectionString:l}'.", + dto.UserIdentity.Identity, bindConnectionString); + return null; + } } - - private static string GetFilter(UserIdentity identity, ILdapSchema schema) + + /// + /// sAMAccountName уникален только в пределах домена, не всего леса — один и тот же логин + /// может найтись сразу в нескольких доменах. Если так и есть, пробуем определить нужный + /// домен по UPN. Не получилось — отказываем. + /// + private ILdapProfile? ResolveSingleMatch(UserIdentity userIdentity, List matches) { - var identityAttribute = GetIdentityAttribute(identity, schema); - var objectClass = schema.ObjectClass; - var classValue = schema.UserObjectClass; + if (matches.Count == 0) + return null; - return $"(&({objectClass}={classValue})({identityAttribute}={identity.Identity.EscapeCharacters()}))"; + 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); + + return null; } - private static string GetIdentityAttribute(UserIdentity identity, ILdapSchema schema) => identity.Format switch + private static ILdapProfile? TryFindMatchByUpnSuffix(UserIdentity userIdentity, List matches) { - UserIdentityFormat.UserPrincipalName => "userPrincipalName", - UserIdentityFormat.DistinguishedName => schema.Dn, - UserIdentityFormat.SamAccountName => schema.Uid, - _ => throw new NotSupportedException("Unsupported user identity format") - }; + if (userIdentity.Format != UserIdentityFormat.UserPrincipalName) + { + return null; + } - private const string ConfigurationNamingContextAttribute = "configurationNamingContext"; - private const string NetBiosNameAttribute = "nETBIOSName"; - private const string DnsRootAttribute = "dnsRoot"; + var suffix = userIdentity.GetUpnSuffix(); + if (string.IsNullOrWhiteSpace(suffix)) + { + return null; + } + + var candidates = matches + .Where(m => IsSameOrSubDomain(m.Dn.GetDomainDnsName(), suffix)) + .ToList(); - public string? ResolveDomainDnsNameByNetBiosName( - string connectionString, - AuthType authType, - string userName, - string password, - int bindTimeoutInSeconds, - string netBiosName) + return candidates.Count == 1 ? candidates[0] : null; + } + + /// + /// Резолвит NetBIOS-домен из логина в DNS-имя (через crossRef в Configuration NC) и + /// отфильтровывает GC-совпадения, оставляя только те, что принадлежат именно этому + /// домену. Если домен не резолвится — отказываем. + /// + private List FilterByNetBiosDomain(FindUserDto dto, List matches) { - ArgumentException.ThrowIfNullOrWhiteSpace(netBiosName); + var userIdentity = dto.UserIdentity; + var index = userIdentity.Identity.IndexOf('\\'); + if (index <= 0) + { + return matches; + } - var options = new LdapConnectionOptions( - new LdapConnectionString(connectionString), - authType, - userName, - password, - TimeSpan.FromSeconds(bindTimeoutInSeconds)); + var netBiosName = userIdentity.Identity[..index]; - using var connection = _connectionFactory.CreateConnection(options); + string? domainDns; + try + { + domainDns = ResolveDomainDnsNameByNetBiosName(dto, 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.GetDomainDnsName(), 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); + + private string? ResolveDomainDnsNameByNetBiosName(FindUserDto dto, string netBiosName) + { + using var connection = CreateConnection(dto); var rootDse = connection.FindOne( DistinguishedName.Empty, @@ -134,7 +284,7 @@ private static string GetFilter(UserIdentity identity, ILdapSchema schema) { _logger.LogWarning( "Could not determine Configuration naming context from RootDSE at '{ConnectionString:l}' while resolving NetBIOS domain '{NetBiosName:l}'.", - connectionString, netBiosName); + dto.ConnectionString, netBiosName); return null; } @@ -158,4 +308,48 @@ private static string GetFilter(UserIdentity identity, ILdapSchema schema) return dnsRoot; } + + private static string BuildFilter(FindUserDto dto) + { + var identityToSearch = dto.UserIdentity; + if (dto.UserIdentity.Format == UserIdentityFormat.NetBiosName) + { + var index = dto.UserIdentity.Identity.IndexOf('\\'); + if (index <= 0) + throw new ArgumentException($"Invalid NetBIOS identity: {dto.UserIdentity.Identity}"); + var userName = dto.UserIdentity.Identity[(index + 1)..]; + identityToSearch = new UserIdentity(userName); + } + + var filter = GetFilter(identityToSearch, dto.LdapSchema); + return filter; + } + + private ILdapConnection CreateConnection(FindUserDto dto) + { + var connectionString = new LdapConnectionString(dto.ConnectionString); + var options = new LdapConnectionOptions(connectionString, + dto.AuthType, + dto.UserName, + dto.Password, + TimeSpan.FromSeconds(dto.BindTimeoutInSeconds)); + return _connectionFactory.CreateConnection(options); + } + + private static string GetFilter(UserIdentity identity, ILdapSchema schema) + { + var identityAttribute = GetIdentityAttribute(identity, schema); + var objectClass = schema.ObjectClass; + var classValue = schema.UserObjectClass; + + return $"(&({objectClass}={classValue})({identityAttribute}={identity.Identity.EscapeCharacters()}))"; + } + + private static string GetIdentityAttribute(UserIdentity identity, ILdapSchema schema) => identity.Format switch + { + UserIdentityFormat.UserPrincipalName => "userPrincipalName", + UserIdentityFormat.DistinguishedName => schema.Dn, + UserIdentityFormat.SamAccountName => schema.Uid, + _ => throw new NotSupportedException("Unsupported user identity format") + }; } \ No newline at end of file From ea376a77e80c1a1732bb5c5043987f464a2d05ab Mon Sep 17 00:00:00 2001 From: "aleksey.trofimov" Date: Tue, 21 Jul 2026 16:22:48 +0300 Subject: [PATCH 3/3] =?UTF-8?q?DEV-1565=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=20Stopwatch=20=D0=B2=20infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Processor/LdapFirstFactorProcessor.cs | 6 ---- .../UseCases/FirstFactor/CheckConnection.cs | 35 +++++++++++++++---- 2 files changed, 29 insertions(+), 12 deletions(-) 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 4a9df670..191c7177 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 @@ -70,17 +70,11 @@ public Task Execute(RadiusPipelineContext context) ?? 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.Infrastructure/Features/PacketHandler/UseCases/FirstFactor/CheckConnection.cs b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/FirstFactor/CheckConnection.cs index ec6f86ab..726eeabe 100644 --- a/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/FirstFactor/CheckConnection.cs +++ b/src/Multifactor.Radius.Adapter.v2.Infrastructure/Features/PacketHandler/UseCases/FirstFactor/CheckConnection.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; using Multifactor.Core.Ldap; using Multifactor.Core.Ldap.Connection; using Multifactor.Core.Ldap.Connection.LdapConnectionFactory; @@ -9,21 +11,42 @@ namespace Multifactor.Radius.Adapter.v2.Infrastructure.Features.PacketHandler.Us internal sealed class CheckConnection : ICheckConnection { private readonly ILdapConnectionFactory _connectionFactory; + private readonly ILogger _logger; - public CheckConnection(ILdapConnectionFactory connectionFactory) + public CheckConnection(ILdapConnectionFactory connectionFactory, ILogger logger) { _connectionFactory = connectionFactory; + _logger = logger; } public bool Execute(CheckConnectionDto dto) { ArgumentNullException.ThrowIfNull(dto); - var options = new LdapConnectionOptions(new LdapConnectionString(dto.ConnectionString), + + var options = new LdapConnectionOptions(new LdapConnectionString(dto.ConnectionString), dto.AuthType, - dto.UserName, - dto.Password, + dto.UserName, + dto.Password, TimeSpan.FromSeconds(dto.BindTimeoutInSeconds)); - using var connection = _connectionFactory.CreateConnection(options); - return true; //true or exception + + var stopwatch = Stopwatch.StartNew(); + try + { + using var connection = _connectionFactory.CreateConnection(options); + stopwatch.Stop(); + _logger.LogInformation( + "LDAP bind for user '{user:l}' to '{ldapUri:l}' took {ElapsedMs} ms. Success: {Success}", + dto.UserName, dto.ConnectionString, stopwatch.ElapsedMilliseconds, true); + + return true; //true or exception + } + catch + { + stopwatch.Stop(); + _logger.LogInformation( + "LDAP bind for user '{user:l}' to '{ldapUri:l}' took {ElapsedMs} ms. Success: {Success}", + dto.UserName, dto.ConnectionString, stopwatch.ElapsedMilliseconds, false); + throw; + } } } \ No newline at end of file