From cd3edceb503baf52d526849b6e3d11fb7df2b607 Mon Sep 17 00:00:00 2001 From: "s.naidenov" Date: Mon, 6 Oct 2025 11:22:09 +0700 Subject: [PATCH] DEV-242 --- src/Server/LdapProxy.cs | 26 ++--- src/Services/LdapService.cs | 30 +++++- .../ActiveDirectoryMemberOfService.cs | 86 ++++++++++++++++ .../MemberOf/FreeIpaMemberOfService.cs | 97 +++++++++++++++++++ src/Services/MemberOf/IMemberOfService.cs | 9 ++ 5 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 src/Services/MemberOf/ActiveDirectoryMemberOfService.cs create mode 100644 src/Services/MemberOf/FreeIpaMemberOfService.cs create mode 100644 src/Services/MemberOf/IMemberOfService.cs diff --git a/src/Server/LdapProxy.cs b/src/Server/LdapProxy.cs index 71c1ee0..1b78ce8 100644 --- a/src/Server/LdapProxy.cs +++ b/src/Server/LdapProxy.cs @@ -9,6 +9,7 @@ using Serilog; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Sockets; @@ -230,15 +231,14 @@ private async Task DataExchange(TcpClient source, Stream sourceStream, TcpClient if (_clientConfig.CheckUserGroups()) { - profile.MemberOf = await _ldapService.GetAllGroups(_serverStream, profile, _clientConfig); - //check ACL if (_clientConfig.ActiveDirectoryGroup.Any()) { - var accessGroup = _clientConfig.ActiveDirectoryGroup.FirstOrDefault(group => IsMemberOf(profile, group)); - if (accessGroup != null) + var memberOfResult = await IsMemberOfAny(profile, _clientConfig.ActiveDirectoryGroup); + + if (memberOfResult.IsMember) { - _logger.Debug($"User '{{user:l}}' is member of '{accessGroup.Trim()}' access group in {profile.BaseDn}", _userName); + _logger.Debug($"User '{{user:l}}' is member of '{memberOfResult.Group.Trim()}' access group in {profile.BaseDn}", _userName); } else { @@ -259,10 +259,10 @@ private async Task DataExchange(TcpClient source, Stream sourceStream, TcpClient //check if mfa is mandatory if (_clientConfig.ActiveDirectory2FaGroup.Any()) { - var mfaGroup = _clientConfig.ActiveDirectory2FaGroup.FirstOrDefault(group => IsMemberOf(profile, group)); - if (mfaGroup != null) + var memberOfResult = await IsMemberOfAny(profile, _clientConfig.ActiveDirectory2FaGroup); + if (memberOfResult.IsMember) { - _logger.Debug($"User '{{user:l}}' is member of '{mfaGroup.Trim()}' 2FA group in {profile.BaseDn}", _userName); + _logger.Debug($"User '{{user:l}}' is member of '{memberOfResult.Group.Trim()}' 2FA group in {profile.BaseDn}", _userName); } else { @@ -274,10 +274,10 @@ private async Task DataExchange(TcpClient source, Stream sourceStream, TcpClient //check of mfa is not mandatory if (_clientConfig.ActiveDirectory2FaBypassGroup.Any() && !bypass) { - var bypassGroup = _clientConfig.ActiveDirectory2FaBypassGroup.FirstOrDefault(group => IsMemberOf(profile, group)); - if (bypassGroup != null) + var memberOfResult = await IsMemberOfAny(profile, _clientConfig.ActiveDirectory2FaBypassGroup); + if (memberOfResult.IsMember) { - _logger.Information($"User '{{user:l}}' is member of '{bypassGroup.Trim()}' 2FA bypass group in {profile.BaseDn}", _userName); + _logger.Information($"User '{{user:l}}' is member of '{memberOfResult.Group.Trim()}' 2FA bypass group in {profile.BaseDn}", _userName); bypass = true; } else @@ -453,9 +453,9 @@ private bool IsServiceAccount(string userName) return false; } - private bool IsMemberOf(LdapProfile profile, string group) + private async Task IsMemberOfAny(LdapProfile profile, IEnumerable groups) { - return profile.MemberOf?.Any(g => g.ToLower() == group.ToLower().Trim()) ?? false; + return await _ldapService.IsMemberOf(_serverStream, profile, _clientConfig, groups); } } diff --git a/src/Services/LdapService.cs b/src/Services/LdapService.cs index b4154ee..556a65f 100644 --- a/src/Services/LdapService.cs +++ b/src/Services/LdapService.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using MultiFactor.Ldap.Adapter.Services.MemberOf; namespace MultiFactor.Ldap.Adapter.Services { @@ -367,7 +368,7 @@ public async Task LoadProfile(Stream ldapConnectedStream, string us mailEntries.Add(entry); break; case "memberOf": - profile.MemberOf.AddRange(entry.Values.Select(v => DnToCn(v))); + profile.MemberOf.AddRange(entry.Values); break; } } @@ -408,6 +409,29 @@ public async Task> GetAllGroups(Stream ldapConnectedStream, LdapPro return groups; } + public async Task IsMemberOf(Stream ldapConnectedStream, LdapProfile profile, ClientConfiguration clientConfiguration, IEnumerable groupDns) + { + if (!clientConfiguration.LoadActiveDirectoryNestedGroups) + { + var intersections = profile.MemberOf?.Select(FormatDn).Intersect(groupDns.Select(FormatDn))?.ToList() ?? new List(); + var isMember = intersections.Any(); + var group = intersections.FirstOrDefault(); + return new MemberOfResult(isMember, group); + } + + IMemberOfService memberShipService = string.IsNullOrWhiteSpace(clientConfiguration.LdapBaseDn) ? new ActiveDirectoryMemberOfService() : new FreeIpaMemberOfService(); + foreach (var group in groupDns) + { + var isMemberOf = await memberShipService.IsMemberOf(ldapConnectedStream, profile, group, _messageId++); + if (!isMemberOf) + continue; + + return new MemberOfResult(true, group); + } + + return new MemberOfResult(false, string.Empty); + } + #endregion private IEnumerable GetGroups(LdapPacket packet) @@ -546,5 +570,9 @@ private class LdapSearchResultEntry public string Name { get; set; } public IList Values { get; set; } } + + public static string FormatDn(string dn) => dn.ToLower().Replace(" ", string.Empty); } + + public record MemberOfResult(bool IsMember, string Group); } \ No newline at end of file diff --git a/src/Services/MemberOf/ActiveDirectoryMemberOfService.cs b/src/Services/MemberOf/ActiveDirectoryMemberOfService.cs new file mode 100644 index 0000000..e8401e8 --- /dev/null +++ b/src/Services/MemberOf/ActiveDirectoryMemberOfService.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MultiFactor.Ldap.Adapter.Core; + +namespace MultiFactor.Ldap.Adapter.Services.MemberOf; + +public class ActiveDirectoryMemberOfService : IMemberOfService +{ + public async Task IsMemberOf(Stream ldapConnectedStream, LdapProfile profile, string groupDn, int messageId) + { + var filter = GetFilter(groupDn); + var request = BuildMemberOfRequest(profile.Dn, filter, messageId); + var requestData = request.GetBytes(); + await ldapConnectedStream.WriteAsync(requestData, 0, requestData.Length); + var users = new List(); + LdapPacket packet; + while ((packet = await LdapPacket.ParsePacket(ldapConnectedStream)) != null) + { + users.AddRange(GetSearchResult(packet)); + } + + return users.Any(); + } + + private LdapAttribute[] GetFilter(string groupDn) + { + return new[] + { + new LdapAttribute((byte)LdapFilterChoice.extensibleMatch) + { + ChildAttributes = + { + new LdapAttribute(1, "1.2.840.113556.1.4.1941"), + new LdapAttribute(2, "memberof"), + new LdapAttribute(3, groupDn), + new LdapAttribute(4, (byte)0) + } + } + }; + } + + private LdapPacket BuildMemberOfRequest(string userName, LdapAttribute[] memberFilter, int messageId) + { + var packet = new LdapPacket(messageId); + + var searchRequest = new LdapAttribute(LdapOperation.SearchRequest); + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.OctetString, userName)); //base dn + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Enumerated, (byte)2)); //scope: subtree + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Enumerated, (byte)0)); //aliases: never + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Integer, (byte)0)); //size limit: unset + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Integer, (byte)60)); //time limit: 60 + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Boolean, true)); //typesOnly: true + + foreach (var attribute in memberFilter) + { + searchRequest.ChildAttributes.Add(attribute); + } + + packet.ChildAttributes.Add(searchRequest); + + var attrList = new LdapAttribute(UniversalDataType.Sequence); + attrList.ChildAttributes.Add(new LdapAttribute(UniversalDataType.OctetString, "distinguishedName")); + + searchRequest.ChildAttributes.Add(attrList); + + return packet; + } + + private IEnumerable GetSearchResult(LdapPacket packet) + { + var searchResults = new List(); + + foreach (var searchResultEntry in packet.ChildAttributes.FindAll(attr => attr.LdapOperation == LdapOperation.SearchResultEntry)) + { + if (searchResultEntry.ChildAttributes.Count > 0) + { + var result = searchResultEntry.ChildAttributes[0].GetValue(); + searchResults.Add(result); + } + } + + return searchResults; + } +} \ No newline at end of file diff --git a/src/Services/MemberOf/FreeIpaMemberOfService.cs b/src/Services/MemberOf/FreeIpaMemberOfService.cs new file mode 100644 index 0000000..8d26407 --- /dev/null +++ b/src/Services/MemberOf/FreeIpaMemberOfService.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using MultiFactor.Ldap.Adapter.Core; + +namespace MultiFactor.Ldap.Adapter.Services.MemberOf; + +public class FreeIpaMemberOfService : IMemberOfService +{ + private List _groups = new List(); + + public async Task IsMemberOf(Stream ldapConnectedStream, LdapProfile profile, string groupDn, int messageId) + { + var groups = await GetUserGroups(ldapConnectedStream, profile, messageId); + return groups.Any(g => g == LdapService.FormatDn(groupDn)); + } + + private async Task> GetUserGroups(Stream ldapConnectedStream, LdapProfile profile, int messageId) + { + if (_groups.Count > 0) + return _groups; + + var filter = GetFilter(profile.Dn); + var request = BuildMemberOfRequest(profile.Dn, filter, messageId); + var requestData = request.GetBytes(); + await ldapConnectedStream.WriteAsync(requestData, 0, requestData.Length); + LdapPacket packet; + while ((packet = await LdapPacket.ParsePacket(ldapConnectedStream)) != null) + { + _groups.AddRange(GetSearchResult(packet)); + } + + return _groups; + } + + private LdapPacket BuildMemberOfRequest(string userName, LdapAttribute[] memberFilter, int messageId) + { + var packet = new LdapPacket(messageId); + + var baseDn = LdapProfile.GetBaseDn(userName); + + var searchRequest = new LdapAttribute(LdapOperation.SearchRequest); + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.OctetString, baseDn)); //base dn + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Enumerated, (byte)2)); //scope: subtree + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Enumerated, (byte)0)); //aliases: never + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Integer, (byte)0)); //size limit: unset + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Integer, (byte)60)); //time limit: 60 + searchRequest.ChildAttributes.Add(new LdapAttribute(UniversalDataType.Boolean, true)); //typesOnly: true + + foreach (var attribute in memberFilter) + { + searchRequest.ChildAttributes.Add(attribute); + } + + packet.ChildAttributes.Add(searchRequest); + + var attrList = new LdapAttribute(UniversalDataType.Sequence); + attrList.ChildAttributes.Add(new LdapAttribute(UniversalDataType.OctetString, "distinguishedName")); + + searchRequest.ChildAttributes.Add(attrList); + + return packet; + } + + + private LdapAttribute[] GetFilter(string userName) + { + return new[] + { + new LdapAttribute((byte)LdapFilterChoice.equalityMatch) + { + ChildAttributes = + { + new LdapAttribute(UniversalDataType.OctetString, "member"), + new LdapAttribute(UniversalDataType.OctetString, userName) + } + } + }; + } + + private IEnumerable GetSearchResult(LdapPacket packet) + { + var searchResults = new List(); + + foreach (var searchResultEntry in packet.ChildAttributes.FindAll(attr => attr.LdapOperation == LdapOperation.SearchResultEntry)) + { + if (searchResultEntry.ChildAttributes.Count > 0) + { + var result = searchResultEntry.ChildAttributes[0].GetValue(); + searchResults.Add(LdapService.FormatDn(result)); + } + } + + return searchResults; + } +} \ No newline at end of file diff --git a/src/Services/MemberOf/IMemberOfService.cs b/src/Services/MemberOf/IMemberOfService.cs new file mode 100644 index 0000000..ea548fb --- /dev/null +++ b/src/Services/MemberOf/IMemberOfService.cs @@ -0,0 +1,9 @@ +using System.IO; +using System.Threading.Tasks; + +namespace MultiFactor.Ldap.Adapter.Services.MemberOf; + +public interface IMemberOfService +{ + Task IsMemberOf(Stream ldapConnectedStream, LdapProfile profile, string groupDn, int messageId); +} \ No newline at end of file