From ccd34528189e89afe2cbc9d3f7dba557c8af93bc Mon Sep 17 00:00:00 2001 From: Vladimir Zimin Date: Fri, 4 Oct 2024 15:02:41 +0300 Subject: [PATCH 1/2] improve multidomain search --- Extensions/HttpContextBaseExtensions.cs | 9 +- Interop/NameTranslator.cs | 60 +++++++++ Interop/NativeMethods.cs | 160 ++++++++++++++++++++++++ Interop/SafeDsHandle.cs | 28 +++++ Interop/UserSearchContext.cs | 23 ++++ MsDynamics365/Module.cs | 12 +- MultiFactor.IIS.Adapter.csproj | 6 + Owa/Module.cs | 20 +-- Owa/UserRequiredSecondFactor.cs | 15 ++- Services/AccessUrl.cs | 20 +-- Services/ActiveDirectoryService.cs | 80 ++++++++---- Services/AuthChecker.cs | 10 +- Services/Ldap/LdapConnectionAdapter.cs | 4 +- Services/Ldap/LdapIdentity.cs | 96 ++++++++++++++ Services/Ldap/NetbiosService.cs | 45 +++++++ Services/Ldap/Profile/ILdapProfile.cs | 5 +- Services/Ldap/Profile/LdapProfile.cs | 29 +++-- Services/Ldap/Profile/ProfileLoader.cs | 45 +++++-- Services/MfaApiRequestExecutor.cs | 9 +- Services/TokenValidationService.cs | 3 + 20 files changed, 598 insertions(+), 81 deletions(-) create mode 100644 Interop/NameTranslator.cs create mode 100644 Interop/NativeMethods.cs create mode 100644 Interop/SafeDsHandle.cs create mode 100644 Interop/UserSearchContext.cs create mode 100644 Services/Ldap/LdapIdentity.cs create mode 100644 Services/Ldap/NetbiosService.cs diff --git a/Extensions/HttpContextBaseExtensions.cs b/Extensions/HttpContextBaseExtensions.cs index a3c614f..0e54bed 100644 --- a/Extensions/HttpContextBaseExtensions.cs +++ b/Extensions/HttpContextBaseExtensions.cs @@ -16,7 +16,7 @@ public static CacheAdapter GetCacheAdapter(this HttpContextBase httpContext) return new CacheAdapter(httpContext); } - public static bool HasApiUnreachableFlag(this HttpContextBase httpContext) + public static bool HasApiUnreachableFlag(this HttpContextBase httpContext, bool trimName = true) { var name = httpContext?.User?.Identity?.Name; if (string.IsNullOrEmpty(name)) @@ -24,7 +24,12 @@ public static bool HasApiUnreachableFlag(this HttpContextBase httpContext) return false; } - return httpContext.GetCacheAdapter().GetApiUnreachable(Util.CanonicalizeUserName(name)); + // for owa we dont want trim username, because owa send netbiosname + // but for compatibility with Ms365 we use flag + if (trimName) + return httpContext.GetCacheAdapter().GetApiUnreachable(Util.CanonicalizeUserName(name)); + else + return httpContext.GetCacheAdapter().GetApiUnreachable(name); } } } \ No newline at end of file diff --git a/Interop/NameTranslator.cs b/Interop/NameTranslator.cs new file mode 100644 index 0000000..1d74e17 --- /dev/null +++ b/Interop/NameTranslator.cs @@ -0,0 +1,60 @@ +using MultiFactor.IIS.Adapter.Services; +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using static MultiFactor.IIS.Adapter.Interop.NativeMethods; + +namespace MultiFactor.IIS.Adapter.Interop +{ + public class NameTranslator : IDisposable + { + private readonly SafeDsHandle _handle; + private readonly Logger _logger; + private readonly string _domain; + + public NameTranslator(string domain, Logger logger) + { + _domain = domain; + _logger = logger; + uint res = DsBind(domain, null, out _handle); + if (res != (uint)DS_NAME_ERROR.DS_NAME_NO_ERROR) + { + _logger.Warn($"Failed to bind to: {domain}"); + throw new Win32Exception((int)res); + } + } + + public UserSearchContext Translate(string netbiosName) + { + uint err = DsCrackNames(_handle, DS_NAME_FLAGS.DS_NAME_FLAG_EVAL_AT_DC | DS_NAME_FLAGS.DS_NAME_FLAG_TRUST_REFERRAL, DS_NAME_FORMAT.DS_NT4_ACCOUNT_NAME, DS_NAME_FORMAT.DS_USER_PRINCIPAL_NAME, 1, new[] { netbiosName }, out IntPtr pResult); + if (err != (uint)DS_NAME_ERROR.DS_NAME_NO_ERROR) + { + _logger.Warn($"Failed to translate {netbiosName} in {_domain}"); + throw new Win32Exception((int)err); + } + + try + { + // Next convert the returned structure to managed environment + DS_NAME_RESULT Result = (DS_NAME_RESULT)Marshal.PtrToStructure(pResult, typeof(DS_NAME_RESULT)); + var res = Result.Items; + if (res == null || res.Length == 0 || (!res[0].status.HasFlag(DS_NAME_ERROR.DS_NAME_ERROR_TRUST_REFERRAL) && !res[0].status.HasFlag(DS_NAME_ERROR.DS_NAME_NO_ERROR))) + { + _logger.Warn($"Unexpected result of translation {netbiosName} in {_domain}"); + throw new System.Security.SecurityException("Unable to resolve user name."); + } + return new UserSearchContext(res[0].pDomain, res[0].pName, netbiosName); + } + finally + { + DsFreeNameResult(pResult); + } + + } + + public void Dispose() + { + _handle.Dispose(); + } + } +} \ No newline at end of file diff --git a/Interop/NativeMethods.cs b/Interop/NativeMethods.cs new file mode 100644 index 0000000..29b6b83 --- /dev/null +++ b/Interop/NativeMethods.cs @@ -0,0 +1,160 @@ +using System; +using System.Runtime.InteropServices; + +namespace MultiFactor.IIS.Adapter.Interop +{ + internal static class NativeMethods + { + private const string NTDSAPI = "ntdsapi.dll"; + + [DllImport(NTDSAPI, CharSet = CharSet.Auto)] + public static extern uint DsBind( + string DomainControllerName, // in, optional + string DnsDomainName, // in, optional + out SafeDsHandle phDS); + + [DllImport(NTDSAPI, CharSet = CharSet.Auto)] + public static extern uint DsCrackNames( + SafeDsHandle hDS, + DS_NAME_FLAGS flags, + DS_NAME_FORMAT formatOffered, + DS_NAME_FORMAT formatDesired, + uint cNames, + [MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPTStr, SizeParamIndex = 4)] + string[] rpNames, + out IntPtr ppResult); + + [DllImport(NTDSAPI, CharSet = CharSet.Auto)] + public static extern void DsFreeNameResult(IntPtr pResult /* DS_NAME_RESULT* */); + + [DllImport(NTDSAPI, CharSet = CharSet.Auto)] + public static extern uint DsUnBind(ref IntPtr phDS); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public struct DS_NAME_RESULT + { + public uint cItems; + internal IntPtr rItems; // PDS_NAME_RESULT_ITEM + public DS_NAME_RESULT_ITEM[] Items + { + get + { + if (rItems == IntPtr.Zero) + { + return new DS_NAME_RESULT_ITEM[0]; + } + + var ResultArray = new DS_NAME_RESULT_ITEM[cItems]; + Type strType = typeof(DS_NAME_RESULT_ITEM); + int stSize = Marshal.SizeOf(strType); + IntPtr curptr; + + for (uint i = 0; i < cItems; i++) + { + curptr = new IntPtr(rItems.ToInt64() + (i * stSize)); + ResultArray[i] = (DS_NAME_RESULT_ITEM)Marshal.PtrToStructure(curptr, strType); + } + return ResultArray; + } + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public struct DS_NAME_RESULT_ITEM + { + public DS_NAME_ERROR status; + public string pDomain; + public string pName; + public override string ToString() + { + if (status == DS_NAME_ERROR.DS_NAME_NO_ERROR) + { + return pName; + } + + return null; + } + } + + public enum DS_NAME_ERROR + { + DS_NAME_NO_ERROR = 0, + // Generic processing error. + DS_NAME_ERROR_RESOLVING = 1, + // Couldn't find the name at all - or perhaps caller doesn't have + // rights to see it. + DS_NAME_ERROR_NOT_FOUND = 2, + // Input name mapped to more than one output name. + DS_NAME_ERROR_NOT_UNIQUE = 3, + // Input name found, but not the associated output format. + // Can happen if object doesn't have all the required attributes. + DS_NAME_ERROR_NO_MAPPING = 4, + // Unable to resolve entire name, but was able to determine which + // domain object resides in. Thus DS_NAME_RESULT_ITEM?.pDomain + // is valid on return. + DS_NAME_ERROR_DOMAIN_ONLY = 5, + // Unable to perform a purely syntactical mapping at the client + // without going out on the wire. + DS_NAME_ERROR_NO_SYNTACTICAL_MAPPING = 6, + // The name is from an external trusted forest. + DS_NAME_ERROR_TRUST_REFERRAL = 7 + } + + [Flags] + public enum DS_NAME_FLAGS + { + DS_NAME_NO_FLAGS = 0x0, + // Perform a syntactical mapping at the client (if possible) without + // going out on the wire. Returns DS_NAME_ERROR_NO_SYNTACTICAL_MAPPING + // if a purely syntactical mapping is not possible. + DS_NAME_FLAG_SYNTACTICAL_ONLY = 0x1, + // Force a trip to the DC for evaluation, even if this could be + // locally cracked syntactically. + DS_NAME_FLAG_EVAL_AT_DC = 0x2, + // The call fails if the DC is not a GC + DS_NAME_FLAG_GCVERIFY = 0x4, + // Enable cross forest trust referral + DS_NAME_FLAG_TRUST_REFERRAL = 0x8 + } + + public enum DS_NAME_FORMAT + { + // unknown name type + DS_UNKNOWN_NAME = 0, + // eg: CN=User Name,OU=Users,DC=Example,DC=Microsoft,DC=Com + DS_FQDN_1779_NAME = 1, + // eg: Example\UserN + // Domain-only version includes trailing '\\'. + DS_NT4_ACCOUNT_NAME = 2, + // Probably "User Name" but could be something else. I.e. The + // display name is not necessarily the defining RDN. + DS_DISPLAY_NAME = 3, + // obsolete - see #define later + // DS_DOMAIN_SIMPLE_NAME = 4, + // obsolete - see #define later + // DS_ENTERPRISE_SIMPLE_NAME = 5, + // String-ized GUID as returned by IIDFromString(). + // eg: {4fa050f0-f561-11cf-bdd9-00aa003a77b6} + DS_UNIQUE_ID_NAME = 6, + // eg: example.microsoft.com/software/user name + // Domain-only version includes trailing '/'. + DS_CANONICAL_NAME = 7, + // eg: usern@example.microsoft.com + DS_USER_PRINCIPAL_NAME = 8, + // Same as DS_CANONICAL_NAME except that rightmost '/' is + // replaced with '\n' - even in domain-only case. + // eg: example.microsoft.com/software\nuser name + DS_CANONICAL_NAME_EX = 9, + // eg: www/www.microsoft.com@example.com - generalized service principal + // names. + DS_SERVICE_PRINCIPAL_NAME = 10, + // This is the string representation of a SID. Invalid for formatDesired. + // See sddl.h for SID binary <--> text conversion routines. + // eg: S-1-5-21-397955417-626881126-188441444-501 + DS_SID_OR_SID_HISTORY_NAME = 11, + // Pseudo-name format so GetUserNameEx can return the DNS domain name to + // a caller. This level is not supported by the DS APIs. + DS_DNS_DOMAIN_NAME = 12 + } + } +} \ No newline at end of file diff --git a/Interop/SafeDsHandle.cs b/Interop/SafeDsHandle.cs new file mode 100644 index 0000000..2ddc245 --- /dev/null +++ b/Interop/SafeDsHandle.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace MultiFactor.IIS.Adapter.Interop +{ + public class SafeDsHandle : SafeHandle + { + public SafeDsHandle() : base(IntPtr.Zero, true) { } + + public override bool IsInvalid + { + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)] + [PrePrepareMethod] + get { return (handle == IntPtr.Zero); } + } + + [ReliabilityContract(Consistency.WillNotCorruptState, Cer.MayFail)] + [PrePrepareMethod] + + protected override bool ReleaseHandle() + { + uint ret = NativeMethods.DsUnBind(ref handle); + System.Diagnostics.Debug.WriteLineIf(ret != 0, "Error unbinding :\t" + ret.ToString()); + return ret != 0; + } + } +} \ No newline at end of file diff --git a/Interop/UserSearchContext.cs b/Interop/UserSearchContext.cs new file mode 100644 index 0000000..f6d212f --- /dev/null +++ b/Interop/UserSearchContext.cs @@ -0,0 +1,23 @@ +using MultiFactor.IIS.Adapter.Services.Ldap; +using System; + +namespace MultiFactor.IIS.Adapter.Interop +{ + public class UserSearchContext + { + public string Domain { get; set; } + public LdapIdentity UserIdentity { get; set; } + + public UserSearchContext(string domain, string upn, string rawUserName) + { + if (string.IsNullOrWhiteSpace(domain)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(domain)); + if (string.IsNullOrWhiteSpace(upn)) + throw new ArgumentException("Value cannot be null or whitespace.", nameof(upn)); + Domain = domain; + UserIdentity = LdapIdentity.Parse(upn).WithRawName(rawUserName); + } + + public override string ToString() => $"User:{UserIdentity.RawName}, UPN:{UserIdentity.Name}, Domain:{Domain}"; + } +} \ No newline at end of file diff --git a/MsDynamics365/Module.cs b/MsDynamics365/Module.cs index eb1ad9f..ea822eb 100644 --- a/MsDynamics365/Module.cs +++ b/MsDynamics365/Module.cs @@ -2,6 +2,7 @@ using MultiFactor.IIS.Adapter.Extensions; using MultiFactor.IIS.Adapter.Owa; using MultiFactor.IIS.Adapter.Services; +using MultiFactor.IIS.Adapter.Services.Ldap; using System; using System.Web; @@ -49,9 +50,8 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) //not yet authenticated with login/pwd return; } - var user = context.User.Identity.Name; + var user = LdapIdentity.Parse(context.User.Identity.Name); - var canonicalUserName = Util.CanonicalizeUserName(user); //process request or postback to/from MultiFactor if (path.Contains(Constants.MULTIFACTOR_PAGE)) @@ -65,8 +65,8 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) } var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.IIS); - var secondFactorRequired = new UserRequiredSecondFactor(ad); - if (!secondFactorRequired.Execute(canonicalUserName)) + var secondFactorRequired = new UserRequiredSecondFactor(ad, Logger.IIS); + if (!secondFactorRequired.Execute(user)) { //bypass 2fa return; @@ -74,8 +74,8 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) //mfa var valSrv = new TokenValidationService(Logger.IIS); - var checker = new AuthChecker(context, valSrv); - var isAuthenticatedByMultifactor = checker.IsAuthenticated(user); + var checker = new AuthChecker(context, valSrv, Logger.IIS); + var isAuthenticatedByMultifactor = checker.IsAuthenticated(user.RawName); if (isAuthenticatedByMultifactor || context.HasApiUnreachableFlag()) { return; diff --git a/MultiFactor.IIS.Adapter.csproj b/MultiFactor.IIS.Adapter.csproj index a4efe75..e9e54d7 100644 --- a/MultiFactor.IIS.Adapter.csproj +++ b/MultiFactor.IIS.Adapter.csproj @@ -69,9 +69,15 @@ + + + + + + diff --git a/Owa/Module.cs b/Owa/Module.cs index 5ca8a26..d96b364 100644 --- a/Owa/Module.cs +++ b/Owa/Module.cs @@ -1,6 +1,7 @@ using MultiFactor.IIS.Adapter.Core; using MultiFactor.IIS.Adapter.Extensions; using MultiFactor.IIS.Adapter.Services; +using MultiFactor.IIS.Adapter.Services.Ldap; using System; using System.Linq; using System.Security.Principal; @@ -68,14 +69,13 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) return; } - var user = context.User.Identity.Name; - if (user.StartsWith("S-1-5-21")) //SID + var user = LdapIdentity.Parse(context.User.Identity.Name); + if (user.RawName.StartsWith("S-1-5-21")) //SID { - user = TryGetUpnFromSid(context.User.Identity); + user = LdapIdentity.Parse(TryGetUpnFromSid(context.User.Identity)); } - var canonicalUserName = Util.CanonicalizeUserName(user); - if (Constants.EXCHANGE_SYSTEM_MAILBOX_PREFIX.Any(sm => canonicalUserName.StartsWith(sm))) + if (Constants.EXCHANGE_SYSTEM_MAILBOX_PREFIX.Any(sm => user.Name.StartsWith(sm))) { //system mailbox return; @@ -93,8 +93,8 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) } var ad = new ActiveDirectoryService(context.GetCacheAdapter(), Logger.Owa); - var secondFactorRequired = new UserRequiredSecondFactor(ad); - if (!secondFactorRequired.Execute(canonicalUserName)) + var secondFactorRequired = new UserRequiredSecondFactor(ad, Logger.Owa); + if (!secondFactorRequired.Execute(user)) { //bypass 2fa return; @@ -102,9 +102,9 @@ public override void OnPostAuthorizeRequest(HttpContextBase context) //mfa var valSrv = new TokenValidationService(Logger.Owa); - var checker = new AuthChecker(context, valSrv); - var isAuthenticatedByMultifactor = checker.IsAuthenticated(user); - if (isAuthenticatedByMultifactor || context.HasApiUnreachableFlag()) + var checker = new AuthChecker(context, valSrv, Logger.Owa); + var isAuthenticatedByMultifactor = checker.IsAuthenticated(user.RawName); + if (isAuthenticatedByMultifactor || context.HasApiUnreachableFlag(false)) { return; } diff --git a/Owa/UserRequiredSecondFactor.cs b/Owa/UserRequiredSecondFactor.cs index d14ca13..37d1b40 100644 --- a/Owa/UserRequiredSecondFactor.cs +++ b/Owa/UserRequiredSecondFactor.cs @@ -1,4 +1,5 @@ using MultiFactor.IIS.Adapter.Services; +using MultiFactor.IIS.Adapter.Services.Ldap; using System; namespace MultiFactor.IIS.Adapter.Owa @@ -6,17 +7,19 @@ namespace MultiFactor.IIS.Adapter.Owa public class UserRequiredSecondFactor { private readonly ActiveDirectoryService _activeDirectory; + private readonly Logger _logger; - public UserRequiredSecondFactor(ActiveDirectoryService activeDirectory) + public UserRequiredSecondFactor(ActiveDirectoryService activeDirectory, Logger logger) { _activeDirectory = activeDirectory ?? throw new ArgumentNullException(nameof(activeDirectory)); + _logger = logger; } - public bool Execute(string samAccountName) + public bool Execute(LdapIdentity identity) { - if (samAccountName is null) + if (identity is null) { - throw new ArgumentNullException(nameof(samAccountName)); + throw new ArgumentNullException(nameof(identity)); } if (string.IsNullOrEmpty(Configuration.Current.ActiveDirectory2FaGroup)) @@ -24,7 +27,9 @@ public bool Execute(string samAccountName) return true; } - return _activeDirectory.ValidateMembership(samAccountName); + // very noisy log, only for debug + //_logger.Info($"Start validate membership for {identity.RawName}"); + return _activeDirectory.ValidateMembership(identity); } } } \ No newline at end of file diff --git a/Services/AccessUrl.cs b/Services/AccessUrl.cs index 7a18b24..37633af 100644 --- a/Services/AccessUrl.cs +++ b/Services/AccessUrl.cs @@ -1,4 +1,5 @@ using System; +using MultiFactor.IIS.Adapter.Services.Ldap; namespace MultiFactor.IIS.Adapter.Services { @@ -13,25 +14,26 @@ public AccessUrl(ActiveDirectoryService activeDirectory, MultiFactorApiClient ap _api = api ?? throw new ArgumentNullException(nameof(api)); } - public string Get(string rawUsername, string postbackUrl) + public string Get(LdapIdentity identity, string postbackUrl) { - var identity = Util.CanonicalizeUserName(rawUsername); - Logger.API.Info($"Applying identity canonicalization: {rawUsername}->{identity}"); - + var profile = _activeDirectory.GetProfile(identity); if (profile == null) { // redirect to (custom?) error page - throw new Exception($"Profile {rawUsername} not found"); + throw new Exception($"Profile {identity.RawName} not found"); } + + var twoFAIdentity = identity.Name; // canonicalizated name + Logger.API.Info($"Applying identity canonicalization: {identity.RawName}->{twoFAIdentity}"); - if (Configuration.Current.HasTwoFaIdentityAttribute && !string.IsNullOrEmpty(profile.TwoFAIdentity)) + if (Configuration.Current.HasTwoFaIdentityAttribute && !string.IsNullOrEmpty(profile.Custom2FAIdentity)) { - Logger.API.Info($"Applying 2fa identity attribute: {identity}->{profile.TwoFAIdentity}"); - identity = profile.TwoFAIdentity; + Logger.API.Info($"Applying 2fa identity attribute: {identity.RawName}->{profile.Custom2FAIdentity}"); + twoFAIdentity = profile.Custom2FAIdentity; } - var multiFactorAccessUrl = _api.CreateRequest(identity, rawUsername, postbackUrl, profile?.Phone); + var multiFactorAccessUrl = _api.CreateRequest(twoFAIdentity, identity.RawName, postbackUrl, profile?.Phone); return multiFactorAccessUrl; } } diff --git a/Services/ActiveDirectoryService.cs b/Services/ActiveDirectoryService.cs index 09d4db3..62ab387 100644 --- a/Services/ActiveDirectoryService.cs +++ b/Services/ActiveDirectoryService.cs @@ -1,4 +1,5 @@ using MultiFactor.IIS.Adapter.Extensions; +using MultiFactor.IIS.Adapter.Interop; using MultiFactor.IIS.Adapter.Services.Ldap; using MultiFactor.IIS.Adapter.Services.Ldap.Profile; using System; @@ -22,14 +23,14 @@ public ActiveDirectoryService(CacheAdapter cache, Logger logger) _config = Configuration.Current; } - public ILdapProfile GetProfile(string samAccountName) + public ILdapProfile GetProfile(LdapIdentity identity) { - if (samAccountName is null) + if (identity is null) { - throw new ArgumentNullException(nameof(samAccountName)); + throw new ArgumentNullException(nameof(identity)); } - var profile = _cache.GetProfile(samAccountName); + var profile = _cache.GetProfile(identity.RawName); if (profile != null) { return profile; @@ -39,19 +40,31 @@ public ILdapProfile GetProfile(string samAccountName) { try { - _logger.Info($"Try load profile from {domain}"); - using (var adapter = LdapConnectionAdapter.Create(domain, _logger)) + _logger.Info($"Try load {identity.RawName} profile from {domain}"); + UserSearchContext searchContext; + if (identity.HasNetbiosName()) + { + searchContext = new NetbiosService(_logger).ConvertToUpnUser(identity, domain); + } + else + { + _logger.Warn($"Something strange: user {identity.RawName} has not netbiosname. Identity: {identity.Name}, {identity.TypeName}, {identity.NetBiosName}"); + searchContext = new UserSearchContext(domain, identity.Name, identity.RawName); + } + + _logger.Info($"Start load user profile in context: {searchContext}"); + using (var adapter = LdapConnectionAdapter.Create(searchContext.Domain, _logger)) { - var loader = new ProfileLoader(adapter, _config); - profile = loader.Load(samAccountName); + var loader = new ProfileLoader(adapter, _config, _logger); + profile = loader.Load(searchContext.UserIdentity); if (profile == null) { continue; } - _logger.Info($"Profile loaded for user '{profile.SamAccountName}'"); + _logger.Info($"Profile loaded for user '{profile.RawUserName}'"); - _cache.SetProfile(samAccountName, profile); + _cache.SetProfile(identity.RawName, profile); return profile; } } @@ -63,6 +76,8 @@ public ILdapProfile GetProfile(string samAccountName) { _logger.Error(ex.ToString()); } + // very noisy, only for debug + // _logger.Info($"Get profile iteration for {domain} finished"); } return null; } @@ -70,21 +85,21 @@ public ILdapProfile GetProfile(string samAccountName) /// /// Only group members should 2fa /// - public bool ValidateMembership(string samAccountName) + public bool ValidateMembership(LdapIdentity identity) { - var cachedMembership = _cache.GetMembership(samAccountName); + var cachedMembership = _cache.GetMembership(identity.RawName); if (cachedMembership != null) { return cachedMembership.Value; } - var isMember = ValidateMembershipInternal(samAccountName); - _cache.SetMembership(samAccountName, isMember); + var isMember = ValidateMembershipInternal(identity); + _cache.SetMembership(identity.RawName, isMember); return isMember; } - private bool ValidateMembershipInternal(string samAccountName) + private bool ValidateMembershipInternal(LdapIdentity identity) { var groupName = _config.ActiveDirectory2FaGroup; @@ -111,14 +126,29 @@ private bool ValidateMembershipInternal(string samAccountName) return true; //group not exists, let unknown result will be true } - var searchFilter = $"(&(sAMAccountName={samAccountName})(memberOf:1.2.840.113556.1.4.1941:={groupDn}))"; - var response = adapter.Search(baseDn, searchFilter, SearchScope.Subtree, "DistinguishedName"); + _logger.Info($"Try validate membership {identity.RawName} profile from {domain} in {groupDn}"); + UserSearchContext searchContext; + if (identity.HasNetbiosName()) + { + searchContext = new NetbiosService(_logger).ConvertToUpnUser(identity, domain); + } + else + { + _logger.Warn($"Something strange: user {identity.RawName} has not netbiosname. Identity: {identity.Name}, {identity.TypeName}, {identity.NetBiosName}"); + searchContext = new UserSearchContext(domain, identity.Name, identity.RawName); + } + + var searchFilter = $"(&({searchContext.UserIdentity.TypeName}={searchContext.UserIdentity.Name})(memberOf:1.2.840.113556.1.4.1941:={groupDn}))"; + var response = adapter.Search(baseDn, searchFilter, SearchScope.Subtree, true, "DistinguishedName"); if (response.Entries.Count != 0) { + _logger.Info($"{identity.RawName} is member of {groupName}"); return true; } } + // very noisy, only for debug + // _logger.Info($"ValidateMembership iteration for {domain} finished"); } return false; } @@ -140,10 +170,18 @@ private bool ValidateMembershipInternal(string samAccountName) private string GetGroupDn(LdapConnectionAdapter adapter, string name, string baseDn) { var searchFilter = $"(&(objectCategory=group)(name={name}))"; - var response = adapter.Search(baseDn, searchFilter, SearchScope.Subtree, "DistinguishedName"); - return response.Entries.Count == 0 - ? null - : response.Entries[0].DistinguishedName; + var response = adapter.Search(baseDn, searchFilter, SearchScope.Subtree, true, "DistinguishedName"); + if(response.Entries.Count != 0) + { + var groupDn = response.Entries[0].DistinguishedName; + _logger.Info($"Group {name} was found:{groupDn}"); + return groupDn; + } + else + { + _logger.Info($"Group {name} was not found"); + return null; + } } } } \ No newline at end of file diff --git a/Services/AuthChecker.cs b/Services/AuthChecker.cs index 2e28e26..9b75ac9 100644 --- a/Services/AuthChecker.cs +++ b/Services/AuthChecker.cs @@ -1,4 +1,5 @@ -using System; +using MultiFactor.IIS.Adapter.Services.Ldap; +using System; using System.Web; namespace MultiFactor.IIS.Adapter.Services @@ -7,11 +8,13 @@ public class AuthChecker { private readonly HttpContextBase _context; private readonly TokenValidationService _validationService; + private readonly Logger _logger; - public AuthChecker(HttpContextBase context, TokenValidationService validationService) + public AuthChecker(HttpContextBase context, TokenValidationService validationService, Logger logger) { _context = context ?? throw new ArgumentNullException(nameof(context)); _validationService = validationService ?? throw new ArgumentNullException(nameof(validationService)); + _logger = logger; } public bool IsAuthenticated(string rawUsername) @@ -28,7 +31,8 @@ public bool IsAuthenticated(string rawUsername) return false; } - return Util.CanonicalizeUserName(userName) == Util.CanonicalizeUserName(rawUsername); + _logger.Info($"Сomparison netbios name of local user {rawUsername} and mf user {userName}"); + return rawUsername == userName; } } } \ No newline at end of file diff --git a/Services/Ldap/LdapConnectionAdapter.cs b/Services/Ldap/LdapConnectionAdapter.cs index 7d68af5..479c85b 100644 --- a/Services/Ldap/LdapConnectionAdapter.cs +++ b/Services/Ldap/LdapConnectionAdapter.cs @@ -42,7 +42,7 @@ public static LdapConnectionAdapter Create(string domain, Logger logger) return new LdapConnectionAdapter(conn, new FullyQualifiedDomainName(domain), logger); } - public SearchResponse Search(string baseDn, string filter, SearchScope scope, params string[] attributes) + public SearchResponse Search(string baseDn, string filter, SearchScope scope, bool chaseRefs, params string[] attributes) { if (string.IsNullOrEmpty(baseDn)) { @@ -61,6 +61,8 @@ public SearchResponse Search(string baseDn, string filter, SearchScope scope, pa var searchRequest = new SearchRequest(baseDn, filter, scope, attributes); + _connection.SessionOptions.ReferralChasing = chaseRefs ? ReferralChasingOptions.All : ReferralChasingOptions.None; + _logger.Info($"Sending search request with params:\r\nbase={baseDn}\r\nfilter={filter}\r\nscope={scope}\r\nattributes={string.Join(";", attributes)}"); return (SearchResponse)_connection.SendRequest(searchRequest); } diff --git a/Services/Ldap/LdapIdentity.cs b/Services/Ldap/LdapIdentity.cs new file mode 100644 index 0000000..5260df2 --- /dev/null +++ b/Services/Ldap/LdapIdentity.cs @@ -0,0 +1,96 @@ +using System; + +namespace MultiFactor.IIS.Adapter.Services.Ldap +{ + public class LdapIdentity + { + /// + /// Name we got from the authentication pipeline. Most often it is netbios + /// + public string RawName { get; private set; } + + /// + /// Normalized name. Can be used to search in the AD and as 2fa identity by default + /// + public string Name { get; private set; } + + /// + /// Most often it matches the RawName + /// + public string NetBiosName { get; private set; } = string.Empty; + + /// + /// Type of name in terms of AD + /// + public IdentityType Type { get; set; } + + /// + /// AD attribute name + /// + public string TypeName + { + get + { + switch (Type) + { + case IdentityType.SamAccountName: + return "sAMAccountName"; + case IdentityType.UserPrincipalName: + return "userPrincipalName"; + default: + return "name"; + } + } + } + + public static LdapIdentity Parse(string name) + { + if (string.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name)); + + var identity = name.ToLower(); + string netBiosName = string.Empty; + //remove DOMAIN\\ prefix + var type = IdentityType.SamAccountName; + var index = identity.IndexOf("\\"); + if (index > 0) + { + type = IdentityType.SamAccountName; + netBiosName = identity.Substring(0, index); + identity = identity.Substring(index + 1); + } + + // rare case + if (identity.Contains("@")) + { + type = IdentityType.UserPrincipalName; + } + + return new LdapIdentity + { + RawName = name, + Name = identity, + Type = type, + NetBiosName = netBiosName, + }; + } + + public bool HasNetbiosName() => !string.IsNullOrEmpty(NetBiosName); + + public LdapIdentity WithRawName(string rawName) + { + this.RawName = rawName; + return this; + } + } + + // from System.DirectoryServices.AccountManagement + public enum IdentityType + { + /// The identity is a Security Account Manager (SAM) name. + SamAccountName, + /// The identity is a name. + Name, + /// The identity is a User Principal Name (UPN). + UserPrincipalName, + } +} \ No newline at end of file diff --git a/Services/Ldap/NetbiosService.cs b/Services/Ldap/NetbiosService.cs new file mode 100644 index 0000000..89c9ac7 --- /dev/null +++ b/Services/Ldap/NetbiosService.cs @@ -0,0 +1,45 @@ +using MultiFactor.IIS.Adapter.Interop; +using System; + +namespace MultiFactor.IIS.Adapter.Services.Ldap +{ + public class NetbiosService + { + public readonly Logger _logger; + + public NetbiosService(Logger logger) + { + _logger = logger; + } + + public UserSearchContext ConvertToUpnUser(LdapIdentity user, string domain) + { + var upnUserName = ResolveUserByNetBios(user.RawName, user.NetBiosName, domain); + return upnUserName; + } + + private UserSearchContext ResolveUserByNetBios(string fullUserName, string netBiosName, string domain) + { + _logger.Info($"Trying to resolve domain by netbios {netBiosName}, user: {fullUserName}."); + try + { + using (var nameTranslator = new NameTranslator(domain, _logger)) + { + // first try a strict domain resolving method + var searchContext = nameTranslator.Translate(fullUserName); + if (!string.IsNullOrEmpty(searchContext.Domain)) + { + _logger.Info($"Success find {searchContext} by {fullUserName}"); + return searchContext; + } + throw new Exception("NetbiosName not found"); + } + } + catch (Exception e) + { + _logger.Warn($"Error during translate netbios name {fullUserName}"); + throw; + } + } + } +} \ No newline at end of file diff --git a/Services/Ldap/Profile/ILdapProfile.cs b/Services/Ldap/Profile/ILdapProfile.cs index 350a27e..cc3207b 100644 --- a/Services/Ldap/Profile/ILdapProfile.cs +++ b/Services/Ldap/Profile/ILdapProfile.cs @@ -2,8 +2,9 @@ { public interface ILdapProfile { - string SamAccountName { get; } - string TwoFAIdentity { get; } + string RawUserName { get; } + string FriendlyUserName { get; } + string Custom2FAIdentity { get; } string Phone { get; } } } \ No newline at end of file diff --git a/Services/Ldap/Profile/LdapProfile.cs b/Services/Ldap/Profile/LdapProfile.cs index 31b00bd..c5dfe54 100644 --- a/Services/Ldap/Profile/LdapProfile.cs +++ b/Services/Ldap/Profile/LdapProfile.cs @@ -6,13 +6,26 @@ namespace MultiFactor.IIS.Adapter.Services.Ldap.Profile { internal class LdapProfile : ILdapProfile { + public LdapIdentity BaseDn { get; } private readonly string _twoFaIdentityAttrName; private readonly string[] _phoneAttrs; private readonly Dictionary> _attrs = new Dictionary>(new AttributeKeyComparer()); - public string SamAccountName => GetAttr("sAMAccountName").First(); - public string TwoFAIdentity => GetAttr(_twoFaIdentityAttrName).FirstOrDefault(); + /// + /// Name we got from the authentication pipeline. Most often it is netbios + /// + public string RawUserName => BaseDn.RawName; + + /// + /// Normalized name. Can be used to search in the AD and as 2fa identity by default. + /// + public string FriendlyUserName => BaseDn.Name; + + /// + /// Use only if corresponding setting is specified in the config + /// + public string Custom2FAIdentity => GetAttr(_twoFaIdentityAttrName).FirstOrDefault(); public string Phone { @@ -30,22 +43,22 @@ public string Phone } } - public LdapProfile(string samAccountName, Configuration configuration) + public LdapProfile(LdapIdentity baseDn, Configuration configuration) { - if (string.IsNullOrWhiteSpace(samAccountName)) + if (baseDn == null) { - throw new ArgumentException($"'{nameof(samAccountName)}' cannot be null or whitespace.", nameof(samAccountName)); + throw new ArgumentException($"'{nameof(baseDn)}' cannot be null.", nameof(baseDn)); } if (configuration == null) { throw new ArgumentNullException(nameof(configuration)); } - - _attrs["sAMAccountName"] = new HashSet{ samAccountName }; + BaseDn = baseDn; + _attrs[baseDn.TypeName] = new HashSet{ baseDn.Name }; _twoFaIdentityAttrName = configuration.HasTwoFaIdentityAttribute ? configuration.TwoFaIdentityAttribute - : "sAMAccountName"; + : baseDn.TypeName; var phoneAttrs = new List { diff --git a/Services/Ldap/Profile/ProfileLoader.cs b/Services/Ldap/Profile/ProfileLoader.cs index 61d4c5b..d41c659 100644 --- a/Services/Ldap/Profile/ProfileLoader.cs +++ b/Services/Ldap/Profile/ProfileLoader.cs @@ -10,21 +10,23 @@ public class ProfileLoader { private readonly LdapConnectionAdapter _adapter; private readonly Configuration _config; + private readonly Logger _logger; - public ProfileLoader(LdapConnectionAdapter adapter, Configuration config) + public ProfileLoader(LdapConnectionAdapter adapter, Configuration config, Logger logger) { _adapter = adapter ?? throw new ArgumentNullException(nameof(adapter)); _config = config ?? throw new ArgumentNullException(nameof(config)); + _logger = logger; } - public ILdapProfile Load(string samAccountName) + public ILdapProfile Load(LdapIdentity user) { - if (string.IsNullOrWhiteSpace(samAccountName)) + if (string.IsNullOrWhiteSpace(user.Name)) { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(samAccountName)); + throw new ArgumentException("Value cannot be null or whitespace.", nameof(user)); } - var profile = new LdapProfile(samAccountName, _config); + var profile = new LdapProfile(user, _config); var queryAttributes = new List(); queryAttributes.AddRange(_config.PhoneAttributes); @@ -33,15 +35,38 @@ public ILdapProfile Load(string samAccountName) queryAttributes.Add(_config.TwoFaIdentityAttribute); } - var baseDn = _adapter.Domain.GetDn(); - var searchFilter = $"(&(sAMAccountName={samAccountName})(objectClass=user))"; + var baseDn = _adapter.Domain.GetDn(); - var response = _adapter.Search(baseDn, searchFilter, SearchScope.Subtree, queryAttributes.ToArray()); - if (response.Entries.Count == 0) + var searchFilter = $"(&(objectClass=user)({user.TypeName}={user.Name}))"; + + //only this domain + var response = _adapter.Search(baseDn, searchFilter, SearchScope.Subtree,false, queryAttributes.ToArray()); + + if (response.Entries.Count != 0) { + // very noisy log,only for debug + // _logger.Info($"Success search for {user.Name} in {baseDn} with filter {searchFilter}, {response.Entries.Count} entries"); + FillProfile(response, queryAttributes, profile); return profile; } + //with ReferralChasing + response = _adapter.Search(baseDn, searchFilter, SearchScope.Subtree, true, queryAttributes.ToArray()); + + if (response.Entries.Count != 0) + { + // very noisy log,only for debug + // _logger.Info($"Success referral search for {user} in {baseDn} with filter {searchFilter}, {response.Entries.Count} entries"); + FillProfile(response, queryAttributes, profile); + return profile; + } + + _logger.Info($"User {user} was not found in {baseDn}"); + return profile; + } + + private static void FillProfile(SearchResponse response, List queryAttributes, LdapProfile profile) + { var attributes = response.Entries[0].Attributes; foreach (var attr in queryAttributes) { @@ -51,8 +76,6 @@ public ILdapProfile Load(string samAccountName) profile.AddAttribute(attr, values); } - - return profile; } } } \ No newline at end of file diff --git a/Services/MfaApiRequestExecutor.cs b/Services/MfaApiRequestExecutor.cs index dfe4cf8..e21d723 100644 --- a/Services/MfaApiRequestExecutor.cs +++ b/Services/MfaApiRequestExecutor.cs @@ -1,4 +1,5 @@ using MultiFactor.IIS.Adapter.Extensions; +using MultiFactor.IIS.Adapter.Services.Ldap; using System; using System.Web; @@ -19,18 +20,20 @@ public MfaApiRequestExecutor(HttpContextBase context, AccessUrl accessUrl, Logge public void Execute(string postbackUrl, string appRootPath) { + var identity = LdapIdentity.Parse(_context.User.Identity.Name); try { - var multiFactorAccessUrl = _accessUrl.Get(_context.User.Identity.Name, postbackUrl); + _logger.Info($"Execute 2fa for {identity.RawName}"); + var multiFactorAccessUrl = _accessUrl.Get(identity, postbackUrl); _context.Response.Redirect(multiFactorAccessUrl, true); } catch (Exception ex) when (NeedToBypass(ex)) { _logger.Warn( - $"Bypassing the second factor for user '{_context.User.Identity.Name}' due to an API error '{ex}'. {Environment.NewLine}" + + $"Bypassing the second factor for user '{identity.RawName}' due to an API error '{ex}'. {Environment.NewLine}" + $"Bypass session duration: {Configuration.Current.ApiLifeCheckInterval.TotalMinutes} min"); _context.GetCacheAdapter() - .SetApiUnreachable(Util.CanonicalizeUserName(_context.User.Identity.Name), true); + .SetApiUnreachable(identity.RawName, true); _context.Response.Redirect(appRootPath, true); } catch (Exception ex) diff --git a/Services/TokenValidationService.cs b/Services/TokenValidationService.cs index 5247d09..e733139 100644 --- a/Services/TokenValidationService.cs +++ b/Services/TokenValidationService.cs @@ -71,6 +71,9 @@ public string VerifyToken(string jwt) //as is logged user without transformation var rawUserName = json[Constants.RAW_USER_NAME_CLAIM] as string; + + // very noisy, only for debug + // _logger.Info($"Token verification result: rawName={rawUserName} and sub={sub}"); return rawUserName ?? sub; } From 7223d9714fd9a0dd4449c91e7f329743b5ea29bd Mon Sep 17 00:00:00 2001 From: Vladimir Zimin Date: Fri, 11 Oct 2024 10:08:01 +0300 Subject: [PATCH 2/2] renaming --- Services/Ldap/Profile/LdapProfile.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Services/Ldap/Profile/LdapProfile.cs b/Services/Ldap/Profile/LdapProfile.cs index c5dfe54..aed7e48 100644 --- a/Services/Ldap/Profile/LdapProfile.cs +++ b/Services/Ldap/Profile/LdapProfile.cs @@ -6,7 +6,7 @@ namespace MultiFactor.IIS.Adapter.Services.Ldap.Profile { internal class LdapProfile : ILdapProfile { - public LdapIdentity BaseDn { get; } + private readonly LdapIdentity _identity; private readonly string _twoFaIdentityAttrName; private readonly string[] _phoneAttrs; @@ -15,12 +15,12 @@ internal class LdapProfile : ILdapProfile /// /// Name we got from the authentication pipeline. Most often it is netbios /// - public string RawUserName => BaseDn.RawName; + public string RawUserName => _identity.RawName; /// /// Normalized name. Can be used to search in the AD and as 2fa identity by default. /// - public string FriendlyUserName => BaseDn.Name; + public string FriendlyUserName => _identity.Name; /// /// Use only if corresponding setting is specified in the config @@ -43,22 +43,22 @@ public string Phone } } - public LdapProfile(LdapIdentity baseDn, Configuration configuration) + public LdapProfile(LdapIdentity identity, Configuration configuration) { - if (baseDn == null) + if (identity == null) { - throw new ArgumentException($"'{nameof(baseDn)}' cannot be null.", nameof(baseDn)); + throw new ArgumentException($"'{nameof(identity)}' cannot be null.", nameof(identity)); } if (configuration == null) { throw new ArgumentNullException(nameof(configuration)); } - BaseDn = baseDn; - _attrs[baseDn.TypeName] = new HashSet{ baseDn.Name }; + _identity = identity; + _attrs[identity.TypeName] = new HashSet{ identity.Name }; _twoFaIdentityAttrName = configuration.HasTwoFaIdentityAttribute ? configuration.TwoFaIdentityAttribute - : baseDn.TypeName; + : identity.TypeName; var phoneAttrs = new List {