Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions src/Security/Authentication/Negotiate/src/Internal/LdapAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Authentication.Negotiate
Expand All @@ -15,8 +17,26 @@ internal static class LdapAdapter
public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdentity identity, ILogger logger)
{
var user = identity.Name;
var userAccountName = user.Substring(0, user.IndexOf('@'));
var userAccountNameIndex = user.IndexOf('@');
var userAccountName = userAccountNameIndex == -1 ? user : user.Substring(0, userAccountNameIndex);

if (settings.ClaimsCache == null)
{
settings.ClaimsCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = settings.ClaimsCacheSize });
}

if (settings.ClaimsCache.TryGetValue<IEnumerable<string>>(user, out var cachedClaims))
{
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={userAccountName}))"; // This is using ldap search query language, it is looking on the server for someUser
var searchRequest = new SearchRequest(distinguishedName, filter, SearchScope.Subtree, null);
Expand Down Expand Up @@ -45,24 +65,38 @@ public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdenti

if (!settings.IgnoreNestedGroups)
{
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger);
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger, retrievedClaims);
}
else
{
AddRole(identity, groupCN);
retrievedClaims.Add(groupCN);
}
}

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(user,
retrievedClaims,
new MemoryCacheEntryOptions()
.SetSize(entrySize)
.SetSlidingExpiration(settings.ClaimsCacheSlidingExpiration)
.SetAbsoluteExpiration(settings.ClaimsCacheAbsoluteExpiration));
}
else
{
logger.LogWarning($"No response received for query: {filter} with distinguished name: {distinguishedName}");
}
}

private static void GetNestedGroups(LdapConnection connection, ClaimsIdentity principal, string distinguishedName, string groupCN, ILogger logger)
private static void GetNestedGroups(LdapConnection connection, ClaimsIdentity principal, string distinguishedName, string groupCN, ILogger logger, IList<string> retrievedClaims)
{
var filter = $"(&(objectClass=group)(sAMAccountName={groupCN}))"; // This is using ldap search query language, it is looking on the server for someUser
var searchRequest = new SearchRequest(distinguishedName, filter, System.DirectoryServices.Protocols.SearchScope.Subtree, null);
var searchRequest = new SearchRequest(distinguishedName, filter, SearchScope.Subtree, null);
var searchResponse = (SearchResponse)connection.SendRequest(searchRequest);

if (searchResponse.Entries.Count > 0)
Expand All @@ -74,7 +108,7 @@ private static void GetNestedGroups(LdapConnection connection, ClaimsIdentity pr

var group = searchResponse.Entries[0]; //Get the object that was found on ldap
string name = group.DistinguishedName;
AddRole(principal, name);
retrievedClaims.Add(name);

var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
if (memberof != null)
Expand All @@ -83,15 +117,10 @@ private static void GetNestedGroups(LdapConnection connection, ClaimsIdentity pr
{
var groupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
var nestedGroupCN = groupDN.Split(',')[0].Substring("CN=".Length);
GetNestedGroups(connection, principal, distinguishedName, nestedGroupCN, logger);
GetNestedGroups(connection, principal, distinguishedName, nestedGroupCN, logger, retrievedClaims);
}
}
}
}

private static void AddRole(ClaimsIdentity identity, string role)
{
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
}
}
20 changes: 20 additions & 0 deletions src/Security/Authentication/Negotiate/src/LdapSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.DirectoryServices.Protocols;
using Microsoft.Extensions.Caching.Memory;

namespace Microsoft.AspNetCore.Authentication.Negotiate
{
Expand Down Expand Up @@ -56,6 +57,25 @@ public class LdapSettings
/// </summary>
public LdapConnection LdapConnection { get; set; }

/// <summary>
/// The sliding expiration that should be used for entries in the cache for user claims, defaults to 10 minutes.
/// This is a sliding expiration that will extend each time claims for a user is retrieved.
/// </summary>
public TimeSpan ClaimsCacheSlidingExpiration { get; set; } = TimeSpan.FromMinutes(10);

/// <summary>
/// The absolute expiration that should be used for entries in the cache for user claims, defaults to 60 minutes.
/// This is an absolute expiration that starts when a claims for a user is retrieved for the first time.
/// </summary>
public TimeSpan ClaimsCacheAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(60);

/// <summary>
/// The maximum size of the claim results cache, defaults to 100 MB.
/// </summary>
public int ClaimsCacheSize { get; set; } = 100 * 1024 * 1024;

internal MemoryCache ClaimsCache { get; set; }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different from the certificate validation cache in that it's not stored in DI. It might be a bit odd to have the cache in LdapSettings but I'm not sure if there's any value in putting this cache in DI.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main value of putting it in DI is if you want to make it easy for them to plug in their own cache implementation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While that's true, I'm not sure we'd want that kind of flexibility here at the moment. I don't see it as particularly useful in this scenario.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine, I was just pointing out what advantages putting it in DI has


public void Validate()
{
if (EnableLdapClaimResolution)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="Microsoft.AspNetCore.Testing" />
<Reference Include="Microsoft.AspNetCore.TestHost" />
<Reference Include="Microsoft.Extensions.Caching.Memory" />
<Reference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;
Expand Down Expand Up @@ -208,6 +208,28 @@ public async Task AuthHeaderAfterNtlmCompleted_ReAuthenticates(bool persist)
await NtlmStage1And2Auth(server, testConnection);
}

[Fact]
public async Task RBACClaimsRetrievedFromCacheAfterKerberosCompleted()
{
var claimsCache = new MemoryCache(new MemoryCacheOptions());
claimsCache.Set("name", new string[] { "CN=Domain Admins,CN=Users,DC=domain,DC=net" });
NegotiateOptions negotiateOptions = null;
using var host = await CreateHostAsync(options =>
{
options.EnableLdap(ldapSettings =>
{
ldapSettings.Domain = "domain.NET";
ldapSettings.ClaimsCache = claimsCache;
ldapSettings.EnableLdapClaimResolution = false; // This disables binding to the LDAP connection on startup
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I expected, this is really hacky but it works.

});
negotiateOptions = options;
});
var server = host.GetTestServer();
var testConnection = new TestConnection();
negotiateOptions.EnableLdap(_ => { }); // Forcefully re-enable ldap claims resolution to trigger RBAC claims retrieval from cache
await AuthenticateAndRetrieveRBACClaims(server, testConnection);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand Down Expand Up @@ -304,6 +326,12 @@ public async Task OtherError_Throws()
var ex = await Assert.ThrowsAsync<Exception>(() => SendAsync(server, "/404", testConnection, "Negotiate OtherError"));
Assert.Equal("A test other error occurred", ex.Message);
}
private static async Task AuthenticateAndRetrieveRBACClaims(TestServer server, TestConnection testConnection)
{
var result = await SendAsync(server, "/AuthenticateAndRetrieveRBACClaims", testConnection, "Negotiate ClientKerberosBlob");
Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode);
Assert.Equal("Negotiate ServerKerberosBlob", result.Response.Headers[HeaderNames.WWWAuthenticate]);
}

// Single Stage
private static async Task KerberosAuth(TestServer server, TestConnection testConnection)
Expand Down Expand Up @@ -408,6 +436,24 @@ private static void ConfigureEndpoints(IEndpointRouteBuilder builder)
await context.Response.WriteAsync(name);
});

builder.Map("/AuthenticateAndRetrieveRBACClaims", async context =>
{
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync();
return;
}

Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2
var name = context.User.Identity.Name;
Assert.False(string.IsNullOrEmpty(name), "name");
Assert.Contains(
context.User.Claims,
claim => claim.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
&& claim.Value == "CN=Domain Admins,CN=Users,DC=domain,DC=net");
await context.Response.WriteAsync(name);
});

builder.Map("/AlreadyAuthenticated", async context =>
{
Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2
Expand Down