Skip to content

Commit 035221d

Browse files
author
John Luo
authored
Add cache for retrieved RBAC claims (#25698)
1 parent 76fbd1a commit 035221d

File tree

4 files changed

+109
-13
lines changed

4 files changed

+109
-13
lines changed

src/Security/Authentication/Negotiate/src/Internal/LdapAdapter.cs

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System.Collections.Generic;
45
using System.DirectoryServices.Protocols;
56
using System.Linq;
67
using System.Security.Claims;
78
using System.Text;
89
using System.Threading.Tasks;
10+
using Microsoft.Extensions.Caching.Memory;
911
using Microsoft.Extensions.Logging;
1012

1113
namespace Microsoft.AspNetCore.Authentication.Negotiate
@@ -15,8 +17,26 @@ internal static class LdapAdapter
1517
public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdentity identity, ILogger logger)
1618
{
1719
var user = identity.Name;
18-
var userAccountName = user.Substring(0, user.IndexOf('@'));
20+
var userAccountNameIndex = user.IndexOf('@');
21+
var userAccountName = userAccountNameIndex == -1 ? user : user.Substring(0, userAccountNameIndex);
22+
23+
if (settings.ClaimsCache == null)
24+
{
25+
settings.ClaimsCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = settings.ClaimsCacheSize });
26+
}
27+
28+
if (settings.ClaimsCache.TryGetValue<IEnumerable<string>>(user, out var cachedClaims))
29+
{
30+
foreach (var claim in cachedClaims)
31+
{
32+
identity.AddClaim(new Claim(identity.RoleClaimType, claim));
33+
}
34+
35+
return;
36+
}
37+
1938
var distinguishedName = settings.Domain.Split('.').Select(name => $"dc={name}").Aggregate((a, b) => $"{a},{b}");
39+
var retrievedClaims = new List<string>();
2040

2141
var filter = $"(&(objectClass=user)(sAMAccountName={userAccountName}))"; // This is using ldap search query language, it is looking on the server for someUser
2242
var searchRequest = new SearchRequest(distinguishedName, filter, SearchScope.Subtree, null);
@@ -45,24 +65,38 @@ public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdenti
4565

4666
if (!settings.IgnoreNestedGroups)
4767
{
48-
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger);
68+
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger, retrievedClaims);
4969
}
5070
else
5171
{
52-
AddRole(identity, groupCN);
72+
retrievedClaims.Add(groupCN);
5373
}
5474
}
75+
76+
var entrySize = user.Length * 2; //Approximate the size of stored key in memory cache.
77+
foreach (var claim in retrievedClaims)
78+
{
79+
identity.AddClaim(new Claim(identity.RoleClaimType, claim));
80+
entrySize += claim.Length * 2; //Approximate the size of stored value in memory cache.
81+
}
82+
83+
settings.ClaimsCache.Set(user,
84+
retrievedClaims,
85+
new MemoryCacheEntryOptions()
86+
.SetSize(entrySize)
87+
.SetSlidingExpiration(settings.ClaimsCacheSlidingExpiration)
88+
.SetAbsoluteExpiration(settings.ClaimsCacheAbsoluteExpiration));
5589
}
5690
else
5791
{
5892
logger.LogWarning($"No response received for query: {filter} with distinguished name: {distinguishedName}");
5993
}
6094
}
6195

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

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

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

79113
var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
80114
if (memberof != null)
@@ -83,15 +117,10 @@ private static void GetNestedGroups(LdapConnection connection, ClaimsIdentity pr
83117
{
84118
var groupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
85119
var nestedGroupCN = groupDN.Split(',')[0].Substring("CN=".Length);
86-
GetNestedGroups(connection, principal, distinguishedName, nestedGroupCN, logger);
120+
GetNestedGroups(connection, principal, distinguishedName, nestedGroupCN, logger, retrievedClaims);
87121
}
88122
}
89123
}
90124
}
91-
92-
private static void AddRole(ClaimsIdentity identity, string role)
93-
{
94-
identity.AddClaim(new Claim(identity.RoleClaimType, role));
95-
}
96125
}
97126
}

src/Security/Authentication/Negotiate/src/LdapSettings.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.DirectoryServices.Protocols;
6+
using Microsoft.Extensions.Caching.Memory;
67

78
namespace Microsoft.AspNetCore.Authentication.Negotiate
89
{
@@ -56,6 +57,25 @@ public class LdapSettings
5657
/// </summary>
5758
public LdapConnection LdapConnection { get; set; }
5859

60+
/// <summary>
61+
/// The sliding expiration that should be used for entries in the cache for user claims, defaults to 10 minutes.
62+
/// This is a sliding expiration that will extend each time claims for a user is retrieved.
63+
/// </summary>
64+
public TimeSpan ClaimsCacheSlidingExpiration { get; set; } = TimeSpan.FromMinutes(10);
65+
66+
/// <summary>
67+
/// The absolute expiration that should be used for entries in the cache for user claims, defaults to 60 minutes.
68+
/// This is an absolute expiration that starts when a claims for a user is retrieved for the first time.
69+
/// </summary>
70+
public TimeSpan ClaimsCacheAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(60);
71+
72+
/// <summary>
73+
/// The maximum size of the claim results cache, defaults to 100 MB.
74+
/// </summary>
75+
public int ClaimsCacheSize { get; set; } = 100 * 1024 * 1024;
76+
77+
internal MemoryCache ClaimsCache { get; set; }
78+
5979
public void Validate()
6080
{
6181
if (EnableLdapClaimResolution)

src/Security/Authentication/Negotiate/test/Negotiate.Test/Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<Reference Include="Microsoft.AspNetCore.Routing" />
1111
<Reference Include="Microsoft.AspNetCore.Testing" />
1212
<Reference Include="Microsoft.AspNetCore.TestHost" />
13+
<Reference Include="Microsoft.Extensions.Caching.Memory" />
1314
<Reference Include="Microsoft.Extensions.Hosting" />
1415
</ItemGroup>
1516

src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
using Microsoft.AspNetCore.Http;
1414
using Microsoft.AspNetCore.Routing;
1515
using Microsoft.AspNetCore.TestHost;
16-
using Microsoft.AspNetCore.Testing;
16+
using Microsoft.Extensions.Caching.Memory;
1717
using Microsoft.Extensions.DependencyInjection;
1818
using Microsoft.Extensions.Hosting;
1919
using Microsoft.Net.Http.Headers;
@@ -208,6 +208,28 @@ public async Task AuthHeaderAfterNtlmCompleted_ReAuthenticates(bool persist)
208208
await NtlmStage1And2Auth(server, testConnection);
209209
}
210210

211+
[Fact]
212+
public async Task RBACClaimsRetrievedFromCacheAfterKerberosCompleted()
213+
{
214+
var claimsCache = new MemoryCache(new MemoryCacheOptions());
215+
claimsCache.Set("name", new string[] { "CN=Domain Admins,CN=Users,DC=domain,DC=net" });
216+
NegotiateOptions negotiateOptions = null;
217+
using var host = await CreateHostAsync(options =>
218+
{
219+
options.EnableLdap(ldapSettings =>
220+
{
221+
ldapSettings.Domain = "domain.NET";
222+
ldapSettings.ClaimsCache = claimsCache;
223+
ldapSettings.EnableLdapClaimResolution = false; // This disables binding to the LDAP connection on startup
224+
});
225+
negotiateOptions = options;
226+
});
227+
var server = host.GetTestServer();
228+
var testConnection = new TestConnection();
229+
negotiateOptions.EnableLdap(_ => { }); // Forcefully re-enable ldap claims resolution to trigger RBAC claims retrieval from cache
230+
await AuthenticateAndRetrieveRBACClaims(server, testConnection);
231+
}
232+
211233
[Theory]
212234
[InlineData(false)]
213235
[InlineData(true)]
@@ -304,6 +326,12 @@ public async Task OtherError_Throws()
304326
var ex = await Assert.ThrowsAsync<Exception>(() => SendAsync(server, "/404", testConnection, "Negotiate OtherError"));
305327
Assert.Equal("A test other error occurred", ex.Message);
306328
}
329+
private static async Task AuthenticateAndRetrieveRBACClaims(TestServer server, TestConnection testConnection)
330+
{
331+
var result = await SendAsync(server, "/AuthenticateAndRetrieveRBACClaims", testConnection, "Negotiate ClientKerberosBlob");
332+
Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode);
333+
Assert.Equal("Negotiate ServerKerberosBlob", result.Response.Headers[HeaderNames.WWWAuthenticate]);
334+
}
307335

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

439+
builder.Map("/AuthenticateAndRetrieveRBACClaims", async context =>
440+
{
441+
if (!context.User.Identity.IsAuthenticated)
442+
{
443+
await context.ChallengeAsync();
444+
return;
445+
}
446+
447+
Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2
448+
var name = context.User.Identity.Name;
449+
Assert.False(string.IsNullOrEmpty(name), "name");
450+
Assert.Contains(
451+
context.User.Claims,
452+
claim => claim.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
453+
&& claim.Value == "CN=Domain Admins,CN=Users,DC=domain,DC=net");
454+
await context.Response.WriteAsync(name);
455+
});
456+
411457
builder.Map("/AlreadyAuthenticated", async context =>
412458
{
413459
Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2

0 commit comments

Comments
 (0)