diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 8da95c6314..68e1f6149c 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -64,6 +64,7 @@ and are generated based on the last package release. + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index c89e801957..db9cc55f62 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -205,6 +205,10 @@ https://github.com/dotnet/runtime f4e99f4afa445b519abcd7c5c87cbf54771614db + + https://github.com/dotnet/runtime + f4e99f4afa445b519abcd7c5c87cbf54771614db + https://github.com/dotnet/runtime f4e99f4afa445b519abcd7c5c87cbf54771614db diff --git a/eng/Versions.props b/eng/Versions.props index 965ed5bdd1..9c2fd0000e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -108,6 +108,7 @@ 5.0.0-rc.1.20425.1 5.0.0-rc.1.20425.1 5.0.0-rc.1.20425.1 + 5.0.0-rc.1.20425.1 5.0.0-rc.1.20425.1 5.0.0-rc.1.20425.1 5.0.0-rc.1.20425.1 diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs index 2e62846f8f..c2b0b0ba7f 100644 --- a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs @@ -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 => diff --git a/src/Security/Authentication/Negotiate/src/Events/LdapContext.cs b/src/Security/Authentication/Negotiate/src/Events/LdapContext.cs new file mode 100644 index 0000000000..9e6d7a40ac --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Events/LdapContext.cs @@ -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 +{ + /// + /// State for the RetrieveLdapClaims event. + /// + public class LdapContext : ResultContext + { + /// + /// Creates a new . + /// + /// + /// + /// + /// + public LdapContext( + HttpContext context, + AuthenticationScheme scheme, + NegotiateOptions options, + LdapSettings settings) + : base(context, scheme, options) + { + LdapSettings = settings; + } + + /// + /// The LDAP settings to use for the RetrieveLdapClaims event. + /// + public LdapSettings LdapSettings { get; } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs b/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs index 0d57be28eb..88dfdf2b74 100644 --- a/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs +++ b/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs @@ -16,6 +16,12 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate /// public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + /// + /// Invoked after the authentication before ClaimsIdentity is populated with claims retrieved through the LDAP connection. + /// This event is invoked when is set to true on . + /// + public Func OnRetrieveLdapClaims { get; set; } = context => Task.CompletedTask; + /// /// Invoked after the authentication is complete and a ClaimsIdentity has been generated. /// @@ -31,6 +37,11 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate /// public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); + /// + /// Invoked after the authentication before ClaimsIdentity is populated with claims retrieved through the LDAP connection. + /// + public virtual Task RetrieveLdapClaims(LdapContext context) => OnRetrieveLdapClaims(context); + /// /// Invoked after the authentication is complete and a ClaimsIdentity has been generated. /// diff --git a/src/Security/Authentication/Negotiate/src/Internal/LdapAdapter.cs b/src/Security/Authentication/Negotiate/src/Internal/LdapAdapter.cs new file mode 100644 index 0000000000..4ddad3c5e3 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/LdapAdapter.cs @@ -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.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)); + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateOptionsValidationStartupFilter.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateOptionsValidationStartupFilter.cs new file mode 100644 index 0000000000..429a57d81e --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateOptionsValidationStartupFilter.cs @@ -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 Configure(Action next) + { + return builder => + { + // Resolve NegotiateOptions on startup to trigger post configuration and bind LdapConnection if needed + var options = builder.ApplicationServices.GetRequiredService>().Get(_authenticationScheme); + next(builder); + }; + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/LdapSettings.cs b/src/Security/Authentication/Negotiate/src/LdapSettings.cs new file mode 100644 index 0000000000..1e26c26c14 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/LdapSettings.cs @@ -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 +{ + /// + /// Options class for configuring LDAP connections on Linux + /// + public class LdapSettings + { + /// + /// Configure whether LDAP connection should be used to resolve claims. + /// This is mainly used on Linux. + /// + public bool EnableLdapClaimResolution { get; set; } + + /// + /// The domain to use for the LDAP connection. This is a mandatory setting. + /// + /// + /// DOMAIN.com + /// + public string Domain { get; set; } + + /// + /// 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. + /// + public string MachineAccountName { get; set; } + + /// + /// The machine account password to use when opening the LDAP connection. + /// This must be provided if a is provided. + /// + public string MachineAccountPassword { get; set; } + + /// + /// This option indicates whether nested groups should be ignored when + /// resolving Roles. The default is false. + /// + public bool IgnoreNestedGroups { get; set; } + + /// + /// The to be used to retrieve role claims. + /// If no explicit connection is provided, an LDAP connection will be + /// automatically created based on the , + /// and + /// options. If provided, this connection will be used and the + /// , and + /// options will not be used to create + /// the . + /// + 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."); + } + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj index 265ffb533a..c0aac839f5 100644 --- a/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj +++ b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj @@ -10,7 +10,9 @@ + + diff --git a/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs index f5bbf8cbc8..e47417e170 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs @@ -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 configureOptions) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureNegotiateOptions>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(new NegotiateOptionsValidationStartupFilter(authenticationScheme))); return builder.AddScheme(authenticationScheme, displayName, configureOptions); } } diff --git a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs index 835542a42d..0ef6697857 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs @@ -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) diff --git a/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs b/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs index 3f5d36b39f..40d090265c 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs @@ -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 { /// @@ -33,6 +35,42 @@ namespace Microsoft.AspNetCore.Authentication.Negotiate /// public bool PersistNtlmCredentials { get; set; } = true; + /// + /// Configuration settings for LDAP connections used to retrieve claims. + /// This should only be used on Linux systems. + /// + internal LdapSettings LdapSettings { get; } = new LdapSettings(); + + /// + /// Use LDAP connections used to retrieve claims for the given domain. + /// This should only be used on Linux systems. + /// + public void EnableLdap(string domain) + { + if (string.IsNullOrEmpty(domain)) + { + throw new ArgumentNullException(nameof(domain)); + } + + LdapSettings.EnableLdapClaimResolution = true; + LdapSettings.Domain = domain; + } + + /// + /// Use LDAP connections used to retrieve claims using the configured settings. + /// This should only be used on Linux systems. + /// + public void EnableLdap(Action configureSettings) + { + if (configureSettings == null) + { + throw new ArgumentNullException(nameof(configureSettings)); + } + + LdapSettings.EnableLdapClaimResolution = true; + configureSettings(LdapSettings); + } + /// /// Indicates if integrated server Windows Auth is being used instead of this handler. /// See . diff --git a/src/Security/Authentication/Negotiate/src/PostConfigureNegotiateOptions.cs b/src/Security/Authentication/Negotiate/src/PostConfigureNegotiateOptions.cs index 91384c3293..4fb8a29be4 100644 --- a/src/Security/Authentication/Negotiate/src/PostConfigureNegotiateOptions.cs +++ b/src/Security/Authentication/Negotiate/src/PostConfigureNegotiateOptions.cs @@ -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. + } } } } diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs index 0979640207..af471a5450 100644 --- a/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs @@ -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); diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/LdapSettingsValidationTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/LdapSettingsValidationTests.cs new file mode 100644 index 0000000000..6a706d820b --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/LdapSettingsValidationTests.cs @@ -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(() => settings.Validate()); + } + + [Fact] + public void AccountPasswordWithoutAccountNameThrows() + { + var settings = new LdapSettings + { + EnableLdapClaimResolution = true, + MachineAccountPassword = "Passw0rd" + }; + + Assert.Throws(() => settings.Validate()); + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/ServerDeferralTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/ServerDeferralTests.cs index efd513b829..f038c7ca80 100644 --- a/src/Security/Authentication/Negotiate/test/Negotiate.Test/ServerDeferralTests.cs +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/ServerDeferralTests.cs @@ -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(() => host.Services.GetRequiredService>().Value); + var ex = await Assert.ThrowsAsync(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); }