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.
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue