diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 067ba7978859..94366eafe9de 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -61,6 +61,7 @@ + diff --git a/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.csproj b/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.csproj new file mode 100644 index 000000000000..ea9c20cd85aa --- /dev/null +++ b/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.csproj @@ -0,0 +1,11 @@ + + + + netcoreapp3.0 + + + + + + + diff --git a/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.netcoreapp3.0.cs b/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.netcoreapp3.0.cs new file mode 100644 index 000000000000..4f9f3a900ed8 --- /dev/null +++ b/src/Security/Authentication/Negotiate/ref/Microsoft.AspNetCore.Authentication.Negotiate.netcoreapp3.0.cs @@ -0,0 +1,64 @@ +// 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. + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + public partial class AuthenticatedContext : Microsoft.AspNetCore.Authentication.ResultContext + { + public AuthenticatedContext(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions options) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions)) { } + } + public partial class AuthenticationFailedContext : Microsoft.AspNetCore.Authentication.RemoteAuthenticationContext + { + public AuthenticationFailedContext(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions options) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions), default(Microsoft.AspNetCore.Authentication.AuthenticationProperties)) { } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } + public partial class ChallengeContext : Microsoft.AspNetCore.Authentication.PropertiesContext + { + public ChallengeContext(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions options, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.Negotiate.NegotiateOptions), default(Microsoft.AspNetCore.Authentication.AuthenticationProperties)) { } + public bool Handled { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + public void HandleResponse() { } + } + public static partial class NegotiateDefaults + { + public const string AuthenticationScheme = "Negotiate"; + } + public partial class NegotiateEvents + { + public NegotiateEvents() { } + public System.Func OnAuthenticated { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public System.Func OnAuthenticationFailed { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public System.Func OnChallenge { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public virtual System.Threading.Tasks.Task Authenticated(Microsoft.AspNetCore.Authentication.Negotiate.AuthenticatedContext context) { throw null; } + public virtual System.Threading.Tasks.Task AuthenticationFailed(Microsoft.AspNetCore.Authentication.Negotiate.AuthenticationFailedContext context) { throw null; } + public virtual System.Threading.Tasks.Task Challenge(Microsoft.AspNetCore.Authentication.Negotiate.ChallengeContext context) { throw null; } + } + public partial class NegotiateHandler : Microsoft.AspNetCore.Authentication.AuthenticationHandler, Microsoft.AspNetCore.Authentication.IAuthenticationHandler, Microsoft.AspNetCore.Authentication.IAuthenticationRequestHandler + { + public NegotiateHandler(Microsoft.Extensions.Options.IOptionsMonitor options, Microsoft.Extensions.Logging.ILoggerFactory logger, System.Text.Encodings.Web.UrlEncoder encoder, Microsoft.AspNetCore.Authentication.ISystemClock clock) : base (default(Microsoft.Extensions.Options.IOptionsMonitor), default(Microsoft.Extensions.Logging.ILoggerFactory), default(System.Text.Encodings.Web.UrlEncoder), default(Microsoft.AspNetCore.Authentication.ISystemClock)) { } + protected new Microsoft.AspNetCore.Authentication.Negotiate.NegotiateEvents Events { get { throw null; } set { } } + protected override System.Threading.Tasks.Task CreateEventsAsync() { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task HandleAuthenticateAsync() { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task HandleChallengeAsync(Microsoft.AspNetCore.Authentication.AuthenticationProperties properties) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public System.Threading.Tasks.Task HandleRequestAsync() { throw null; } + } + public partial class NegotiateOptions : Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions + { + public NegotiateOptions() { } + public new Microsoft.AspNetCore.Authentication.Negotiate.NegotiateEvents Events { get { throw null; } set { } } + public bool PersistKerberosCredentials { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public bool PersistNtlmCredentials { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } +} +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class NegotiateExtensions + { + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddNegotiate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddNegotiate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, System.Action configureOptions) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddNegotiate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, string authenticationScheme, System.Action configureOptions) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddNegotiate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, string authenticationScheme, string displayName, System.Action configureOptions) { throw null; } + } +} diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/NegotiateAuthSample.csproj b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/NegotiateAuthSample.csproj new file mode 100644 index 000000000000..df3d2e036b20 --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/NegotiateAuthSample.csproj @@ -0,0 +1,15 @@ + + + + netcoreapp3.0 + OutOfProcess + + + + + + + + + + diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Program.cs b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Program.cs new file mode 100644 index 000000000000..05f8132cb202 --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Program.cs @@ -0,0 +1,23 @@ +// 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.Hosting; +using Microsoft.Extensions.Hosting; + +namespace NegotiateAuthSample +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Properties/launchSettings.json b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Properties/launchSettings.json new file mode 100644 index 000000000000..128e6417fa0e --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:6449", + "sslPort": 44369 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "NegotiateAuthSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs new file mode 100644 index 000000000000..1643abe61434 --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/Startup.cs @@ -0,0 +1,40 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Negotiate; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace NegotiateAuthSample +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthorization(options => + { + options.FallbackPolicy = options.DefaultPolicy; + }); + services.AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDeveloperExceptionPage(); + app.UseAuthentication(); + app.UseAuthorization(); + app.Run(HandleRequest); + } + + public async Task HandleRequest(HttpContext context) + { + var user = context.User.Identity; + await context.Response.WriteAsync($"Authenticated? {user.IsAuthenticated}, Name: {user.Name}, Protocol: {context.Request.Protocol}"); + } + } +} diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.Development.json b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.Development.json new file mode 100644 index 000000000000..6d1c8438d2ae --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Microsoft.AspNetCore.Authentication": "Debug" + } + } +} diff --git a/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.json b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.json new file mode 100644 index 000000000000..7cb5ac81931a --- /dev/null +++ b/src/Security/Authentication/Negotiate/samples/NegotiateAuthSample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Security/Authentication/Negotiate/src/Events/AuthenticatedContext.cs b/src/Security/Authentication/Negotiate/src/Events/AuthenticatedContext.cs new file mode 100644 index 000000000000..fee54f3e8d68 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Events/AuthenticatedContext.cs @@ -0,0 +1,25 @@ +// 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 Authenticated event. + /// + public class AuthenticatedContext : ResultContext + { + /// + /// Creates a new . + /// + /// + /// + /// + public AuthenticatedContext( + HttpContext context, + AuthenticationScheme scheme, + NegotiateOptions options) + : base(context, scheme, options) { } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Events/AuthenticationFailedContext.cs b/src/Security/Authentication/Negotiate/src/Events/AuthenticationFailedContext.cs new file mode 100644 index 000000000000..d64c8da43e82 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Events/AuthenticationFailedContext.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.Http; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// State for the AuthenticationFailed event. + /// + public class AuthenticationFailedContext : RemoteAuthenticationContext + { + /// + /// Creates a . + /// + /// + /// + /// + public AuthenticationFailedContext( + HttpContext context, + AuthenticationScheme scheme, + NegotiateOptions options) + : base(context, scheme, options, properties: null) { } + + /// + /// The exception that occured while processing the authentication. + /// + public Exception Exception { get; set; } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Events/ChallengeContext.cs b/src/Security/Authentication/Negotiate/src/Events/ChallengeContext.cs new file mode 100644 index 000000000000..23b6ab041de4 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Events/ChallengeContext.cs @@ -0,0 +1,38 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// State for the Challenge event. + /// + public class ChallengeContext : PropertiesContext + { + /// + /// Creates a new . + /// + /// + /// + /// + /// + public ChallengeContext( + HttpContext context, + AuthenticationScheme scheme, + NegotiateOptions options, + AuthenticationProperties properties) + : base(context, scheme, options, properties) { } + + /// + /// If true, will skip any default logic for this challenge. + /// + public bool Handled { get; private set; } + + /// + /// Skips any default logic for this challenge. + /// + public void HandleResponse() => Handled = true; + } +} diff --git a/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs b/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs new file mode 100644 index 000000000000..0d57be28ebdc --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Events/NegotiateEvents.cs @@ -0,0 +1,44 @@ +// 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.Threading.Tasks; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// Specifies events which the invokes to enable developer control over the authentication process. + /// + public class NegotiateEvents + { + /// + /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. + /// + public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked after the authentication is complete and a ClaimsIdentity has been generated. + /// + public Func OnAuthenticated { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked before a challenge is sent back to the caller. + /// + public Func OnChallenge { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. + /// + public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); + + /// + /// Invoked after the authentication is complete and a ClaimsIdentity has been generated. + /// + public virtual Task Authenticated(AuthenticatedContext context) => OnAuthenticated(context); + + /// + /// Invoked before a challenge is sent back to the caller. + /// + public virtual Task Challenge(ChallengeContext context) => OnChallenge(context); + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/INegotiateState.cs b/src/Security/Authentication/Negotiate/src/Internal/INegotiateState.cs new file mode 100644 index 000000000000..dbc215ef7dbf --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/INegotiateState.cs @@ -0,0 +1,20 @@ +// 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.Security.Principal; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + // For testing + internal interface INegotiateState : IDisposable + { + string GetOutgoingBlob(string incomingBlob); + + bool IsCompleted { get; } + + string Protocol { get; } + + IIdentity GetIdentity(); + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs b/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs new file mode 100644 index 000000000000..32cdeef1e92e --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/INegotiateStateFactory.cs @@ -0,0 +1,11 @@ +// 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. + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + // For testing + internal interface INegotiateStateFactory + { + INegotiateState CreateInstance(); + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/NegotiateLoggingExtensions.cs b/src/Security/Authentication/Negotiate/src/Internal/NegotiateLoggingExtensions.cs new file mode 100644 index 000000000000..399b6fbe52a3 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/NegotiateLoggingExtensions.cs @@ -0,0 +1,71 @@ +// 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.Extensions.Logging +{ + internal static class NegotiateLoggingExtensions + { + private static Action _incompleteNegotiateChallenge; + private static Action _negotiateComplete; + private static Action _enablingCredentialPersistence; + private static Action _disablingCredentialPersistence; + private static Action _exceptionProcessingAuth; + private static Action _challengeNegotiate; + private static Action _reauthenticating; + + static NegotiateLoggingExtensions() + { + _incompleteNegotiateChallenge = LoggerMessage.Define( + eventId: new EventId(1, "IncompleteNegotiateChallenge"), + logLevel: LogLevel.Information, + formatString: "Incomplete Negotiate handshake, sending an additional 401 Negotiate challenge."); + _negotiateComplete = LoggerMessage.Define( + eventId: new EventId(2, "NegotiateComplete"), + logLevel: LogLevel.Debug, + formatString: "Completed Negotiate authentication."); + _enablingCredentialPersistence = LoggerMessage.Define( + eventId: new EventId(3, "EnablingCredentialPersistence"), + logLevel: LogLevel.Debug, + formatString: "Enabling credential persistence for a complete Kerberos handshake."); + _disablingCredentialPersistence = LoggerMessage.Define( + eventId: new EventId(4, "DisablingCredentialPersistence"), + logLevel: LogLevel.Debug, + formatString: "Disabling credential persistence for a complete {protocol} handshake."); + _exceptionProcessingAuth = LoggerMessage.Define( + eventId: new EventId(5, "ExceptionProcessingAuth"), + logLevel: LogLevel.Error, + formatString: "An exception occurred while processing the authentication request."); + _challengeNegotiate = LoggerMessage.Define( + eventId: new EventId(6, "ChallengeNegotiate"), + logLevel: LogLevel.Debug, + formatString: "Challenged 401 Negotiate"); + _reauthenticating = LoggerMessage.Define( + eventId: new EventId(7, "Reauthenticating"), + logLevel: LogLevel.Debug, + formatString: "Negotiate data received for an already authenticated connection, Re-authenticating."); + } + + public static void IncompleteNegotiateChallenge(this ILogger logger) + => _incompleteNegotiateChallenge(logger, null); + + public static void NegotiateComplete(this ILogger logger) + => _negotiateComplete(logger, null); + + public static void EnablingCredentialPersistence(this ILogger logger) + => _enablingCredentialPersistence(logger, null); + + public static void DisablingCredentialPersistence(this ILogger logger, string protocol) + => _disablingCredentialPersistence(logger, protocol, null); + + public static void ExceptionProcessingAuth(this ILogger logger, Exception ex) + => _exceptionProcessingAuth(logger, ex); + + public static void ChallengeNegotiate(this ILogger logger) + => _challengeNegotiate(logger, null); + + public static void Reauthenticating(this ILogger logger) + => _reauthenticating(logger, null); + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateState.cs b/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateState.cs new file mode 100644 index 000000000000..37a1dff6f0e7 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateState.cs @@ -0,0 +1,96 @@ +// 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.Linq; +using System.Net; +using System.Reflection; +using System.Security.Authentication; +using System.Security.Principal; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + internal class ReflectedNegotiateState : INegotiateState + { + private static readonly ConstructorInfo _constructor; + private static readonly MethodInfo _getOutgoingBlob; + private static readonly MethodInfo _isCompleted; + private static readonly MethodInfo _protocol; + private static readonly MethodInfo _getIdentity; + private static readonly MethodInfo _closeContext; + + private readonly object _instance; + + static ReflectedNegotiateState() + { + var ntAuthType = typeof(AuthenticationException).Assembly.GetType("System.Net.NTAuthentication"); + _constructor = ntAuthType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance).First(); + _getOutgoingBlob = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info => + info.Name.Equals("GetOutgoingBlob") && info.GetParameters().Count() == 2).Single(); + _isCompleted = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info => + info.Name.Equals("get_IsCompleted")).Single(); + _protocol = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info => + info.Name.Equals("get_ProtocolName")).Single(); + _closeContext = ntAuthType.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).Where(info => + info.Name.Equals("CloseContext")).Single(); + + var negoStreamPalType = typeof(AuthenticationException).Assembly.GetType("System.Net.Security.NegotiateStreamPal"); + _getIdentity = negoStreamPalType.GetMethods(BindingFlags.NonPublic | BindingFlags.Static).Where(info => + info.Name.Equals("GetIdentity")).Single(); + } + + public ReflectedNegotiateState() + { + // internal NTAuthentication(bool isServer, string package, NetworkCredential credential, string spn, ContextFlagsPal requestedContextFlags, ChannelBinding channelBinding) + var credential = CredentialCache.DefaultCredentials; + _instance = _constructor.Invoke(new object[] { true, "Negotiate", credential, null, 0, null }); + } + + // Copied rather than reflected to remove the IsCompleted -> CloseContext check. + // The client doesn't need the context once auth is complete, but the server does. + // I'm not sure why it auto-closes for the client given that the client closes it just a few lines later. + // https://github.com/dotnet/corefx/blob/a3ab91e10045bb298f48c1d1f9bd5b0782a8ac46/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.NtAuth.cs#L134 + public string GetOutgoingBlob(string incomingBlob) + { + byte[] decodedIncomingBlob = null; + if (incomingBlob != null && incomingBlob.Length > 0) + { + decodedIncomingBlob = Convert.FromBase64String(incomingBlob); + } + byte[] decodedOutgoingBlob = GetOutgoingBlob(decodedIncomingBlob, true); + + string outgoingBlob = null; + if (decodedOutgoingBlob != null && decodedOutgoingBlob.Length > 0) + { + outgoingBlob = Convert.ToBase64String(decodedOutgoingBlob); + } + + return outgoingBlob; + } + + private byte[] GetOutgoingBlob(byte[] incomingBlob, bool thrownOnError) + { + return (byte[])_getOutgoingBlob.Invoke(_instance, new object[] { incomingBlob, thrownOnError }); + } + + public bool IsCompleted + { + get => (bool)_isCompleted.Invoke(_instance, Array.Empty()); + } + + public string Protocol + { + get => (string)_protocol.Invoke(_instance, Array.Empty()); + } + + public IIdentity GetIdentity() + { + return (IIdentity)_getIdentity.Invoke(obj: null, parameters: new object[] { _instance }); + } + + public void Dispose() + { + _closeContext.Invoke(_instance, Array.Empty()); + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateStateFactory.cs b/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateStateFactory.cs new file mode 100644 index 000000000000..31fa29d49bb8 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Internal/ReflectedNegotiateStateFactory.cs @@ -0,0 +1,17 @@ +// 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.Collections.Generic; +using System.Text; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + internal class ReflectedNegotiateStateFactory : INegotiateStateFactory + { + public INegotiateState CreateInstance() + { + return new ReflectedNegotiateState(); + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj new file mode 100644 index 000000000000..f71af581a92a --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Microsoft.AspNetCore.Authentication.Negotiate.csproj @@ -0,0 +1,16 @@ + + + + ASP.NET Core authentication handler used to authenticate requests using Negotiate, Kerberos, or NTLM. + netcoreapp3.0 + true + aspnetcore;authentication;security + true + + + + + + + + diff --git a/src/Security/Authentication/Negotiate/src/NegotiateDefaults.cs b/src/Security/Authentication/Negotiate/src/NegotiateDefaults.cs new file mode 100644 index 000000000000..1c119ea87122 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/NegotiateDefaults.cs @@ -0,0 +1,16 @@ +// 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. + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// Default values used by Negotiate authentication. + /// + public static class NegotiateDefaults + { + /// + /// Default value for AuthenticationScheme used to identify the Negotiate auth handler. + /// + public const string AuthenticationScheme = "Negotiate"; + } +} diff --git a/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs new file mode 100644 index 000000000000..ea01f040a85d --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs @@ -0,0 +1,62 @@ +// 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.Authentication; +using Microsoft.AspNetCore.Authentication.Negotiate; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extensions for enabling Negotiate authentication. + /// + public static class NegotiateExtensions + { + /// + /// Adds Negotiate authentication. + /// + /// The . + /// The original builder. + public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder) + => builder.AddNegotiate(NegotiateDefaults.AuthenticationScheme, _ => { }); + + /// + /// Adds and configures Negotiate authentication. + /// + /// The . + /// Allows for configuring the authentication handler. + /// The original builder. + public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, Action configureOptions) + => builder.AddNegotiate(NegotiateDefaults.AuthenticationScheme, configureOptions); + + /// + /// Adds and configures Negotiate authentication. + /// + /// The . + /// The scheme name used to identify the authentication handler internally. + /// Allows for configuring the authentication handler. + /// The original builder. + public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) + => builder.AddNegotiate(authenticationScheme, displayName: null, configureOptions: configureOptions); + + /// + /// Adds and configures Negotiate authentication. + /// + /// The . + /// The scheme name used to identify the authentication handler internally. + /// The name displayed to users when selecting an authentication handler. The default is null to prevent this from displaying. + /// Allows for configuring the authentication handler. + /// The original builder. + public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable($"ASPNETCORE_TOKEN"))) + { + throw new NotSupportedException( + "The Negotiate authentication handler must not be used with IIS out-of-process mode or similar reverse proxies that share connections between users." + + " Use the Windows Authentication features available within IIS or IIS Express."); + } + + return builder.AddScheme(authenticationScheme, displayName, configureOptions); + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs new file mode 100644 index 000000000000..27844382fb54 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/NegotiateHandler.cs @@ -0,0 +1,340 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// Authenticates requests using Negotiate, Kerberos, or NTLM. + /// + public class NegotiateHandler : AuthenticationHandler, IAuthenticationRequestHandler + { + private const string AuthPersistenceKey = nameof(AuthPersistence); + private const string NegotiateVerb = "Negotiate"; + private const string AuthHeaderPrefix = NegotiateVerb + " "; + + private bool _requestProcessed; + private INegotiateState _negotiateState; + + /// + /// Creates a new + /// + /// + /// + /// + /// + public NegotiateHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) + : base(options, logger, encoder, clock) + { } + + /// + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// If it is not provided a default instance is supplied which does nothing when the methods are called. + /// + protected new NegotiateEvents Events + { + get => (NegotiateEvents)base.Events; + set => base.Events = value; + } + + /// + /// Creates the default events type. + /// + /// + protected override Task CreateEventsAsync() => Task.FromResult(new NegotiateEvents()); + + private bool IsHttp2 => string.Equals("HTTP/2", Request.Protocol, StringComparison.OrdinalIgnoreCase); + + /// + /// Intercepts incomplete Negotiate authentication handshakes and continues or completes them. + /// + /// True if a response was generated, false otherwise. + public async Task HandleRequestAsync() + { + try + { + if (_requestProcessed) + { + // This request was already processed but something is re-executing it like an exception handler. + // Don't re-run because we could corrupt the connection state, e.g. if this was a stage2 NTLM request + // that we've already completed the handshake for. + return false; + } + + _requestProcessed = true; + + if (IsHttp2) + { + // HTTP/2 is not supported. Do not throw because this may be running on a server that supports + // both HTTP/1 and HTTP/2. + return false; + } + + var connectionItems = GetConnectionItems(); + var persistence = (AuthPersistence)connectionItems[AuthPersistenceKey]; + _negotiateState = persistence?.State; + + var authorizationHeader = Request.Headers[HeaderNames.Authorization]; + + if (StringValues.IsNullOrEmpty(authorizationHeader)) + { + if (_negotiateState?.IsCompleted == false) + { + throw new InvalidOperationException("An anonymous request was received in between authentication handshake requests."); + } + return false; + } + + var authorization = authorizationHeader.ToString(); + string token = null; + if (authorization.StartsWith(AuthHeaderPrefix, StringComparison.OrdinalIgnoreCase)) + { + token = authorization.Substring(AuthHeaderPrefix.Length).Trim(); + } + else + { + if (_negotiateState?.IsCompleted == false) + { + throw new InvalidOperationException("Non-negotiate request was received in between authentication handshake requests."); + } + return false; + } + + // WinHttpHandler re-authenticates an existing connection if it gets another challenge on subsequent requests. + if (_negotiateState?.IsCompleted == true) + { + Logger.Reauthenticating(); + _negotiateState.Dispose(); + _negotiateState = null; + persistence.State = null; + } + + _negotiateState ??= Options.StateFactory.CreateInstance(); + + var outgoing = _negotiateState.GetOutgoingBlob(token); + + if (!_negotiateState.IsCompleted) + { + persistence ??= EstablishConnectionPersistence(connectionItems); + // Save the state long enough to complete the multi-stage handshake. + // We'll remove it once complete if !PersistNtlm/KerberosCredentials. + persistence.State = _negotiateState; + + Logger.IncompleteNegotiateChallenge(); + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.Append(HeaderNames.WWWAuthenticate, AuthHeaderPrefix + outgoing); + return true; + } + + Logger.NegotiateComplete(); + + // There can be a final blob of data we need to send to the client, but let the request execute as normal. + if (!string.IsNullOrEmpty(outgoing)) + { + Response.OnStarting(() => + { + // Only include it if the response ultimately succeeds. This avoids adding it twice if Challenge is called again. + if (Response.StatusCode < StatusCodes.Status400BadRequest) + { + Response.Headers.Append(HeaderNames.WWWAuthenticate, AuthHeaderPrefix + outgoing); + } + return Task.CompletedTask; + }); + } + + // Deal with connection credential persistence. + + if (_negotiateState.Protocol == "NTLM" && !Options.PersistNtlmCredentials) + { + // NTLM was already put in the persitence cache on the prior request so we could complete the handshake. + // Take it out if we don't want it to persist. + Debug.Assert(object.ReferenceEquals(persistence?.State, _negotiateState), + "NTLM is a two stage process, it must have already been in the cache for the handshake to succeed."); + Logger.DisablingCredentialPersistence(_negotiateState.Protocol); + persistence.State = null; + Response.RegisterForDispose(_negotiateState); + } + else if (_negotiateState.Protocol == "Kerberos") + { + // Kerberos can require one or two stage handshakes + if (Options.PersistKerberosCredentials) + { + Logger.EnablingCredentialPersistence(); + persistence ??= EstablishConnectionPersistence(connectionItems); + persistence.State = _negotiateState; + } + else + { + if (persistence?.State != null) + { + Logger.DisablingCredentialPersistence(_negotiateState.Protocol); + persistence.State = null; + } + Response.RegisterForDispose(_negotiateState); + } + } + + // Note we run the Authenticated event in HandleAuthenticateAsync so it is per-request rather than per connection. + } + catch (Exception ex) + { + Logger.ExceptionProcessingAuth(ex); + var errorContext = new AuthenticationFailedContext(Context, Scheme, Options) { Exception = ex }; + await Events.AuthenticationFailed(errorContext); + + if (errorContext.Result != null) + { + if (errorContext.Result.Handled) + { + return true; + } + else if (errorContext.Result.Skipped) + { + return false; + } + else if (errorContext.Result.Failure != null) + { + throw new Exception("An error was returned from the AuthenticationFailed event.", errorContext.Result.Failure); + } + } + + throw; + } + + return false; + } + + /// + /// Checks if the current request is authenticated and returns the user. + /// + /// + protected override async Task HandleAuthenticateAsync() + { + if (!_requestProcessed) + { + throw new InvalidOperationException("AuthenticateAsync must not be called before the UseAuthentication middleware runs."); + } + + if (IsHttp2) + { + // Not supported. We don't throw because Negotiate may be set as the default auth + // handler on a server that's running HTTP/1 and HTTP/2. We'll challenge HTTP/2 requests + // that require auth and they'll downgrade to HTTP/1.1. + return AuthenticateResult.NoResult(); + } + + if (_negotiateState == null) + { + return AuthenticateResult.NoResult(); + } + + if (!_negotiateState.IsCompleted) + { + // This case should have been rejected by HandleRequestAsync + throw new InvalidOperationException("Attempting to use an incomplete authentication context."); + } + + // Make a new copy of the user for each request, they are mutable objects and + // things like ClaimsTransformation run per request. + var identity = _negotiateState.GetIdentity(); + ClaimsPrincipal user; + if (identity is WindowsIdentity winIdentity) + { + user = new WindowsPrincipal(winIdentity); + Response.RegisterForDispose(winIdentity); + } + else + { + user = new ClaimsPrincipal(new ClaimsIdentity(identity)); + } + + var authenticatedContext = new AuthenticatedContext(Context, Scheme, Options) + { + Principal = user + }; + await Events.Authenticated(authenticatedContext); + + if (authenticatedContext.Result != null) + { + return authenticatedContext.Result; + } + + var ticket = new AuthenticationTicket(authenticatedContext.Principal, authenticatedContext.Properties, Scheme.Name); + return AuthenticateResult.Success(ticket); + } + + /// + /// Issues a 401 WWW-Authenticate Negotiate challenge. + /// + /// + /// + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + // We allow issuing a challenge from an HTTP/2 request. Browser clients will gracefully downgrade to HTTP/1.1. + // SocketHttpHandler will not downgrade (https://github.com/dotnet/corefx/issues/35195), but WinHttpHandler will. + var eventContext = new ChallengeContext(Context, Scheme, Options, properties); + await Events.Challenge(eventContext); + if (eventContext.Handled) + { + return; + } + + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.Append(HeaderNames.WWWAuthenticate, NegotiateVerb); + Logger.ChallengeNegotiate(); + } + + private AuthPersistence EstablishConnectionPersistence(IDictionary items) + { + Debug.Assert(!items.ContainsKey(AuthPersistenceKey), "This should only be registered once per connection"); + var persistence = new AuthPersistence(); + RegisterForConnectionDispose(persistence); + items[AuthPersistenceKey] = persistence; + return persistence; + } + + private IDictionary GetConnectionItems() + { + return Context.Features.Get()?.Items + ?? throw new NotSupportedException($"Negotiate authentication requires a server that supports {nameof(IConnectionItemsFeature)} like Kestrel."); + } + + private void RegisterForConnectionDispose(IDisposable authState) + { + var connectionCompleteFeature = Context.Features.Get() + ??throw new NotSupportedException($"Negotiate authentication requires a server that supports {nameof(IConnectionCompleteFeature)} like Kestrel."); + connectionCompleteFeature.OnCompleted(DisposeState, authState); + } + + private static Task DisposeState(object state) + { + ((IDisposable)state).Dispose(); + return Task.CompletedTask; + } + + // This allows us to have one disposal registration per connection and limits churn on the Items collection. + private class AuthPersistence : IDisposable + { + internal INegotiateState State { get; set; } + + public void Dispose() + { + State?.Dispose(); + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs b/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs new file mode 100644 index 000000000000..78df48589798 --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/NegotiateOptions.cs @@ -0,0 +1,39 @@ +// 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. + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + /// + /// Options class provides information needed to control Negotiate Authentication handler behavior + /// + public class NegotiateOptions : AuthenticationSchemeOptions + { + /// + /// The object provided by the application to process events raised by the negotiate authentication handler. + /// The application may use the existing NegotiateEvents instance and assign delegates only to the events it + /// wants to process. The application may also replace it with its own derived instance. + /// + public new NegotiateEvents Events + { + get { return (NegotiateEvents)base.Events; } + set { base.Events = value; } + } + + /// + /// Indicates if Kerberos credentials should be persisted and re-used for subsquent anonymous requests. + /// This option must not be used if connections may be shared by requests from different users. + /// The default is false. + /// + public bool PersistKerberosCredentials { get; set; } = false; + + /// + /// Indicates if NTLM credentials should be persisted and re-used for subsquent anonymous requests. + /// This option must not be used if connections may be shared by requests from different users. + /// The default is true. + /// + public bool PersistNtlmCredentials { get; set; } = true; + + // For testing + internal INegotiateStateFactory StateFactory { get; set; } = new ReflectedNegotiateStateFactory(); + } +} diff --git a/src/Security/Authentication/Negotiate/src/Properties/AssemblyInfo.cs b/src/Security/Authentication/Negotiate/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..626e2fd7ed1c --- /dev/null +++ b/src/Security/Authentication/Negotiate/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// 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.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Authentication.Negotiate.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineReadMe.md b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineReadMe.md new file mode 100644 index 000000000000..e263a2c5f7b1 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineReadMe.md @@ -0,0 +1,48 @@ +Cross Machine Tests + +Kerberos can only be tested in a multi-machine environment. On localhost it always falls back to NTLM which has different requirements. Multi-machine is also neccisary for interop testing across OSs. Kerberos also requires domain controler SPN configuration so we can't test it on arbitrary test boxes. + +Test structure: +- A remote test server with various endpoints with different authentication restrictions. +- A remote test client with endpoints that execute specific scenarios. The input for these endpoints is theory data. The output is either 200Ok, or a failure code and desciption. +- The CrossMachineTest class that drives the tests. It invokes the client app with the theory data and confirms the results. + +We use these three components beceause it allows us to run the tests from a dev machine or CI agent that is not part of the dedicated test domain/environment. + +(Static) Environment Setup: +- Warning, this environment can take a day to set up. That's why we want a static test environment that we can re-use. +- Create a Windows server running DNS and Active Directory. Promote it to a domain controller. + - Create an SPN on this machine for Windows -> Windows testing. `setspn -S "http/chrross-dc.crkerberos.com" -U administrator` + - Future: Can we replace the domain controller with an AAD instance? We'd still want a second windows machine for Windows -> Windows testing, but AAD might be easier to configure. + - https://docs.microsoft.com/en-us/azure/active-directory-domain-services/active-directory-ds-getting-started + - https://docs.microsoft.com/en-us/azure/active-directory-domain-services/active-directory-ds-join-ubuntu-linux-vm + - https://docs.microsoft.com/en-us/azure/active-directory-domain-services/active-directory-ds-enable-kcd +- Create another Windows machine and join it to the test domain. +- Create a Linux machine and joing it to the domain. Ubuntu 18.04 has been used in the past. + - https://www.safesquid.com/content-filtering/integrating-linux-host-windows-ad-kerberos-sso-authentication + - Include an HTTP SPN + +Test deployment variations, prioritized: +- Windows -> Linux +- Windows -> Windows +- Localhost Windows -> Windows +Future: +- Note the Linux HttpClient doesn't support default credentials, you have to update Negotiate.Client to provide explicit credentials. +- Linux -> Windows +- Linux -> Linux +- Localhost Linux -> Linux + +Test run setup: +- Publish Negotiate.Client as a standalone application targeting the OS you want to run it on. Copy it to that machine and run it. + - Make sure it's running on a public IP and that the port is not blocked by the firewall. + - Note we primarily care about having the server on the latest runtime. Publishing the client the same way is convenient but not required. We do want to update it periodically for interop testing. + - HTTPS is optional for this client. +- Publish Negotiate.Server as a standalone application targeting the OS you want to run it on. Copy it to that machine and run it. + - Make sure it's running on a public IP and that the port is not blocked by the firewall. + - Note the server app starts two server instances, one with connection persistence enabled and the other with it disabled. + - HTTPS is needed on the server for some HTTP/2 downgrade tests. These tests can be ignored if HTTPS is not conveniently available. + - Future: Automate remote publishing +- In CrossMachineTests: + - Set ClientAddress, ServerPersistAddress, and ServerNonPersistAddress. (Future: Pull from environment variables?) + - UnSkip the test cases. (Future: Make these conditional on the test environment being available, environment variables?) +- Run tests! diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineTests.cs new file mode 100644 index 000000000000..0bfed42c0311 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/CrossMachineTests.cs @@ -0,0 +1,150 @@ +// 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.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + // The OS's being tested are on other machines, don't duplicate the tests across runs. + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10)] + // See CrossMachineReadMe.md + public class CrossMachineTests + { + private const string Http11Version = "HTTP/1.1"; + private const string Http2Version = "HTTP/2"; + + private const string ClientAddress = + // "http://chrross-udesk:5004"; + "https://localhost:5005"; + private const string ServerName = + "chrross-dc"; + // "chrross-udesk"; + private static readonly string ServerPersistAddress = $"http://{ServerName}.CRKerberos.com:5000"; + private static readonly string ServerNonPersistAddress = $"http://{ServerName}.CRKerberos.com:5002"; + + public static IEnumerable Http11And2 => + new List + { + new object[] { Http11Version }, + new object[] { Http2Version }, + }; + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(Http11And2))] + public Task Anonymous_NoChallenge_NoOps(string protocol) + { + return RunTest(protocol, "/Anonymous/Unrestricted"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(Http11And2))] + public Task Anonymous_Challenge_401Negotiate(string protocol) + { + return RunTest(protocol, "/Anonymous/Authorized"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(Http11And2))] + public Task DefautCredentials_Success(string protocol) + { + return RunTest(protocol, "/DefaultCredentials/Authorized"); + } + + public static IEnumerable HttpOrders => + new List + { + new object[] { Http11Version, Http11Version }, + new object[] { Http11Version, Http2Version }, + new object[] { Http2Version, Http11Version }, + }; + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(HttpOrders))] + // AuthorizedRequestAfterAuth_ReUses1WithPersistence would give the same results + public Task UrestrictedRequestAfterAuth_ReUses1WithPersistence(string protocol1, string protocol2) + { + return RunTest(ServerPersistAddress, protocol1, protocol2, "/AfterAuth/Unrestricted/Persist"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(HttpOrders))] + public Task UrestrictedRequestAfterAuth_AnonymousWhenNotPersisted(string protocol1, string protocol2) + { + return RunTest(ServerNonPersistAddress, protocol1, protocol2, "/AfterAuth/Unrestricted/NonPersist"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(HttpOrders))] + public Task AuthorizedRequestAfterAuth_ReauthenticatesWhenNotPersisted(string protocol1, string protocol2) + { + return RunTest(ServerNonPersistAddress, protocol1, protocol2, "/AfterAuth/Authorized/NonPersist"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(Http11And2))] + public Task Unauthorized_401Negotiate(string protocol) + { + return RunTest(protocol, "/Unauthorized"); + } + + [ConditionalTheory(Skip = "Manual testing only")] + [MemberData(nameof(Http11And2))] + public Task UnauthorizedAfterAuthenticated_Success(string protocol) + { + return RunTest(protocol, "/AfterAuth/Unauthorized", persist: true); + } + + private Task RunTest(string protocol, string path, bool persist = false) + { + var queryBuilder = new QueryBuilder + { + { "server", persist ? ServerPersistAddress : ServerNonPersistAddress }, + { "protocol", protocol } + }; + + return RunTest(path, queryBuilder); + } + + private Task RunTest(string server, string protocol1, string protocol2, string path) + { + var queryBuilder = new QueryBuilder + { + { "server", server }, + { "protocol1", protocol1 }, + { "protocol2", protocol2 } + }; + + return RunTest(path, queryBuilder); + } + + private async Task RunTest(string path, QueryBuilder queryBuilder) + { + using var client = CreateClient(ClientAddress); + + var response = await client.GetAsync("/authtest" + path + queryBuilder.ToString()); + var body = await response.Content.ReadAsStringAsync(); + + Assert.True(HttpStatusCode.OK == response.StatusCode, $"{response.StatusCode}: {body}"); + } + + private static HttpClient CreateClient(string address) + { + return new HttpClient(new HttpClientHandler() + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }) + { + BaseAddress = new Uri(address), + DefaultRequestVersion = new Version(2, 0), + }; + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest.csproj b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest.csproj new file mode 100644 index 000000000000..24c387964fb7 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp3.0 + true + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/NegotiateHandlerFunctionalTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/NegotiateHandlerFunctionalTests.cs new file mode 100644 index 000000000000..742bb426b07a --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/NegotiateHandlerFunctionalTests.cs @@ -0,0 +1,307 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + // In theory this would work on Linux and Mac, but the client would require explicit credentials. + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public class NegotiateHandlerFunctionalTests + { + private static readonly Version Http11Version = new Version(1, 1); + private static readonly Version Http2Version = new Version(2, 0); + + public static IEnumerable Http11And2 => + new List + { + new object[] { Http11Version }, + new object[] { Http2Version }, + }; + + [ConditionalTheory] + [MemberData(nameof(Http11And2))] + public async Task Anonymous_NoChallenge_NoOps(Version version) + { + using var host = await CreateHostAsync(); + using var client = CreateSocketHttpClient(host); + client.DefaultRequestVersion = version; + + var result = await client.GetAsync("/Anonymous" + version.Major); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.False(result.Headers.Contains(HeaderNames.WWWAuthenticate)); + Assert.Equal(version, result.Version); + } + + [ConditionalTheory] + [MemberData(nameof(Http11And2))] + public async Task Anonymous_Challenge_401Negotiate(Version version) + { + using var host = await CreateHostAsync(); + // WinHttpHandler can't disable default credentials on localhost, use SocketHttpHandler. + using var client = CreateSocketHttpClient(host); + client.DefaultRequestVersion = version; + + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Negotiate", result.Headers.WwwAuthenticate.ToString()); + Assert.Equal(version, result.Version); + } + + [ConditionalTheory] + [MemberData(nameof(Http11And2))] + public async Task DefautCredentials_Success(Version version) + { + using var host = await CreateHostAsync(); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = version; + + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); // HTTP/2 downgrades. + } + + public static IEnumerable HttpOrders => + new List + { + new object[] { Http11Version, Http11Version }, + new object[] { Http11Version, Http2Version }, + new object[] { Http2Version, Http11Version }, + }; + + [ConditionalTheory] + [MemberData(nameof(HttpOrders))] + public async Task RequestAfterAuth_ReUses1WithPersistence(Version first, Version second) + { + using var host = await CreateHostAsync(options => options.PersistNtlmCredentials = true); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = first; + + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); // Http/2 downgrades + + // Re-uses the 1.1 connection. + result = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/AlreadyAuthenticated") { Version = second }); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); + } + + [ConditionalTheory] + [MemberData(nameof(HttpOrders))] + public async Task RequestAfterAuth_ReauthenticatesWhenNotPersisted(Version first, Version second) + { + using var host = await CreateHostAsync(options => options.PersistNtlmCredentials = false); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = first; + + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); // Http/2 downgrades + + // Re-uses the 1.1 connection. + result = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/Authenticate") { Version = second }); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task RequestAfterAuth_Http2Then2_Success(bool persistNtlm) + { + using var host = await CreateHostAsync(options => options.PersistNtlmCredentials = persistNtlm); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = Http2Version; + + // Falls back to HTTP/1.1 after trying HTTP/2. + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); + + // Tries HTTP/2, falls back to HTTP/1.1 and re-authenticates. + result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); + } + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public async Task RequestAfterAuth_Http2Then2Anonymous_Success(bool persistNtlm) + { + using var host = await CreateHostAsync(options => options.PersistNtlmCredentials = persistNtlm); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = Http2Version; + + // Falls back to HTTP/1.1 after trying HTTP/2. + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); + + // Makes an anonymous HTTP/2 request + result = await client.GetAsync("/Anonymous2"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http2Version, result.Version); + } + + [ConditionalTheory] + [MemberData(nameof(Http11And2))] + public async Task Unauthorized_401Negotiate(Version version) + { + using var host = await CreateHostAsync(); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = version; + + var result = await client.GetAsync("/Unauthorized"); + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Negotiate", result.Headers.WwwAuthenticate.ToString()); + Assert.Equal(Http11Version, result.Version); // HTTP/2 downgrades. + } + + [ConditionalTheory] + [MemberData(nameof(Http11And2))] + public async Task UnauthorizedAfterAuthenticated_Success(Version version) + { + using var host = await CreateHostAsync(); + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade. WinHttpHandler does. + using var client = CreateWinHttpClient(host); + client.DefaultRequestVersion = version; + + var result = await client.GetAsync("/Authenticate"); + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + Assert.Equal(Http11Version, result.Version); // HTTP/2 downgrades. + + result = await client.GetAsync("/Unauthorized"); + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Negotiate", result.Headers.WwwAuthenticate.ToString()); + Assert.Equal(Http11Version, result.Version); // HTTP/2 downgrades. + } + + private static Task CreateHostAsync(Action configureOptions = null) + { + var builder = new HostBuilder() + .ConfigureServices(services => services + .AddRouting() + .AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(configureOptions)) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, endpoint => + { + endpoint.UseHttps("testCert.pfx", "testPassword"); + }); + }); + webHostBuilder.Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseEndpoints(ConfigureEndpoints); + }); + }); + + return builder.StartAsync(); + } + + private static void ConfigureEndpoints(IEndpointRouteBuilder builder) + { + builder.Map("/Anonymous1", context => + { + Assert.Equal("HTTP/1.1", context.Request.Protocol); + Assert.False(context.User.Identity.IsAuthenticated, "Anonymous"); + return Task.CompletedTask; + }); + + builder.Map("/Anonymous2", context => + { + Assert.Equal("HTTP/2", context.Request.Protocol); + Assert.False(context.User.Identity.IsAuthenticated, "Anonymous"); + return Task.CompletedTask; + }); + + builder.Map("/Authenticate", 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"); + await context.Response.WriteAsync(name); + }); + + builder.Map("/AlreadyAuthenticated", async context => + { + Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2 + Assert.True(context.User.Identity.IsAuthenticated, "Authenticated"); + var name = context.User.Identity.Name; + Assert.False(string.IsNullOrEmpty(name), "name"); + await context.Response.WriteAsync(name); + }); + + builder.Map("/Unauthorized", async context => + { + // Simulate Authorization failure + var result = await context.AuthenticateAsync(); + await context.ChallengeAsync(); + }); + } + + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade. WinHttpHandler does. + private static HttpClient CreateWinHttpClient(IHost host) + { + var address = host.Services.GetRequiredService().Features.Get().Addresses.First(); + + // WinHttpHandler always uses default credentials on localhost + return new HttpClient(new WinHttpHandler() + { + ServerCertificateValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }) + { + BaseAddress = new Uri(address) + }; + } + + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade. WinHttpHandler does. + private static HttpClient CreateSocketHttpClient(IHost host, bool useDefaultCredentials = false) + { + var address = host.Services.GetRequiredService().Features.Get().Addresses.First(); + + return new HttpClient(new HttpClientHandler() + { + UseDefaultCredentials = useDefaultCredentials, + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }) + { + BaseAddress = new Uri(address) + }; + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/testCert.pfx b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/testCert.pfx new file mode 100644 index 000000000000..888ccb032a97 Binary files /dev/null and b/src/Security/Authentication/Negotiate/test/Negotiate.FunctionalTest/testCert.pfx differ diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs new file mode 100644 index 000000000000..adc562d0dd04 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/EventTests.cs @@ -0,0 +1,393 @@ +// 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.Collections.Generic; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + public class EventTests + { + [Fact] + public async Task OnChallenge_Fires() + { + var eventInvoked = false; + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnChallenge = context => + { + // Not changed yet + eventInvoked = true; + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate)); + return Task.CompletedTask; + } + }; + }); + + var result = await SendAsync(server, "/Authenticate", new TestConnection()); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + Assert.True(eventInvoked); + } + + [Fact] + public async Task OnChallenge_Handled() + { + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnChallenge = context => + { + context.Response.StatusCode = StatusCodes.Status418ImATeapot; + context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot"; + context.HandleResponse(); + return Task.CompletedTask; + } + }; + }); + + var result = await SendAsync(server, "/Authenticate", new TestConnection()); + Assert.Equal(StatusCodes.Status418ImATeapot, result.Response.StatusCode); + Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Fact] + public async Task OnAuthenticationFailed_Fires() + { + var eventInvoked = false; + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnAuthenticationFailed = context => + { + eventInvoked = true; + Assert.IsType(context.Exception); + Assert.Equal("InvalidBlob", context.Exception.Message); + return Task.CompletedTask; + } + }; + }); + + var ex = await Assert.ThrowsAsync(() => + SendAsync(server, "/404", new TestConnection(), "Negotiate InvalidBlob")); + Assert.Equal("InvalidBlob", ex.Message); + Assert.True(eventInvoked); + } + + [Fact] + public async Task OnAuthenticationFailed_Handled() + { + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnAuthenticationFailed = context => + { + context.Response.StatusCode = StatusCodes.Status418ImATeapot; ; + context.Response.Headers[HeaderNames.WWWAuthenticate] = "Teapot"; + context.HandleResponse(); + return Task.CompletedTask; + } + }; + }); + + var result = await SendAsync(server, "/404", new TestConnection(), "Negotiate InvalidBlob"); + Assert.Equal(StatusCodes.Status418ImATeapot, result.Response.StatusCode); + Assert.Equal("Teapot", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Fact] + public async Task OnAuthenticated_FiresOncePerRequest() + { + var callCount = 0; + var server = await CreateServerAsync(options => + { + options.PersistKerberosCredentials = true; + options.Events = new NegotiateEvents() + { + OnAuthenticated = context => + { + var identity = context.Principal.Identity; + Assert.True(identity.IsAuthenticated); + Assert.Equal("name", identity.Name); + Assert.Equal("Kerberos", identity.AuthenticationType); + callCount++; + return Task.CompletedTask; + } + }; + }); + + var testConnection = new TestConnection(); + await KerberosStage1And2Auth(server, testConnection); + var result = await SendAsync(server, "/Authenticate", testConnection); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.False(result.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate)); + Assert.Equal(2, callCount); + } + + [Fact] + public async Task OnAuthenticated_Success_Continues() + { + var callCount = 0; + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnAuthenticated = context => + { + context.Success(); + callCount++; + return Task.CompletedTask; + } + }; + }); + + await KerberosStage1And2Auth(server, new TestConnection()); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task OnAuthenticated_NoResult_SuppresesCredentials() + { + var callCount = 0; + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnAuthenticated = context => + { + context.NoResult(); + callCount++; + return Task.CompletedTask; + } + }; + }); + + var result = await SendAsync(server, "/Authenticate", new TestConnection(), "Negotiate ClientKerberosBlob"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task OnAuthenticated_Fail_SuppresesCredentials() + { + var callCount = 0; + var server = await CreateServerAsync(options => + { + options.Events = new NegotiateEvents() + { + OnAuthenticated = context => + { + callCount++; + context.Fail("Event error."); + return Task.CompletedTask; + } + }; + }); + + var result = await SendAsync(server, "/Authenticate", new TestConnection(), "Negotiate ClientKerberosBlob"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + Assert.Equal(1, callCount); + } + + private static async Task KerberosStage1And2Auth(TestServer server, TestConnection testConnection) + { + await KerberosStage1Auth(server, testConnection); + await KerberosStage2Auth(server, testConnection); + } + + private static async Task KerberosStage1Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientKerberosBlob1"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate ServerKerberosBlob1", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task KerberosStage2Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientKerberosBlob2"); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.Equal("Negotiate ServerKerberosBlob2", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task CreateServerAsync(Action configureOptions = null) + { + var builder = new HostBuilder() + .ConfigureServices(services => services + .AddRouting() + .AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(options => + { + options.StateFactory = new TestNegotiateStateFactory(); + configureOptions?.Invoke(options); + })) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseTestServer(); + webHostBuilder.Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseEndpoints(ConfigureEndpoints); + }); + }); + + var server = (await builder.StartAsync()).GetTestServer(); + return server; + } + + private static void ConfigureEndpoints(IEndpointRouteBuilder builder) + { + builder.Map("/Authenticate", 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"); + await context.Response.WriteAsync(name); + }); + } + + private static Task SendAsync(TestServer server, string path, TestConnection connection, string authorizationHeader = null) + { + return server.SendAsync(context => + { + context.Request.Path = path; + if (!string.IsNullOrEmpty(authorizationHeader)) + { + context.Request.Headers[HeaderNames.Authorization] = authorizationHeader; + } + if (connection != null) + { + context.Features.Set(connection); + context.Features.Set(connection); + } + }); + } + + private class TestConnection : IConnectionItemsFeature, IConnectionCompleteFeature + { + public IDictionary Items { get; set; } = new ConnectionItems(); + + public void OnCompleted(Func callback, object state) + { + } + } + + private class TestNegotiateStateFactory : INegotiateStateFactory + { + public INegotiateState CreateInstance() => new TestNegotiateState(); + } + + private class TestNegotiateState : INegotiateState + { + private string _protocol; + private bool Stage1Complete { get; set; } + public bool IsCompleted { get; private set; } + public bool IsDisposed { get; private set; } + + public string Protocol + { + get + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (!Stage1Complete) + { + throw new InvalidOperationException("Authentication has not started yet."); + } + return _protocol; + } + } + + public void Dispose() + { + IsDisposed = true; + } + + public IIdentity GetIdentity() + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (!IsCompleted) + { + throw new InvalidOperationException("Authentication is not complete."); + } + return new GenericIdentity("name", _protocol); + } + + public string GetOutgoingBlob(string incomingBlob) + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (IsCompleted) + { + throw new InvalidOperationException("Authentication is already complete."); + } + switch (incomingBlob) + { + case "ClientNtlmBlob1": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + Stage1Complete = true; + _protocol = "NTLM"; + return "ServerNtlmBlob1"; + case "ClientNtlmBlob2": + Assert.True(Stage1Complete, nameof(Stage1Complete)); + Assert.Equal("NTLM", _protocol); + IsCompleted = true; + return "ServerNtlmBlob2"; + // Kerberos can require one or two stages + case "ClientKerberosBlob": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + _protocol = "Kerberos"; + Stage1Complete = true; + IsCompleted = true; + return "ServerKerberosBlob"; + case "ClientKerberosBlob1": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + _protocol = "Kerberos"; + Stage1Complete = true; + return "ServerKerberosBlob1"; + case "ClientKerberosBlob2": + Assert.True(Stage1Complete, nameof(Stage1Complete)); + Assert.Equal("Kerberos", _protocol); + IsCompleted = true; + return "ServerKerberosBlob2"; + default: + throw new InvalidOperationException(incomingBlob); + } + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj b/src/Security/Authentication/Negotiate/test/Negotiate.Test/Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj new file mode 100644 index 000000000000..f0f681cda488 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj @@ -0,0 +1,19 @@ + + + + netcoreapp3.0 + + + + + + + + + + + + + + + diff --git a/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs new file mode 100644 index 000000000000..bced9c7608cb --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/Negotiate.Test/NegotiateHandlerTests.cs @@ -0,0 +1,506 @@ +// 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.Collections.Generic; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Net.Http.Headers; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Authentication.Negotiate +{ + public class NegotiateHandlerTests + { + [Fact] + public async Task Anonymous_MissingConnectionFeatures_ThrowsNotSupported() + { + var server = await CreateServerAsync(); + + var ex = await Assert.ThrowsAsync(() => SendAsync(server, "/Anonymous1", connection: null)); + Assert.Equal("Negotiate authentication requires a server that supports IConnectionItemsFeature like Kestrel.", ex.Message); + } + + [Fact] + public async Task Anonymous_NoChallenge_NoOps() + { + var server = await CreateServerAsync(); + + var result = await SendAsync(server, "/Anonymous1", new TestConnection()); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + } + + [Fact] + public async Task Anonymous_Http2_NoOps() + { + var server = await CreateServerAsync(); + + var result = await SendAsync(server, "/Anonymous2", connection: null, http2: true); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + } + + [Fact] + public async Task Anonymous_Challenge_401Negotiate() + { + var server = await CreateServerAsync(); + + var result = await SendAsync(server, "/Authenticate", new TestConnection()); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Fact] + public async Task Anonymous_ChallengeHttp2_401Negotiate() + { + var server = await CreateServerAsync(); + + var result = await SendAsync(server, "/Authenticate", connection: null, http2: true); + // Clients will downgrade to HTTP/1.1 and authenticate. + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Fact] + public async Task NtlmStage1Auth_401NegotiateServerBlob1() + { + var server = await CreateServerAsync(); + var result = await SendAsync(server, "/404", new TestConnection(), "Negotiate ClientNtlmBlob1"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate ServerNtlmBlob1", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Fact] + public async Task AnonymousAfterNtlmStage1_Throws() + { + var server = await CreateServerAsync(); + var testConnection = new TestConnection(); + await NtlmStage1Auth(server, testConnection); + + var ex = await Assert.ThrowsAsync(() => SendAsync(server, "/404", testConnection)); + Assert.Equal("An anonymous request was received in between authentication handshake requests.", ex.Message); + } + + [Fact] + public async Task NtlmStage2Auth_WithoutStage1_Throws() + { + var server = await CreateServerAsync(); + + var ex = await Assert.ThrowsAsync(() => SendAsync(server, "/404", new TestConnection(), "Negotiate ClientNtlmBlob2")); + Assert.Equal("Stage1Complete", ex.UserMessage); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NtlmStage1And2Auth_Success(bool persistNtlm) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = persistNtlm); + var testConnection = new TestConnection(); + await NtlmStage1And2Auth(server, testConnection); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task KerberosAuth_Success(bool persistKerberos) + { + var server = await CreateServerAsync(options => options.PersistKerberosCredentials = persistKerberos); + var testConnection = new TestConnection(); + await KerberosAuth(server, testConnection); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task KerberosTwoStageAuth_Success(bool persistKerberos) + { + var server = await CreateServerAsync(options => options.PersistKerberosCredentials = persistKerberos); + var testConnection = new TestConnection(); + await KerberosStage1And2Auth(server, testConnection); + } + + [Theory] + [InlineData("NTLM")] + [InlineData("Kerberos")] + [InlineData("Kerberos2")] + public async Task AnonymousAfterCompletedPersist_Cached(string protocol) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = options.PersistKerberosCredentials = true); + var testConnection = new TestConnection(); + if (protocol == "NTLM") + { + await NtlmStage1And2Auth(server, testConnection); + } + else if (protocol == "Kerberos2") + { + await KerberosStage1And2Auth(server, testConnection); + } + else + { + await KerberosAuth(server, testConnection); + } + + var result = await SendAsync(server, "/Authenticate", testConnection); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.False(result.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate)); + } + + [Theory] + [InlineData("NTLM")] + [InlineData("Kerberos")] + [InlineData("Kerberos2")] + public async Task AnonymousAfterCompletedNoPersist_Denied(string protocol) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = options.PersistKerberosCredentials = false); + var testConnection = new TestConnection(); + if (protocol == "NTLM") + { + await NtlmStage1And2Auth(server, testConnection); + } + else if (protocol == "Kerberos2") + { + await KerberosStage1And2Auth(server, testConnection); + } + else + { + await KerberosAuth(server, testConnection); + } + + var result = await SendAsync(server, "/Authenticate", testConnection); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthHeaderAfterNtlmCompleted_ReAuthenticates(bool persist) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = persist); + var testConnection = new TestConnection(); + await NtlmStage1And2Auth(server, testConnection); + await NtlmStage1And2Auth(server, testConnection); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthHeaderAfterKerberosCompleted_ReAuthenticates(bool persist) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = persist); + var testConnection = new TestConnection(); + await KerberosAuth(server, testConnection); + await KerberosAuth(server, testConnection); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AuthHeaderAfterKerberos2StageCompleted_ReAuthenticates(bool persist) + { + var server = await CreateServerAsync(options => options.PersistNtlmCredentials = persist); + var testConnection = new TestConnection(); + await KerberosStage1And2Auth(server, testConnection); + await KerberosStage1And2Auth(server, testConnection); + } + + [Fact] + public async Task ApplicationExceptionReExecute_AfterComplete_DoesntReRun() + { + var builder = new HostBuilder() + .ConfigureServices(services => services + .AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(options => + { + options.StateFactory = new TestNegotiateStateFactory(); + })) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseTestServer(); + webHostBuilder.Configure(app => + { + app.UseExceptionHandler("/error"); + app.UseAuthentication(); + app.Run(context => + { + Assert.True(context.User.Identity.IsAuthenticated); + if (context.Request.Path.Equals("/error")) + { + return context.Response.WriteAsync("Error Handler"); + } + + throw new TimeZoneNotFoundException(); + }); + }); + }); + + var server = (await builder.StartAsync()).GetTestServer(); + + var testConnection = new TestConnection(); + await NtlmStage1Auth(server, testConnection); + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientNtlmBlob2"); + Assert.Equal(StatusCodes.Status500InternalServerError, result.Response.StatusCode); + Assert.False(result.Response.Headers.ContainsKey(HeaderNames.WWWAuthenticate)); + } + + // Single Stage + private static async Task KerberosAuth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientKerberosBlob"); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.Equal("Negotiate ServerKerberosBlob", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task KerberosStage1And2Auth(TestServer server, TestConnection testConnection) + { + await KerberosStage1Auth(server, testConnection); + await KerberosStage2Auth(server, testConnection); + } + + private static async Task KerberosStage1Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientKerberosBlob1"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate ServerKerberosBlob1", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task KerberosStage2Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientKerberosBlob2"); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.Equal("Negotiate ServerKerberosBlob2", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task NtlmStage1And2Auth(TestServer server, TestConnection testConnection) + { + await NtlmStage1Auth(server, testConnection); + await NtlmStage2Auth(server, testConnection); + } + + private static async Task NtlmStage1Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/404", testConnection, "Negotiate ClientNtlmBlob1"); + Assert.Equal(StatusCodes.Status401Unauthorized, result.Response.StatusCode); + Assert.Equal("Negotiate ServerNtlmBlob1", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task NtlmStage2Auth(TestServer server, TestConnection testConnection) + { + var result = await SendAsync(server, "/Authenticate", testConnection, "Negotiate ClientNtlmBlob2"); + Assert.Equal(StatusCodes.Status200OK, result.Response.StatusCode); + Assert.Equal("Negotiate ServerNtlmBlob2", result.Response.Headers[HeaderNames.WWWAuthenticate]); + } + + private static async Task CreateServerAsync(Action configureOptions = null) + { + var builder = new HostBuilder() + .ConfigureServices(services => services + .AddRouting() + .AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(options => + { + options.StateFactory = new TestNegotiateStateFactory(); + configureOptions?.Invoke(options); + })) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder.UseTestServer(); + webHostBuilder.Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseEndpoints(ConfigureEndpoints); + }); + }); + + var server = (await builder.StartAsync()).GetTestServer(); + return server; + } + + private static void ConfigureEndpoints(IEndpointRouteBuilder builder) + { + builder.Map("/Anonymous1", context => + { + Assert.Equal("HTTP/1.1", context.Request.Protocol); + Assert.False(context.User.Identity.IsAuthenticated, "Anonymous"); + return Task.CompletedTask; + }); + + builder.Map("/Anonymous2", context => + { + Assert.Equal("HTTP/2", context.Request.Protocol); + Assert.False(context.User.Identity.IsAuthenticated, "Anonymous"); + return Task.CompletedTask; + }); + + builder.Map("/Authenticate", 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"); + await context.Response.WriteAsync(name); + }); + + builder.Map("/AlreadyAuthenticated", async context => + { + Assert.Equal("HTTP/1.1", context.Request.Protocol); // Not HTTP/2 + Assert.True(context.User.Identity.IsAuthenticated, "Authenticated"); + var name = context.User.Identity.Name; + Assert.False(string.IsNullOrEmpty(name), "name"); + await context.Response.WriteAsync(name); + }); + + builder.Map("/Unauthorized", async context => + { + // Simulate Authorization failure + var result = await context.AuthenticateAsync(); + await context.ChallengeAsync(); + }); + + builder.Map("/SignIn", context => + { + return Assert.ThrowsAsync(() => context.SignInAsync(new ClaimsPrincipal())); + }); + + builder.Map("/signOut", context => + { + return Assert.ThrowsAsync(() => context.SignOutAsync()); + }); + } + + private static Task SendAsync(TestServer server, string path, TestConnection connection, string authorizationHeader = null, bool http2 = false) + { + return server.SendAsync(context => + { + context.Request.Protocol = http2 ? "HTTP/2" : "HTTP/1.1"; + context.Request.Path = path; + if (!string.IsNullOrEmpty(authorizationHeader)) + { + context.Request.Headers[HeaderNames.Authorization] = authorizationHeader; + } + if (connection != null) + { + context.Features.Set(connection); + context.Features.Set(connection); + } + }); + } + + private class TestConnection : IConnectionItemsFeature, IConnectionCompleteFeature + { + public IDictionary Items { get; set; } = new ConnectionItems(); + + public void OnCompleted(Func callback, object state) + { + } + } + + private class TestNegotiateStateFactory : INegotiateStateFactory + { + public INegotiateState CreateInstance() => new TestNegotiateState(); + } + + private class TestNegotiateState : INegotiateState + { + private string _protocol; + private bool Stage1Complete { get; set; } + public bool IsCompleted { get; private set; } + public bool IsDisposed { get; private set; } + + public string Protocol + { + get + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (!Stage1Complete) + { + throw new InvalidOperationException("Authentication has not started yet."); + } + return _protocol; + } + } + + public void Dispose() + { + IsDisposed = true; + } + + public IIdentity GetIdentity() + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (!IsCompleted) + { + throw new InvalidOperationException("Authentication is not complete."); + } + return new GenericIdentity("name", _protocol); + } + + public string GetOutgoingBlob(string incomingBlob) + { + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(TestNegotiateState)); + } + if (IsCompleted) + { + throw new InvalidOperationException("Authentication is already complete."); + } + switch (incomingBlob) + { + case "ClientNtlmBlob1": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + Stage1Complete = true; + _protocol = "NTLM"; + return "ServerNtlmBlob1"; + case "ClientNtlmBlob2": + Assert.True(Stage1Complete, nameof(Stage1Complete)); + Assert.Equal("NTLM", _protocol); + IsCompleted = true; + return "ServerNtlmBlob2"; + // Kerberos can require one or two stages + case "ClientKerberosBlob": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + _protocol = "Kerberos"; + Stage1Complete = true; + IsCompleted = true; + return "ServerKerberosBlob"; + case "ClientKerberosBlob1": + Assert.False(Stage1Complete, nameof(Stage1Complete)); + _protocol = "Kerberos"; + Stage1Complete = true; + return "ServerKerberosBlob1"; + case "ClientKerberosBlob2": + Assert.True(Stage1Complete, nameof(Stage1Complete)); + Assert.Equal("Kerberos", _protocol); + IsCompleted = true; + return "ServerKerberosBlob2"; + default: + throw new InvalidOperationException(incomingBlob); + } + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Controllers/AuthTestController.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Controllers/AuthTestController.cs new file mode 100644 index 000000000000..ec201b996bb0 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Controllers/AuthTestController.cs @@ -0,0 +1,361 @@ +// 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.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Negotiate.Client.Controllers +{ + [Route("authtest")] + [ApiController] + public class AuthTestController : ControllerBase + { + private const int StatusCode600WrongStatusCode = 600; + private const int StatusCode601WrongUser = 601; + private const int StatusCode602WrongAuthType = 602; + private const int StatusCode603WrongAuthHeader = 603; + private const int StatusCode604WrongProtocol = 604; + + private const string Http11Protocol = "HTTP/1.1"; + private const string Http2Protocol = "HTTP/2"; + + [HttpGet] + [Route("Anonymous/Unrestricted")] + public async Task AnonymousUnrestricted([FromQuery] string server, [FromQuery] string protocol) + { + var client = CreateSocketHttpClient(server); + client.DefaultRequestVersion = GetProtocolVersion(protocol); + + var result = await client.GetAsync("auth/Unrestricted"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + || HasWrongProtocol(protocol, result.Version, out actionResult) + || HasUser(body, out actionResult)) + { + return actionResult; + } + + return Ok(); + } + + [HttpGet] + [Route("Anonymous/Authorized")] + public async Task AnonymousAuthorized([FromQuery] string server, [FromQuery] string protocol) + { + // Note WinHttpHandler cannot disable default credentials on localhost. + var client = CreateSocketHttpClient(server); + client.DefaultRequestVersion = GetProtocolVersion(protocol); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status401Unauthorized, result.StatusCode, body, out var actionResult) + || HasWrongProtocol(protocol, result.Version, out actionResult)) + { + return actionResult; + } + + var authHeader = result.Headers.WwwAuthenticate.ToString(); + + if (!string.Equals("Negotiate", authHeader)) + { + return StatusCode(StatusCode603WrongAuthHeader, authHeader); + } + + return Ok(); + } + + [HttpGet] + [Route("DefaultCredentials/Authorized")] + public async Task DefaultCredentialsAuthorized([FromQuery] string server, [FromQuery] string protocol) + { + // Note WinHttpHandler cannot disable default credentials on localhost. + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + // Automatic downgrade to HTTP/1.1 + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + return Ok(); + } + + [HttpGet] + [Route("AfterAuth/Unrestricted/Persist")] + public async Task AfterAuthUnrestrictedPersist([FromQuery] string server, [FromQuery] string protocol1, [FromQuery] string protocol2) + { + // Note WinHttpHandler cannot disable default credentials on localhost. + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol1); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + // Automatic downgrade to HTTP/1.1 + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + result = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "auth/Unrestricted") { Version = GetProtocolVersion(protocol2) }); + body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out actionResult) + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + return Ok(); + } + + [HttpGet] + [Route("AfterAuth/Unrestricted/NonPersist")] + public async Task AfterAuthUnrestrictedNonPersist([FromQuery] string server, [FromQuery] string protocol1, [FromQuery] string protocol2) + { + // Note WinHttpHandler cannot disable default credentials on localhost. + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol1); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + // Automatic downgrade to HTTP/1.1 + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + result = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "auth/Unrestricted") { Version = GetProtocolVersion(protocol2) }); + body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out actionResult) + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || HasUser(body, out actionResult)) + { + return actionResult; + } + + return Ok(); + } + + [HttpGet] + [Route("AfterAuth/Authorized/NonPersist")] + public async Task AfterAuthAuthorizedNonPersist([FromQuery] string server, [FromQuery] string protocol1, [FromQuery] string protocol2) + { + // Note WinHttpHandler cannot disable default credentials on localhost. + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol1); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + // Automatic downgrade to HTTP/1.1 + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + result = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "auth/Authorized") { Version = GetProtocolVersion(protocol2) }); + body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out actionResult) + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + return Ok(); + } + + [HttpGet] + [Route("Unauthorized")] + public async Task Unauthorized([FromQuery] string server, [FromQuery] string protocol) + { + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol); + + var result = await client.GetAsync("auth/Unauthorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status401Unauthorized, result.StatusCode, body, out var actionResult) + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult)) // HTTP/2 downgrades. + { + return actionResult; + } + + var authHeader = result.Headers.WwwAuthenticate.ToString(); + + if (!string.Equals("Negotiate", authHeader)) + { + return StatusCode(StatusCode603WrongAuthHeader, authHeader); + } + + return Ok(); + } + + [HttpGet] + [Route("AfterAuth/Unauthorized")] + public async Task AfterAuthUnauthorized([FromQuery] string server, [FromQuery] string protocol) + { + var client = CreateWinHttpClient(server, useDefaultCredentials: true); + client.DefaultRequestVersion = GetProtocolVersion(protocol); + + var result = await client.GetAsync("auth/Authorized"); + var body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status200OK, result.StatusCode, body, out var actionResult) + // Automatic downgrade to HTTP/1.1 + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult) + || MissingUser(body, out actionResult)) + { + return actionResult; + } + + result = await client.GetAsync("auth/Unauthorized"); + body = await result.Content.ReadAsStringAsync(); + + if (HasWrongStatusCode(StatusCodes.Status401Unauthorized, result.StatusCode, body, out actionResult) + || HasWrongProtocol(Http11Protocol, result.Version, out actionResult)) // HTTP/2 downgrades. + { + return actionResult; + } + + var authHeader = result.Headers.WwwAuthenticate.ToString(); + + if (!string.Equals("Negotiate", authHeader)) + { + return StatusCode(StatusCode603WrongAuthHeader, authHeader); + } + + return Ok(); + } + + private bool HasWrongStatusCode(int expected, HttpStatusCode actual, string body, out IActionResult actionResult) + { + if (expected != (int)actual) + { + actionResult = StatusCode(StatusCode600WrongStatusCode, $"{actual} {body}"); + return true; + } + actionResult = null; + return false; + } + + private bool HasWrongProtocol(string expected, Version actual, out IActionResult actionResult) + { + if ((expected == Http11Protocol && actual != new Version(1, 1)) + || (expected == Http2Protocol && actual != new Version(2, 0))) + { + actionResult = StatusCode(StatusCode604WrongProtocol, actual.ToString()); + return true; + } + actionResult = null; + return false; + } + + private bool MissingUser(string body, out IActionResult actionResult) + { + var details = JsonDocument.Parse(body).RootElement; + + if (string.IsNullOrEmpty(details.GetProperty("name").GetString())) + { + actionResult = StatusCode(StatusCode601WrongUser, body); + return true; + } + + if (string.IsNullOrEmpty(details.GetProperty("authenticationType").GetString())) + { + actionResult = StatusCode(StatusCode602WrongAuthType, body); + return true; + } + + actionResult = null; + return false; + } + + private bool HasUser(string body, out IActionResult actionResult) + { + var details = JsonDocument.Parse(body).RootElement; + + if (!string.IsNullOrEmpty(details.GetProperty("name").GetString())) + { + actionResult = StatusCode(StatusCode601WrongUser, body); + return true; + } + + if (!string.IsNullOrEmpty(details.GetProperty("authenticationType").GetString())) + { + actionResult = StatusCode(StatusCode602WrongAuthType, body); + return true; + } + + actionResult = null; + return false; + } + + // Normally you'd want to re-use clients, but we want to ensure we have fresh state for each test. + + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + private HttpClient CreateSocketHttpClient(string remote, bool useDefaultCredentials = false) + { + return new HttpClient(new HttpClientHandler() + { + UseDefaultCredentials = useDefaultCredentials, + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }) + { + BaseAddress = new Uri(remote), + }; + } + + // https://github.com/dotnet/corefx/issues/35195 SocketHttpHandler won't downgrade HTTP/2. WinHttpHandler does. + private HttpClient CreateWinHttpClient(string remote, bool useDefaultCredentials = false) + { + // WinHttpHandler always uses default credentials on localhost + return new HttpClient(new WinHttpHandler() + { + ServerCredentials = CredentialCache.DefaultCredentials, + ServerCertificateValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }) + { + BaseAddress = new Uri(remote) + }; + } + + private Version GetProtocolVersion(string protocol) + { + switch (protocol) + { + case "HTTP/1.1": return new Version(1, 1); + case "HTTP/2": return new Version(2, 0); + default: throw new NotImplementedException(Request.Protocol); + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Negotiate.Client.csproj b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Negotiate.Client.csproj new file mode 100644 index 000000000000..55a22b834ddc --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Negotiate.Client.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp3.0 + OutOfProcess + + + + + + + + + + + + diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Program.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Program.cs new file mode 100644 index 000000000000..04676b121ba0 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Program.cs @@ -0,0 +1,23 @@ +// 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.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Negotiate.Client +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Properties/launchSettings.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Properties/launchSettings.json new file mode 100644 index 000000000000..d4027d33b369 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:2517", + "sslPort": 44346 + } + }, + "profiles": {/* + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + },*/ + "Negotiate.Client": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:5005;http://localhost:5004", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/ReadMe.md b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/ReadMe.md new file mode 100644 index 000000000000..a382593efbfa --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/ReadMe.md @@ -0,0 +1,4 @@ +Negotiate Client + +This project is part of a suite of cross machine tests. It's intended to be deployed to a client machine, controled via WebApi requests, +and make outbound authentication requests to a specified server. diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Startup.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Startup.cs new file mode 100644 index 000000000000..67bb147440ea --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/Startup.cs @@ -0,0 +1,39 @@ +// 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.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Negotiate.Client +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDeveloperExceptionPage(); + + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.Development.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.Development.json new file mode 100644 index 000000000000..e203e9407e74 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.json new file mode 100644 index 000000000000..d9d9a9bff6fd --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Client/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Controllers/AuthController.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Controllers/AuthController.cs new file mode 100644 index 000000000000..3e699e7fec6c --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Controllers/AuthController.cs @@ -0,0 +1,46 @@ +// 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.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Negotiate.Server.Controllers +{ + [Route("auth")] + [ApiController] + public class AuthController : ControllerBase + { + [HttpGet] + [Route("Unrestricted")] + public ObjectResult GetUnrestricted() + { + var user = HttpContext.User.Identity; + return new ObjectResult(new + { + user.Name, + user.AuthenticationType, + }); + } + + [HttpGet] + [Authorize] + [Route("Authorized")] + public ObjectResult GetAuthorized() + { + var user = HttpContext.User.Identity; + return new ObjectResult(new + { + user.Name, + user.AuthenticationType, + }); + } + + [HttpGet] + [Authorize] + [Route("Unauthorized")] + public ChallengeResult GetUnauthorized() + { + return Challenge(); + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Negotiate.Server.csproj b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Negotiate.Server.csproj new file mode 100644 index 000000000000..627f54bc3580 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Negotiate.Server.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp3.0 + OutOfProcess + + + + + + + + + + + + diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Program.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Program.cs new file mode 100644 index 000000000000..f993cfe93e74 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Program.cs @@ -0,0 +1,43 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Negotiate.Server +{ + public class Program + { + public static async Task Main(string[] args) + { + using var host1 = CreateHostBuilder(args.Append("Persist=true").ToArray()).Build(); + using var host2 = CreateHostBuilder(args.Append("Persist=false").ToArray()).Build(); + await host1.StartAsync(); + await host2.StartAsync(); + await host1.WaitForShutdownAsync(); // CTL+C + await host2.StopAsync(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + webBuilder.ConfigureKestrel((context, options) => + { + if (string.Equals("true", context.Configuration["Persist"])) + { + options.ListenAnyIP(5000); + options.ListenAnyIP(5001, listenOptions => listenOptions.UseHttps()); + } + else + { + options.ListenAnyIP(5002); + options.ListenAnyIP(5003, listenOptions => listenOptions.UseHttps()); + } + }); + }); + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Properties/launchSettings.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Properties/launchSettings.json new file mode 100644 index 000000000000..089c887c8839 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:3049", + "sslPort": 44306 + } + }, + "profiles": { /* + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/values", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + },*/ + "Negotiate.Server": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "auth/user", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/ReadMe.md b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/ReadMe.md new file mode 100644 index 000000000000..9913fdf7c393 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/ReadMe.md @@ -0,0 +1,5 @@ +Negotiate Server + +This project is part of a suite of cross machine tests. It's intended to be deployed to a server machine and invoked indirectly via a client. + +This project launches two servers on different ports, one with connection credential persistence enabled, and the other with it disabled. diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Startup.cs b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Startup.cs new file mode 100644 index 000000000000..9f2af6b18c32 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/Startup.cs @@ -0,0 +1,49 @@ +// 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.Authentication.Negotiate; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Negotiate.Server +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + services.AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(options => + { + var persist = string.Equals("true", Configuration["Persist"]); + options.PersistKerberosCredentials = persist; + options.PersistNtlmCredentials = persist; + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseDeveloperExceptionPage(); + + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.Development.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.Development.json new file mode 100644 index 000000000000..e203e9407e74 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.json b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.json new file mode 100644 index 000000000000..4e1d4aa1bb16 --- /dev/null +++ b/src/Security/Authentication/Negotiate/test/testassets/Negotiate.Server/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore.Authentication": "Debug", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Security/Authentication/test/selfSigned.cer b/src/Security/Authentication/test/selfSigned.cer deleted file mode 100644 index 6acc7af5a606..000000000000 Binary files a/src/Security/Authentication/test/selfSigned.cer and /dev/null differ diff --git a/src/Security/Security.sln b/src/Security/Security.sln index cf56b8509166..0e10f4be5ea9 100644 --- a/src/Security/Security.sln +++ b/src/Security/Security.sln @@ -136,6 +136,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Routin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.IISIntegration", "..\Servers\IIS\IISIntegration\src\Microsoft.AspNetCore.Server.IISIntegration.csproj", "{FD3AB895-2AF6-447D-82CF-DB002B491D23}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Negotiate", "Negotiate", "{A482E4FD-51C2-4061-8357-1E4757D6CF27}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NegotiateAuthSample", "Authentication\Negotiate\Samples\NegotiateAuthSample\NegotiateAuthSample.csproj", "{473D25BB-9F02-4BA4-A47A-729E239C06FD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.Negotiate", "Authentication\Negotiate\src\Microsoft.AspNetCore.Authentication.Negotiate.csproj", "{B7EA3B80-3A38-402A-BC3F-986907CA657C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.Negotiate.Test", "Authentication\Negotiate\test\Negotiate.Test\Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj", "{903E6CD4-7503-4BBB-86A1-96E0C73F0A90}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest", "Authentication\Negotiate\test\Negotiate.FunctionalTest\Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest.csproj", "{8991AEC8-49F3-4DF1-ADA9-00C13737E005}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Negotiate.Client", "Authentication\Negotiate\test\testassets\Negotiate.Client\Negotiate.Client.csproj", "{57DCE828-241E-437C-BEFC-AF4B6EB06D62}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Negotiate.Server", "Authentication\Negotiate\test\testassets\Negotiate.Server\Negotiate.Server.csproj", "{8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc", "..\Mvc\Mvc\src\Microsoft.AspNetCore.Mvc.csproj", "{27B5D7B5-75A6-4BE6-BD09-597044D06970}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Mvc.Core", "..\Mvc\Mvc.Core\src\Microsoft.AspNetCore.Mvc.Core.csproj", "{553F8C79-13AF-4993-99C1-D70F2143AD8E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,6 +368,38 @@ Global {FD3AB895-2AF6-447D-82CF-DB002B491D23}.Debug|Any CPU.Build.0 = Debug|Any CPU {FD3AB895-2AF6-447D-82CF-DB002B491D23}.Release|Any CPU.ActiveCfg = Release|Any CPU {FD3AB895-2AF6-447D-82CF-DB002B491D23}.Release|Any CPU.Build.0 = Release|Any CPU + {473D25BB-9F02-4BA4-A47A-729E239C06FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {473D25BB-9F02-4BA4-A47A-729E239C06FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {473D25BB-9F02-4BA4-A47A-729E239C06FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {473D25BB-9F02-4BA4-A47A-729E239C06FD}.Release|Any CPU.Build.0 = Release|Any CPU + {B7EA3B80-3A38-402A-BC3F-986907CA657C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7EA3B80-3A38-402A-BC3F-986907CA657C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7EA3B80-3A38-402A-BC3F-986907CA657C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7EA3B80-3A38-402A-BC3F-986907CA657C}.Release|Any CPU.Build.0 = Release|Any CPU + {903E6CD4-7503-4BBB-86A1-96E0C73F0A90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {903E6CD4-7503-4BBB-86A1-96E0C73F0A90}.Debug|Any CPU.Build.0 = Debug|Any CPU + {903E6CD4-7503-4BBB-86A1-96E0C73F0A90}.Release|Any CPU.ActiveCfg = Release|Any CPU + {903E6CD4-7503-4BBB-86A1-96E0C73F0A90}.Release|Any CPU.Build.0 = Release|Any CPU + {8991AEC8-49F3-4DF1-ADA9-00C13737E005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8991AEC8-49F3-4DF1-ADA9-00C13737E005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8991AEC8-49F3-4DF1-ADA9-00C13737E005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8991AEC8-49F3-4DF1-ADA9-00C13737E005}.Release|Any CPU.Build.0 = Release|Any CPU + {57DCE828-241E-437C-BEFC-AF4B6EB06D62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57DCE828-241E-437C-BEFC-AF4B6EB06D62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57DCE828-241E-437C-BEFC-AF4B6EB06D62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57DCE828-241E-437C-BEFC-AF4B6EB06D62}.Release|Any CPU.Build.0 = Release|Any CPU + {8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82}.Release|Any CPU.Build.0 = Release|Any CPU + {27B5D7B5-75A6-4BE6-BD09-597044D06970}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27B5D7B5-75A6-4BE6-BD09-597044D06970}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27B5D7B5-75A6-4BE6-BD09-597044D06970}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27B5D7B5-75A6-4BE6-BD09-597044D06970}.Release|Any CPU.Build.0 = Release|Any CPU + {553F8C79-13AF-4993-99C1-D70F2143AD8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {553F8C79-13AF-4993-99C1-D70F2143AD8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {553F8C79-13AF-4993-99C1-D70F2143AD8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {553F8C79-13AF-4993-99C1-D70F2143AD8E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -417,6 +467,15 @@ Global {5B2F3890-198E-4BE8-8464-10B4D97F976A} = {A3766414-EB5C-40F7-B031-121804ED5D0A} {71961A8D-B26F-46AE-A475-D00425D875A0} = {A3766414-EB5C-40F7-B031-121804ED5D0A} {FD3AB895-2AF6-447D-82CF-DB002B491D23} = {A3766414-EB5C-40F7-B031-121804ED5D0A} + {A482E4FD-51C2-4061-8357-1E4757D6CF27} = {79C549BA-2932-450A-B87D-635879361343} + {473D25BB-9F02-4BA4-A47A-729E239C06FD} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {B7EA3B80-3A38-402A-BC3F-986907CA657C} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {903E6CD4-7503-4BBB-86A1-96E0C73F0A90} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {8991AEC8-49F3-4DF1-ADA9-00C13737E005} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {57DCE828-241E-437C-BEFC-AF4B6EB06D62} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {8771B5C8-4B96-4A40-A3FC-8CC7E16D7A82} = {A482E4FD-51C2-4061-8357-1E4757D6CF27} + {27B5D7B5-75A6-4BE6-BD09-597044D06970} = {A3766414-EB5C-40F7-B031-121804ED5D0A} + {553F8C79-13AF-4993-99C1-D70F2143AD8E} = {A3766414-EB5C-40F7-B031-121804ED5D0A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ABF8089E-43D0-4010-84A7-7A9DCFE49357} diff --git a/src/Servers/build.cmd b/src/Servers/build.cmd new file mode 100644 index 000000000000..033fe6f61468 --- /dev/null +++ b/src/Servers/build.cmd @@ -0,0 +1,3 @@ +@ECHO OFF +SET RepoRoot=%~dp0..\.. +%RepoRoot%\build.cmd -projects %~dp0\**\*.*proj %* diff --git a/src/Servers/test/FunctionalTests/NtlmAuthenticationTest.cs b/src/Servers/test/FunctionalTests/NtlmAuthenticationTest.cs index 254418ca2338..2c0d6e15def4 100644 --- a/src/Servers/test/FunctionalTests/NtlmAuthenticationTest.cs +++ b/src/Servers/test/FunctionalTests/NtlmAuthenticationTest.cs @@ -21,12 +21,14 @@ public NtlmAuthenticationTests(ITestOutputHelper output) : base(output) } public static TestMatrix TestVariants - => TestMatrix.ForServers(ServerType.IISExpress, ServerType.HttpSys) + => TestMatrix.ForServers(ServerType.IISExpress, ServerType.HttpSys, ServerType.Kestrel) .WithTfms(Tfm.NetCoreApp30) .WithAllHostingModels(); [ConditionalTheory] [MemberData(nameof(TestVariants))] + // In theory it could work on these platforms but the client would need non-default credentials. + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] public async Task NtlmAuthentication(TestVariant variant) { var testName = $"NtlmAuthentication_{variant.Server}_{variant.Tfm}_{variant.Architecture}_{variant.ApplicationType}"; @@ -65,7 +67,14 @@ public async Task NtlmAuthentication(TestVariant variant) response = await httpClient.GetAsync("/Restricted"); responseText = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Contains("NTLM", response.Headers.WwwAuthenticate.ToString()); + if (variant.Server == ServerType.Kestrel) + { + Assert.DoesNotContain("NTLM", response.Headers.WwwAuthenticate.ToString()); + } + else + { + Assert.Contains("NTLM", response.Headers.WwwAuthenticate.ToString()); + } Assert.Contains("Negotiate", response.Headers.WwwAuthenticate.ToString()); logger.LogInformation("Testing /Forbidden"); @@ -97,4 +106,4 @@ public async Task NtlmAuthentication(TestVariant variant) } } } -} \ No newline at end of file +} diff --git a/src/Servers/testassets/ServerComparison.TestSites/ServerComparison.TestSites.csproj b/src/Servers/testassets/ServerComparison.TestSites/ServerComparison.TestSites.csproj index 73eec76d8690..eb8d5f9d98d2 100644 --- a/src/Servers/testassets/ServerComparison.TestSites/ServerComparison.TestSites.csproj +++ b/src/Servers/testassets/ServerComparison.TestSites/ServerComparison.TestSites.csproj @@ -1,4 +1,4 @@ - + @@ -9,6 +9,7 @@ + diff --git a/src/Servers/testassets/ServerComparison.TestSites/StartupNtlmAuthentication.cs b/src/Servers/testassets/ServerComparison.TestSites/StartupNtlmAuthentication.cs index c4e837db581b..73810a6b80a5 100644 --- a/src/Servers/testassets/ServerComparison.TestSites/StartupNtlmAuthentication.cs +++ b/src/Servers/testassets/ServerComparison.TestSites/StartupNtlmAuthentication.cs @@ -3,14 +3,39 @@ using System; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Negotiate; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.IISIntegration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace ServerComparison.TestSites { public class StartupNtlmAuthentication { + public IConfiguration Configuration { get; } + public bool IsKestrel => string.Equals(Configuration["server"], "Microsoft.AspNetCore.Server.Kestrel"); + + public StartupNtlmAuthentication(IConfiguration configuration) + { + Configuration = configuration; + } + + public void ConfigureServices(IServiceCollection services) + { + if (IsKestrel) + { + services.AddAuthentication(NegotiateDefaults.AuthenticationScheme) + .AddNegotiate(); + } + else + { + services.AddAuthentication(IISDefaults.AuthenticationScheme); + } + } + public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) { app.Use(async (context, next) => @@ -31,6 +56,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) } }); + app.UseAuthentication(); app.Use((context, next) => { if (context.Request.Path.Equals("/Anonymous")) @@ -46,17 +72,17 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) } else { - return context.ChallengeAsync("Windows"); + return context.ChallengeAsync(); } } if (context.Request.Path.Equals("/Forbidden")) { - return context.ForbidAsync("Windows"); + return context.ForbidAsync(); } return context.Response.WriteAsync("Hello World"); }); } } -} \ No newline at end of file +}