Add cache for retrieved RBAC claims (#25698)

This commit is contained in:
John Luo 2020-09-09 14:24:48 -07:00 committed by GitHub
parent 76fbd1a283
commit 035221d731
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 109 additions and 13 deletions

View File

@ -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
@ -15,8 +17,26 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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);
@ -45,13 +65,27 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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
{
@ -59,10 +93,10 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
}
}
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)
@ -74,7 +108,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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)
@ -83,15 +117,10 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
{
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));
}
}
}

View File

@ -3,6 +3,7 @@
using System;
using System.DirectoryServices.Protocols;
using Microsoft.Extensions.Caching.Memory;
namespace Microsoft.AspNetCore.Authentication.Negotiate
{
@ -56,6 +57,25 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
/// </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; }
public void Validate()
{
if (EnableLdapClaimResolution)

View File

@ -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>

View File

@ -13,7 +13,7 @@ using Microsoft.AspNetCore.Hosting;
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;
@ -208,6 +208,28 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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
});
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)]
@ -304,6 +326,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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)
@ -408,6 +436,24 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
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