Add cache for retrieved RBAC claims (#25698)
This commit is contained in:
parent
76fbd1a283
commit
035221d731
|
|
@ -1,11 +1,13 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// 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.
|
// 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.DirectoryServices.Protocols;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.Authentication.Negotiate
|
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)
|
public static async Task RetrieveClaimsAsync(LdapSettings settings, ClaimsIdentity identity, ILogger logger)
|
||||||
{
|
{
|
||||||
var user = identity.Name;
|
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 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 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);
|
var searchRequest = new SearchRequest(distinguishedName, filter, SearchScope.Subtree, null);
|
||||||
|
|
@ -45,13 +65,27 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
|
|
||||||
if (!settings.IgnoreNestedGroups)
|
if (!settings.IgnoreNestedGroups)
|
||||||
{
|
{
|
||||||
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger);
|
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger, retrievedClaims);
|
||||||
}
|
}
|
||||||
else
|
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
|
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 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);
|
var searchResponse = (SearchResponse)connection.SendRequest(searchRequest);
|
||||||
|
|
||||||
if (searchResponse.Entries.Count > 0)
|
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
|
var group = searchResponse.Entries[0]; //Get the object that was found on ldap
|
||||||
string name = group.DistinguishedName;
|
string name = group.DistinguishedName;
|
||||||
AddRole(principal, name);
|
retrievedClaims.Add(name);
|
||||||
|
|
||||||
var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
|
var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
|
||||||
if (memberof != null)
|
if (memberof != null)
|
||||||
|
|
@ -83,15 +117,10 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
var groupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
|
var groupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
|
||||||
var nestedGroupCN = groupDN.Split(',')[0].Substring("CN=".Length);
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.DirectoryServices.Protocols;
|
using System.DirectoryServices.Protocols;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.Authentication.Negotiate
|
namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
{
|
{
|
||||||
|
|
@ -56,6 +57,25 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LdapConnection LdapConnection { get; set; }
|
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()
|
public void Validate()
|
||||||
{
|
{
|
||||||
if (EnableLdapClaimResolution)
|
if (EnableLdapClaimResolution)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<Reference Include="Microsoft.AspNetCore.Routing" />
|
<Reference Include="Microsoft.AspNetCore.Routing" />
|
||||||
<Reference Include="Microsoft.AspNetCore.Testing" />
|
<Reference Include="Microsoft.AspNetCore.Testing" />
|
||||||
<Reference Include="Microsoft.AspNetCore.TestHost" />
|
<Reference Include="Microsoft.AspNetCore.TestHost" />
|
||||||
|
<Reference Include="Microsoft.Extensions.Caching.Memory" />
|
||||||
<Reference Include="Microsoft.Extensions.Hosting" />
|
<Reference Include="Microsoft.Extensions.Hosting" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.AspNetCore.Testing;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
@ -208,6 +208,28 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
await NtlmStage1And2Auth(server, testConnection);
|
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]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
|
|
@ -304,6 +326,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
var ex = await Assert.ThrowsAsync<Exception>(() => SendAsync(server, "/404", testConnection, "Negotiate OtherError"));
|
var ex = await Assert.ThrowsAsync<Exception>(() => SendAsync(server, "/404", testConnection, "Negotiate OtherError"));
|
||||||
Assert.Equal("A test other error occurred", ex.Message);
|
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
|
// Single Stage
|
||||||
private static async Task KerberosAuth(TestServer server, TestConnection testConnection)
|
private static async Task KerberosAuth(TestServer server, TestConnection testConnection)
|
||||||
|
|
@ -408,6 +436,24 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
|
||||||
await context.Response.WriteAsync(name);
|
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 =>
|
builder.Map("/AlreadyAuthenticated", async context =>
|
||||||
{
|
{
|
||||||
Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2
|
Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue