File: Internal\LdapAdapter.cs
Web Access
Project: ..\..\..\src\aspnetcore\src\Security\Authentication\Negotiate\src\Microsoft.AspNetCore.Authentication.Negotiate.csproj (Microsoft.AspNetCore.Authentication.Negotiate)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers.Binary;
using System.Diagnostics;
using System.DirectoryServices.Protocols;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Authentication.Negotiate;
 
internal static partial class LdapAdapter
{
    // internal for testing
    internal const string RequirePrimarySidClaimSwitchName = "Microsoft.AspNetCore.Authentication.Negotiate.RequirePrimarySidClaim";
    public static bool RequirePrimarySidClaim { get; } = AppContext.TryGetSwitch(RequirePrimarySidClaimSwitchName, out var enabled) && enabled;
 
    // Restores pre-fix behaviour: emit role claims for groups that are not security-enabled.
    private const string AllowNonSecurityEnabledGroupClaimsSwitch =
        "Microsoft.AspNetCore.Authentication.Negotiate.AllowNonSecurityEnabledGroupClaims";
 
    private static readonly bool AllowNonSecurityEnabledGroupClaims =
        AppContext.TryGetSwitch(AllowNonSecurityEnabledGroupClaimsSwitch, out var allow) && allow;
 
    [GeneratedRegex(@"(?<![^\\]\\),")]
    internal static partial Regex DistinguishedNameSeparatorRegex { get; }
 
    public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdentity identity, ILogger logger)
    {
        var user = identity.Name!;
        var userAccountNameIndex = user.IndexOf('@');
 
        // Without a realm in the name (no '@'), we cannot tell which directory the principal came from.
        // The local part alone is not a safe LDAP lookup key — sAMAccountName is unique only within a single domain,
        // so any same-named account in the configured domain would be returned. 
        if (userAccountNameIndex == -1)
        {
            logger.LogDebug(
                "Skipping LDAP claim resolution for '{User}': its realm cannot be verified against the configured LDAP domain '{Domain}'.",
                user, settings.Domain);
 
            return;
        }
 
        // With a realm that doesn't match settings.Domain the principal authenticated through a foreign Kerberos realm
        // should NOT be trusted by the application's realm.
        var realm = user.AsSpan(userAccountNameIndex + 1);
        if (!realm.Equals(settings.Domain.AsSpan(), StringComparison.OrdinalIgnoreCase))
        {
            logger.LogDebug(
                "Skipping LDAP claim resolution for '{User}': principal realm does not match the configured LDAP domain '{Domain}'. Cross-domain claim resolution is not supported.",
                user, settings.Domain);
 
            return;
        }
 
        var userAccountName = user[..userAccountNameIndex];
 
        // cache key should have sAMAccountName and SID to uniquely identify user
        var identitySid = identity.FindFirst(ClaimTypes.PrimarySid)?.Value;
        object cacheKey = identitySid is null ? user : (user, identitySid);
 
        if (settings.ClaimsCache == null)
        {
            settings.ClaimsCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = settings.ClaimsCacheSize });
        }
 
        if (settings.ClaimsCache.TryGetValue<IEnumerable<string>>(cacheKey, out var cachedClaims) && cachedClaims is not null)
        {
            foreach (var claim in cachedClaims)
            {
                identity.AddClaim(new Claim(identity.RoleClaimType, claim));
            }
 
            return;
        }
 
        var distinguishedName = settings.Domain.Split('.').Select(name => $"dc={name}").Aggregate((a, b) => $"{a},{b}");
        var retrievedClaims = new List<string>();
 
        var filter = $"(&(objectClass=user)(sAMAccountName={EscapeLdapFilterValue(userAccountName)}))"; // This is using ldap search query language, it is looking on the server for someUser
        var searchRequest = new SearchRequest(distinguishedName, filter, SearchScope.Subtree);
 
        Debug.Assert(settings.LdapConnection != null);
        var searchResponse = (SearchResponse)await Task<DirectoryResponse>.Factory.FromAsync(
            settings.LdapConnection.BeginSendRequest!,
            settings.LdapConnection.EndSendRequest,
            searchRequest,
            PartialResultProcessing.NoPartialResultSupport,
            null);
 
        if (searchResponse.Entries.Count > 0)
        {
            if (searchResponse.Entries.Count > 1 && logger.IsEnabled(LogLevel.Warning))
            {
                logger.LogWarning($"More than one response received for query: {filter} with distinguished name: {distinguishedName}");
            }
 
            var userFound = searchResponse.Entries[0]; //Get the object that was found on ldap
 
            // Verify the objectSid from the LDAP entry matches the SID of the Kerberos-authenticated principal
            var ldapObjectSidAttr = userFound.Attributes["objectSid"];
            var ldapObjectSidBytes = ldapObjectSidAttr?.Count > 0 ? (byte[])ldapObjectSidAttr[0] : null;
            if (!VerifyLdapSidMatchesKerberosPrincipal(ldapObjectSidBytes, identitySid, user, logger, RequirePrimarySidClaim))
            {
                return;
            }
 
            var memberof = userFound.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
 
            foreach (var group in memberof)
            {
                // Example distinguished name: CN=TestGroup,DC=KERB,DC=local
                var groupDN = $"{Encoding.UTF8.GetString((byte[])group)}";
                var groupCN = DistinguishedNameSeparatorRegex.Split(groupDN)[0].Substring("CN=".Length);
 
                // Resolved via GetGroups even when nesting is disabled, so the security-enabled
                // check applies to directly held groups too.
                GetGroups(settings.LdapConnection, identity, distinguishedName, groupDN, groupCN, logger, retrievedClaims, new HashSet<string>(), AllowNonSecurityEnabledGroupClaims, !settings.IgnoreNestedGroups);
            }
 
            var entrySize = user.Length * 2; //Approximate the size of stored key in memory cache.
            foreach (var claim in retrievedClaims)
            {
                identity.AddClaim(new Claim(identity.RoleClaimType, claim));
                entrySize += claim.Length * 2; //Approximate the size of stored value in memory cache.
            }
 
            settings.ClaimsCache.Set(cacheKey,
                retrievedClaims,
                new MemoryCacheEntryOptions()
                    .SetSize(entrySize)
                    .SetSlidingExpiration(settings.ClaimsCacheSlidingExpiration)
                    .SetAbsoluteExpiration(settings.ClaimsCacheAbsoluteExpiration));
        }
        else if (logger.IsEnabled(LogLevel.Warning))
        {
            logger.LogWarning($"No response received for query: {filter} with distinguished name: {distinguishedName}");
        }
    }
 
    private static void GetGroups(LdapConnection connection, ClaimsIdentity principal, string distinguishedName, string groupDN, string groupCN, ILogger logger, IList<string> retrievedClaims, HashSet<string> processedGroups, bool allowNonSecurityEnabledGroupClaims, bool resolveNestedGroups)
    {
        // Look up the group entry by its distinguished name using base scope:
        // a base-scope read against the DN is the only LDAP operation that
        // returns exactly the intended group object.
        var searchRequest = new SearchRequest(groupDN, "(objectClass=group)", SearchScope.Base, "memberof", "groupType");
        SearchResponse searchResponse;
        try
        {
            searchResponse = (SearchResponse)connection.SendRequest(searchRequest);
        }
        catch (DirectoryOperationException ex) when (ex.Response is SearchResponse r && r.ResultCode == ResultCode.NoSuchObject)
        {
            // Stale memberOf reference: group no longer exists. Stop traversal of this branch.
            logger.LogDebug("Stale memberOf reference: group with distinguished name '{GroupDN}' no longer exists; stopping traversal.", groupDN);
            return;
        }
 
        if (searchResponse.Entries.Count > 0)
        {
            var group = searchResponse.Entries[0]; // Get the object that was found on ldap
 
            var groupTypeAttr = group.Attributes["groupType"];
            var groupTypeValue = groupTypeAttr is { Count: > 0 } ? groupTypeAttr[0] : null;
 
            if (ShouldEmitRoleClaim(groupTypeValue, allowNonSecurityEnabledGroupClaims))
            {
                retrievedClaims.Add(groupCN);
            }
            else
            {
                logger.LogDebug("LdapAdapter omitted distribution group '{GroupDN}' from role claims.", groupDN);
            }
 
            if (!resolveNestedGroups)
            {
                return;
            }
 
            processedGroups.Add(groupDN);
 
            var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
            if (memberof != null)
            {
                foreach (var member in memberof)
                {
                    var nestedGroupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
                    var nestedGroupCN = DistinguishedNameSeparatorRegex.Split(nestedGroupDN)[0].Substring("CN=".Length);
 
                    if (processedGroups.Contains(nestedGroupDN))
                    {
                        // We need to keep track of already processed groups because circular references are possible with AD groups
                        return;
                    }
 
                    GetGroups(connection, principal, distinguishedName, nestedGroupDN, nestedGroupCN, logger, retrievedClaims, processedGroups, allowNonSecurityEnabledGroupClaims, resolveNestedGroups);
                }
            }
        }
    }
 
    /// <summary>
    /// Verifies that the <paramref name="ldapObjectSidBytes"/> from the LDAP search result
    /// represent the same account as the Kerberos-authenticated principal identified by <paramref name="identitySid"/>.
    /// </summary>
    // internal for testing
    internal static bool VerifyLdapSidMatchesKerberosPrincipal(byte[]? ldapObjectSidBytes, string? identitySid, string user, ILogger logger, bool requirePrimarySidClaim = false)
    {
        if (identitySid is null)
        {
            if (requirePrimarySidClaim)
            {
                logger.LogDebug("Skipping LDAP claim resolution for '{User}': the authenticated identity has no PrimarySid claim.", user);
                return false;
            }
 
            // No PrimarySid claim: either the runtime doesn't populate it, or the Kerberos ticket legitimately carried no PAC (e.g. non-AD KDC, NTLM).
            return true;
        }
 
        if (ldapObjectSidBytes is null)
        {
            // Valid AD user object should have objectSid attribute
            logger.LogDebug("LDAP entry for '{User}' has no objectSid attribute; skipping claim resolution to prevent potential name confusion.", user);
            return false;
        }
 
        var ldapSid = ParseSidBytesToString(ldapObjectSidBytes);
        if (ldapSid is null)
        {
            logger.LogDebug("LDAP entry for '{User}' has an objectSid attribute that could not be parsed; skipping claim resolution to prevent potential name confusion.", user);
            return false;
        }
 
        if (!string.Equals(ldapSid, identitySid, StringComparison.OrdinalIgnoreCase))
        {
            logger.LogWarning("LDAP account SID '{LdapSid}' for query '{User}' does not match the Kerberos-authenticated principal SID '{IdentitySid}'. ", ldapSid, user, identitySid);
            return false;
        }
 
        return true;
    }
 
    /// <summary>
    /// Parses a binary-encoded Windows SID into its canonical string form (e.g. <c>S-1-5-21-…-500</c>).
    /// Returns <see langword="null"/> if <paramref name="bytes"/> is malformed.
    /// </summary>
    /// <remarks>
    /// The binary format is defined in MS-DTYP §2.4.2:
    /// <list type="bullet">
    ///   <item>Byte 0: Revision</item>
    ///   <item>Byte 1: SubAuthorityCount (N)</item>
    ///   <item>Bytes 2–7: IdentifierAuthority (6 bytes, big-endian)</item>
    ///   <item>Bytes 8+: N sub-authority values (4 bytes each, little-endian)</item>
    /// </list>
    /// This helper is cross-platform and avoids <c>System.Security.Principal.SecurityIdentifier</c>
    /// which is Windows-only.
    /// </remarks>
    // internal for testing
    internal static string? ParseSidBytesToString(byte[] bytes)
    {
        if (bytes.Length < 8)
        {
            return null;
        }
 
        var revision = bytes[0];
        var subAuthorityCount = bytes[1];
 
        if (bytes.Length < 8 + (subAuthorityCount * 4))
        {
            return null;
        }
 
        long authority = 0;
        for (var i = 2; i < 8; i++)
        {
            authority = (authority << 8) | bytes[i];
        }
 
        var sb = new StringBuilder();
        sb.Append(CultureInfo.InvariantCulture, $"S-{revision}-{authority}");
 
        for (var i = 0; i < subAuthorityCount; i++)
        {
            var subAuth = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(8 + (i * 4)));
            sb.Append(CultureInfo.InvariantCulture, $"-{subAuth}");
        }
 
        return sb.ToString();
    }
 
    /// <summary>
    /// Determines whether a group should produce a role claim, honouring the
    /// AllowNonSecurityEnabledGroupClaims opt-out.
    /// </summary>
    // internal for testing
    internal static bool ShouldEmitRoleClaim(object? groupTypeValue, bool allowNonSecurityEnabledGroupClaims)
        => IsSecurityGroup(groupTypeValue) || allowNonSecurityEnabledGroupClaims;
 
    /// <summary>
    /// Evaluates the groupType attribute value (byte[] or string depending on LDAP client implementation)
    /// to determine whether a group is a Security Group (0x80000000).
    /// </summary>
    // internal for testing
    internal static bool IsSecurityGroup(object? groupTypeVal)
    {
        if (groupTypeVal == null)
        {
            return true;
        }
 
        var typeString = groupTypeVal is byte[] b ? Encoding.UTF8.GetString(b) : groupTypeVal.ToString();
        if (int.TryParse(typeString, out int groupType))
        {
            const int GROUP_TYPE_SECURITY_ENABLED = unchecked((int)0x80000000);
            return (groupType & GROUP_TYPE_SECURITY_ENABLED) == GROUP_TYPE_SECURITY_ENABLED;
        }
 
        return true; // fail open if we cannot parse
    }
 
    /// <summary>
    /// Escapes special characters in a value used in an LDAP search filter per RFC 4515
    /// https://datatracker.ietf.org/doc/html/rfc4515#section-3
    /// </summary>
    // internal for testing
    internal static string EscapeLdapFilterValue(string value)
    {
        var sb = new StringBuilder(value.Length);
        foreach (var c in value)
        {
            switch (c)
            {
                case '\\':
                    sb.Append(@"\5c");
                    break;
                case '*':
                    sb.Append(@"\2a");
                    break;
                case '(':
                    sb.Append(@"\28");
                    break;
                case ')':
                    sb.Append(@"\29");
                    break;
                case '\0':
                    sb.Append(@"\00");
                    break;
                default:
                    sb.Append(c);
                    break;
            }
        }
 
        return sb.ToString();
    }
}