Use LDAP support from DirectoryServices.Protocols for RBAC claim resolution on Linux for Negotiate (#25075)

This commit is contained in:
John Luo 2020-08-25 13:21:46 -07:00 committed by GitHub
parent c2f0331805
commit 098be5f5ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 435 additions and 7 deletions

View File

@ -64,6 +64,7 @@ and are generated based on the last package release.
<LatestPackageReference Include="System.ComponentModel.Annotations" />
<LatestPackageReference Include="System.Diagnostics.DiagnosticSource" />
<LatestPackageReference Include="System.Diagnostics.EventLog" />
<LatestPackageReference Include="System.DirectoryServices.Protocols" />
<LatestPackageReference Include="System.Drawing.Common" />
<LatestPackageReference Include="System.IO.Pipelines" />
<LatestPackageReference Include="System.Net.Http" />

View File

@ -205,6 +205,10 @@
<Uri>https://github.com/dotnet/runtime</Uri>
<Sha>f4e99f4afa445b519abcd7c5c87cbf54771614db</Sha>
</Dependency>
<Dependency Name="System.DirectoryServices.Protocols" Version="5.0.0-rc.1.20425.1">
<Uri>https://github.com/dotnet/runtime</Uri>
<Sha>f4e99f4afa445b519abcd7c5c87cbf54771614db</Sha>
</Dependency>
<Dependency Name="System.Drawing.Common" Version="5.0.0-rc.1.20425.1">
<Uri>https://github.com/dotnet/runtime</Uri>
<Sha>f4e99f4afa445b519abcd7c5c87cbf54771614db</Sha>

View File

@ -108,6 +108,7 @@
<SystemComponentModelAnnotationsPackageVersion>5.0.0-rc.1.20425.1</SystemComponentModelAnnotationsPackageVersion>
<SystemDiagnosticsDiagnosticSourcePackageVersion>5.0.0-rc.1.20425.1</SystemDiagnosticsDiagnosticSourcePackageVersion>
<SystemDiagnosticsEventLogPackageVersion>5.0.0-rc.1.20425.1</SystemDiagnosticsEventLogPackageVersion>
<SystemDirectoryServicesProtocolsPackageVersion>5.0.0-rc.1.20425.1</SystemDirectoryServicesProtocolsPackageVersion>
<SystemDrawingCommonPackageVersion>5.0.0-rc.1.20425.1</SystemDrawingCommonPackageVersion>
<SystemIOPipelinesPackageVersion>5.0.0-rc.1.20425.1</SystemIOPipelinesPackageVersion>
<SystemNetHttpJsonPackageVersion>5.0.0-rc.1.20425.1</SystemNetHttpJsonPackageVersion>

View File

@ -1,6 +1,7 @@
// 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.Runtime.InteropServices;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Builder;
@ -22,6 +23,23 @@ namespace NegotiateAuthSample
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate(options =>
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
/*
options.EnableLdap("DOMAIN.net");
options.EnableLdap(settings =>
{
// Mandatory settings
settings.Domain = "DOMAIN.com";
// Optional settings
settings.MachineAccountName = "machineName";
settings.MachineAccountPassword = "PassW0rd";
settings.IgnoreNestedGroups = true;
});
*/
}
options.Events = new NegotiateEvents()
{
OnAuthenticationFailed = context =>

View File

@ -0,0 +1,35 @@
// 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 Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Authentication.Negotiate
{
/// <summary>
/// State for the RetrieveLdapClaims event.
/// </summary>
public class LdapContext : ResultContext<NegotiateOptions>
{
/// <summary>
/// Creates a new <see cref="LdapContext"/>.
/// </summary>
/// <param name="context"></param>
/// <param name="scheme"></param>
/// <param name="options"></param>
/// <param name="settings"></param>
public LdapContext(
HttpContext context,
AuthenticationScheme scheme,
NegotiateOptions options,
LdapSettings settings)
: base(context, scheme, options)
{
LdapSettings = settings;
}
/// <summary>
/// The LDAP settings to use for the RetrieveLdapClaims event.
/// </summary>
public LdapSettings LdapSettings { get; }
}
}

View File

@ -16,6 +16,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
/// </summary>
public Func<AuthenticationFailedContext, Task> OnAuthenticationFailed { get; set; } = context => Task.CompletedTask;
/// <summary>
/// Invoked after the authentication before ClaimsIdentity is populated with claims retrieved through the LDAP connection.
/// This event is invoked when <see cref="LdapSettings.EnableLdapClaimResolution"/> is set to true on <see cref="LdapSettings"/>.
/// </summary>
public Func<LdapContext, Task> OnRetrieveLdapClaims { get; set; } = context => Task.CompletedTask;
/// <summary>
/// Invoked after the authentication is complete and a ClaimsIdentity has been generated.
/// </summary>
@ -31,6 +37,11 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
/// </summary>
public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context);
/// <summary>
/// Invoked after the authentication before ClaimsIdentity is populated with claims retrieved through the LDAP connection.
/// </summary>
public virtual Task RetrieveLdapClaims(LdapContext context) => OnRetrieveLdapClaims(context);
/// <summary>
/// Invoked after the authentication is complete and a ClaimsIdentity has been generated.
/// </summary>

View File

@ -0,0 +1,97 @@
// 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.DirectoryServices.Protocols;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Authentication.Negotiate
{
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 distinguishedName = settings.Domain.Split('.').Select(name => $"dc={name}").Aggregate((a, b) => $"{a},{b}");
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 searchResponse = (SearchResponse) await Task<DirectoryResponse>.Factory.FromAsync(
settings.LdapConnection.BeginSendRequest,
settings.LdapConnection.EndSendRequest,
searchRequest,
PartialResultProcessing.NoPartialResultSupport,
null);
if (searchResponse.Entries.Count > 0)
{
if (searchResponse.Entries.Count > 1)
{
logger.LogWarning($"More than one response received for query: {filter} with distinguished name: {distinguishedName}");
}
var userFound = searchResponse.Entries[0]; //Get the object that was found on ldap
var memberof = userFound.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
foreach (var group in memberof)
{
// Example distinguished name: CN=TestGroup,DC=KERB,DC=local
var groupDN = $"{Encoding.UTF8.GetString((byte[])group)}";
var groupCN = groupDN.Split(',')[0].Substring("CN=".Length);
if (!settings.IgnoreNestedGroups)
{
GetNestedGroups(settings.LdapConnection, identity, distinguishedName, groupCN, logger);
}
else
{
AddRole(identity, groupCN);
}
}
}
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)
{
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 searchResponse = (SearchResponse)connection.SendRequest(searchRequest);
if (searchResponse.Entries.Count > 0)
{
if (searchResponse.Entries.Count > 1)
{
logger.LogWarning($"More than one response received for query: {filter} with distinguished name: {distinguishedName}");
}
var group = searchResponse.Entries[0]; //Get the object that was found on ldap
string name = group.DistinguishedName;
AddRole(principal, name);
var memberof = group.Attributes["memberof"]; // You can access ldap Attributes with Attributes property
if (memberof != null)
{
foreach (var member in memberof)
{
var groupDN = $"{Encoding.UTF8.GetString((byte[])member)}";
var nestedGroupCN = groupDN.Split(',')[0].Substring("CN=".Length);
GetNestedGroups(connection, principal, distinguishedName, nestedGroupCN, logger);
}
}
}
}
private static void AddRole(ClaimsIdentity identity, string role)
{
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
}
}

View File

@ -0,0 +1,31 @@
// 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;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Authentication.Negotiate.Internal
{
internal class NegotiateOptionsValidationStartupFilter : IStartupFilter
{
private readonly string _authenticationScheme;
public NegotiateOptionsValidationStartupFilter(string authenticationScheme)
{
_authenticationScheme = authenticationScheme;
}
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return builder =>
{
// Resolve NegotiateOptions on startup to trigger post configuration and bind LdapConnection if needed
var options = builder.ApplicationServices.GetRequiredService<IOptionsMonitor<NegotiateOptions>>().Get(_authenticationScheme);
next(builder);
};
}
}
}

View File

@ -0,0 +1,75 @@
// 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;
using System.DirectoryServices.Protocols;
namespace Microsoft.AspNetCore.Authentication.Negotiate
{
/// <summary>
/// Options class for configuring LDAP connections on Linux
/// </summary>
public class LdapSettings
{
/// <summary>
/// Configure whether LDAP connection should be used to resolve claims.
/// This is mainly used on Linux.
/// </summary>
public bool EnableLdapClaimResolution { get; set; }
/// <summary>
/// The domain to use for the LDAP connection. This is a mandatory setting.
/// </summary>
/// <example>
/// DOMAIN.com
/// </example>
public string Domain { get; set; }
/// <summary>
/// The machine account name to use when opening the LDAP connection.
/// If this is not provided, the machine wide credentials of the
/// domain joined machine will be used.
/// </summary>
public string MachineAccountName { get; set; }
/// <summary>
/// The machine account password to use when opening the LDAP connection.
/// This must be provided if a <see cref="MachineAccountName"/> is provided.
/// </summary>
public string MachineAccountPassword { get; set; }
/// <summary>
/// This option indicates whether nested groups should be ignored when
/// resolving Roles. The default is false.
/// </summary>
public bool IgnoreNestedGroups { get; set; }
/// <summary>
/// The <see cref="LdapConnection"/> to be used to retrieve role claims.
/// If no explicit connection is provided, an LDAP connection will be
/// automatically created based on the <see cref="Domain"/>,
/// <see cref="MachineAccountName"/> and <see cref="MachineAccountPassword"/>
/// options. If provided, this connection will be used and the
/// <see cref="Domain"/>, <see cref="MachineAccountName"/> and
/// <see cref="MachineAccountPassword"/> options will not be used to create
/// the <see cref="LdapConnection"/>.
/// </summary>
public LdapConnection LdapConnection { get; set; }
public void Validate()
{
if (EnableLdapClaimResolution)
{
if (string.IsNullOrEmpty(Domain))
{
throw new ArgumentException($"{nameof(EnableLdapClaimResolution)} is set to true but {nameof(Domain)} is not set.");
}
if (string.IsNullOrEmpty(MachineAccountName) && !string.IsNullOrEmpty(MachineAccountPassword))
{
throw new ArgumentException($"{nameof(MachineAccountPassword)} should only be specified when {nameof(MachineAccountName)} is configured.");
}
}
}
}
}

View File

@ -10,7 +10,9 @@
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Authentication" />
<Reference Include="Microsoft.AspNetCore.Connections.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Hosting.Server.Abstractions" />
<Reference Include="System.DirectoryServices.Protocols" />
</ItemGroup>
</Project>

View File

@ -4,6 +4,8 @@
using System;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.AspNetCore.Authentication.Negotiate.Internal;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
@ -52,6 +54,7 @@ namespace Microsoft.Extensions.DependencyInjection
public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<NegotiateOptions> configureOptions)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<NegotiateOptions>, PostConfigureNegotiateOptions>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter>(new NegotiateOptionsValidationStartupFilter(authenticationScheme)));
return builder.AddScheme<NegotiateOptions, NegotiateHandler>(authenticationScheme, displayName, configureOptions);
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Claims;
using System.Security.Principal;
using System.Text.Encodings.Web;
@ -324,10 +325,37 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
user = new ClaimsPrincipal(new ClaimsIdentity(identity));
}
var authenticatedContext = new AuthenticatedContext(Context, Scheme, Options)
AuthenticatedContext authenticatedContext;
if (Options.LdapSettings.EnableLdapClaimResolution)
{
Principal = user
};
var ldapContext = new LdapContext(Context, Scheme, Options, Options.LdapSettings)
{
Principal = user
};
await Events.RetrieveLdapClaims(ldapContext);
if (ldapContext.Result != null)
{
return ldapContext.Result;
}
await LdapAdapter.RetrieveClaimsAsync(ldapContext.LdapSettings, ldapContext.Principal.Identity as ClaimsIdentity, Logger);
authenticatedContext = new AuthenticatedContext(Context, Scheme, Options)
{
Principal = ldapContext.Principal
};
}
else
{
authenticatedContext = new AuthenticatedContext(Context, Scheme, Options)
{
Principal = user
};
}
await Events.Authenticated(authenticatedContext);
if (authenticatedContext.Result != null)

View File

@ -1,6 +1,8 @@
// 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;
namespace Microsoft.AspNetCore.Authentication.Negotiate
{
/// <summary>
@ -33,6 +35,42 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
/// </summary>
public bool PersistNtlmCredentials { get; set; } = true;
/// <summary>
/// Configuration settings for LDAP connections used to retrieve claims.
/// This should only be used on Linux systems.
/// </summary>
internal LdapSettings LdapSettings { get; } = new LdapSettings();
/// <summary>
/// Use LDAP connections used to retrieve claims for the given domain.
/// This should only be used on Linux systems.
/// </summary>
public void EnableLdap(string domain)
{
if (string.IsNullOrEmpty(domain))
{
throw new ArgumentNullException(nameof(domain));
}
LdapSettings.EnableLdapClaimResolution = true;
LdapSettings.Domain = domain;
}
/// <summary>
/// Use LDAP connections used to retrieve claims using the configured settings.
/// This should only be used on Linux systems.
/// </summary>
public void EnableLdap(Action<LdapSettings> configureSettings)
{
if (configureSettings == null)
{
throw new ArgumentNullException(nameof(configureSettings));
}
LdapSettings.EnableLdapClaimResolution = true;
configureSettings(LdapSettings);
}
/// <summary>
/// Indicates if integrated server Windows Auth is being used instead of this handler.
/// See <see cref="PostConfigureNegotiateOptions"/>.

View File

@ -4,7 +4,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -59,6 +61,36 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
+ " Enable Windows Authentication for the server and the Negotiate Authentication handler will defer to it.");
}
}
var ldapSettings = options.LdapSettings;
if (ldapSettings.EnableLdapClaimResolution)
{
ldapSettings.Validate();
if (ldapSettings.LdapConnection == null)
{
var di = new LdapDirectoryIdentifier(server: ldapSettings.Domain, fullyQualifiedDnsHostName: true, connectionless: false);
if (string.IsNullOrEmpty(ldapSettings.MachineAccountName))
{
// Use default credentials
ldapSettings.LdapConnection = new LdapConnection(di);
}
else
{
// Use specific specific machine account
var machineAccount = ldapSettings.MachineAccountName + "@" + ldapSettings.Domain;
var credentials = new NetworkCredential(machineAccount, ldapSettings.MachineAccountPassword);
ldapSettings.LdapConnection = new LdapConnection(di, credentials);
}
ldapSettings.LdapConnection.SessionOptions.ProtocolVersion = 3; //Setting LDAP Protocol to latest version
ldapSettings.LdapConnection.Timeout = TimeSpan.FromMinutes(1);
}
ldapSettings.LdapConnection.Bind(); // This line actually makes the connection.
}
}
}
}

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Reflection.Metadata;
using System.Security.Principal;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
@ -13,7 +12,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;
@ -371,6 +369,27 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
Assert.Equal(1, callCount);
}
[Fact]
public async Task OnRetrieveLdapClaims_DoesNotFireWhenLdapDisabled()
{
var callCount = 0;
using var host = await CreateHostAsync(options =>
{
options.Events = new NegotiateEvents()
{
OnRetrieveLdapClaims = context =>
{
callCount++;
return Task.CompletedTask;
}
};
});
var server = host.GetTestServer();
await KerberosStage1And2Auth(server, new TestConnection());
Assert.Equal(0, callCount);
}
private static async Task KerberosStage1And2Auth(TestServer server, TestConnection testConnection)
{
await KerberosStage1Auth(server, testConnection);

View File

@ -0,0 +1,34 @@
// 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;
using Xunit;
namespace Microsoft.AspNetCore.Authentication.Negotiate.Test
{
public class LdapSettingsValidationTests
{
[Fact]
public void EnabledWithoutDomainThrows()
{
var settings = new LdapSettings
{
EnableLdapClaimResolution = true
};
Assert.Throws<ArgumentException>(() => settings.Validate());
}
[Fact]
public void AccountPasswordWithoutAccountNameThrows()
{
var settings = new LdapSettings
{
EnableLdapClaimResolution = true,
MachineAccountPassword = "Passw0rd"
};
Assert.Throws<ArgumentException>(() => settings.Validate());
}
}
}

View File

@ -29,8 +29,7 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate
[Fact]
public async Task ServerSupportsAuthButDisabled_Error()
{
using var host = await CreateHostAsync(supportsAuth: true, isEnabled: false);
var ex = Assert.Throws<InvalidOperationException>(() => host.Services.GetRequiredService<IOptions<NegotiateOptions>>().Value);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await CreateHostAsync(supportsAuth: true, isEnabled: false));
Assert.Equal("The Negotiate Authentication handler cannot be used on a server that directly supports Windows Authentication."
+ " Enable Windows Authentication for the server and the Negotiate Authentication handler will defer to it.", ex.Message);
}