From 2fe2e0d8418ff55612fc9001b7f7bde058ae5bb9 Mon Sep 17 00:00:00 2001 From: Chris R Date: Tue, 13 Oct 2015 13:47:45 -0700 Subject: [PATCH] #1 Implement a full authentication handler. --- .../AuthenticationHandler.cs | 138 ++++++++++++++++++ .../IISPlatformHandlerDefaults.cs | 11 ++ .../IISPlatformHandlerMiddleware.cs | 93 ++++++++++-- .../IISPlatformHandlerMiddlewareExtensions.cs | 52 ++++++- .../IISPlatformHandlerOptions.cs | 35 +++++ .../Microsoft.AspNet.IISPlatformHandler.xproj | 6 +- .../project.json | 1 + .../NtlmAuthentation.config | 3 +- .../NtlmAuthentationTest.cs | 55 +++---- 9 files changed, 349 insertions(+), 45 deletions(-) create mode 100644 src/Microsoft.AspNet.IISPlatformHandler/AuthenticationHandler.cs create mode 100644 src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerDefaults.cs create mode 100644 src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerOptions.cs diff --git a/src/Microsoft.AspNet.IISPlatformHandler/AuthenticationHandler.cs b/src/Microsoft.AspNet.IISPlatformHandler/AuthenticationHandler.cs new file mode 100644 index 0000000000..118364cf70 --- /dev/null +++ b/src/Microsoft.AspNet.IISPlatformHandler/AuthenticationHandler.cs @@ -0,0 +1,138 @@ +// 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.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Features.Authentication; + +namespace Microsoft.AspNet.IISPlatformHandler +{ + internal class AuthenticationHandler : IAuthenticationHandler + { + internal AuthenticationHandler(HttpContext httpContext, IISPlatformHandlerOptions options, ClaimsPrincipal user) + { + HttpContext = httpContext; + User = user; + Options = options; + } + + internal HttpContext HttpContext { get; } + + internal IISPlatformHandlerOptions Options { get; } + + internal ClaimsPrincipal User { get; } + + internal IAuthenticationHandler PriorHandler { get; set; } + + public Task AuthenticateAsync(AuthenticateContext context) + { + if (ShouldHandleScheme(context.AuthenticationScheme)) + { + if (User != null) + { + context.Authenticated(User, properties: null, + description: Options.AuthenticationDescriptions.Where(descrip => + string.Equals(User.Identity.AuthenticationType, descrip.AuthenticationScheme, StringComparison.Ordinal)).FirstOrDefault()?.Items); + } + else + { + context.NotAuthenticated(); + } + } + + if (PriorHandler != null) + { + return PriorHandler.AuthenticateAsync(context); + } + return Task.FromResult(0); + } + + public Task ChallengeAsync(ChallengeContext context) + { + bool handled = false; + if (ShouldHandleScheme(context.AuthenticationScheme)) + { + switch (context.Behavior) + { + case ChallengeBehavior.Automatic: + // If there is a principal already, invoke the forbidden code path + if (User == null) + { + goto case ChallengeBehavior.Unauthorized; + } + else + { + goto case ChallengeBehavior.Forbidden; + } + case ChallengeBehavior.Unauthorized: + HttpContext.Response.StatusCode = 401; + // We would normally set the www-authenticate header here, but IIS does that for us. + break; + case ChallengeBehavior.Forbidden: + HttpContext.Response.StatusCode = 403; + handled = true; // No other handlers need to consider this challenge. + break; + } + context.Accept(); + } + + if (!handled && PriorHandler != null) + { + return PriorHandler.ChallengeAsync(context); + } + return Task.FromResult(0); + } + + public void GetDescriptions(DescribeSchemesContext context) + { + foreach (var description in Options.AuthenticationDescriptions) + { + context.Accept(description.Items); + } + + if (PriorHandler != null) + { + PriorHandler.GetDescriptions(context); + } + } + + public Task SignInAsync(SignInContext context) + { + // Not supported, fall through + if (PriorHandler != null) + { + return PriorHandler.SignInAsync(context); + } + return Task.FromResult(0); + } + + public Task SignOutAsync(SignOutContext context) + { + // Not supported, fall through + if (PriorHandler != null) + { + return PriorHandler.SignOutAsync(context); + } + return Task.FromResult(0); + } + + private bool ShouldHandleScheme(string authenticationScheme) + { + if (Options.AutomaticAuthentication && string.IsNullOrEmpty(authenticationScheme)) + { + return true; + } + foreach (var description in Options.AuthenticationDescriptions) + { + if (string.Equals(description.AuthenticationScheme, authenticationScheme, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + } +} diff --git a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerDefaults.cs b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerDefaults.cs new file mode 100644 index 0000000000..2ca6f40588 --- /dev/null +++ b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerDefaults.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.AspNet.IISPlatformHandler +{ + public class IISPlatformHandlerDefaults + { + public const string Negotiate = "Negotiate"; + public const string Ntlm = "NTLM"; + } +} diff --git a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddleware.cs b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddleware.cs index 1348711e93..4750749666 100644 --- a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddleware.cs +++ b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddleware.cs @@ -3,13 +3,18 @@ using System; using System.Globalization; +using System.Linq; using System.Net; using System.Security.Principal; using System.Threading.Tasks; using Microsoft.AspNet.Builder; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Http.Features; +using Microsoft.AspNet.Http.Features.Authentication; +using Microsoft.AspNet.Http.Features.Authentication.Internal; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNet.IISPlatformHandler { @@ -22,13 +27,44 @@ namespace Microsoft.AspNet.IISPlatformHandler private const string XOriginalIPName = "X-Original-IP"; private readonly RequestDelegate _next; + private readonly IISPlatformHandlerOptions _options; - public IISPlatformHandlerMiddleware(RequestDelegate next) + public IISPlatformHandlerMiddleware(RequestDelegate next, IISPlatformHandlerOptions options) { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } _next = next; + _options = options; } - public Task Invoke(HttpContext httpContext) + public async Task Invoke(HttpContext httpContext) + { + UpdateScheme(httpContext); + + UpdateRemoteIp(httpContext); + + var winPrincipal = UpdateUser(httpContext); + + var handler = new AuthenticationHandler(httpContext, _options, winPrincipal); + AttachAuthenticationHandler(handler); + + try + { + await _next(httpContext); + } + finally + { + DetachAuthenticationhandler(handler); + } + } + + private static void UpdateScheme(HttpContext httpContext) { var xForwardProtoHeaderValue = httpContext.Request.Headers[XForwardedProtoHeaderName]; if (!string.IsNullOrEmpty(xForwardProtoHeaderValue)) @@ -39,7 +75,10 @@ namespace Microsoft.AspNet.IISPlatformHandler } httpContext.Request.Scheme = xForwardProtoHeaderValue; } - + } + + private static void UpdateRemoteIp(HttpContext httpContext) + { var xForwardedForHeaderValue = httpContext.Request.Headers.GetCommaSeparatedValues(XForwardedForHeaderName); if (xForwardedForHeaderValue != null && xForwardedForHeaderValue.Length > 0) { @@ -54,32 +93,62 @@ namespace Microsoft.AspNet.IISPlatformHandler httpContext.Connection.RemoteIpAddress = ipFromHeader; } } + } + private WindowsPrincipal UpdateUser(HttpContext httpContext) + { var xIISWindowsAuthToken = httpContext.Request.Headers[XIISWindowsAuthToken]; int hexHandle; + WindowsPrincipal winPrincipal = null; if (!StringValues.IsNullOrEmpty(xIISWindowsAuthToken) && int.TryParse(xIISWindowsAuthToken, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out hexHandle)) { + // Always create the identity if the handle exists, we need to dispose it so it does not leak. var handle = new IntPtr(hexHandle); var winIdentity = new WindowsIdentity(handle); + // WindowsIdentity just duplicated the handle so we need to close the original. NativeMethods.CloseHandle(handle); httpContext.Response.RegisterForDispose(winIdentity); - var winPrincipal = new WindowsPrincipal(winIdentity); + winPrincipal = new WindowsPrincipal(winIdentity); - var existingPrincipal = httpContext.User; - if (existingPrincipal != null) + if (_options.AutomaticAuthentication) { - httpContext.User = SecurityHelper.MergeUserPrincipal(existingPrincipal, winPrincipal); - } - else - { - httpContext.User = winPrincipal; + var existingPrincipal = httpContext.User; + if (existingPrincipal != null) + { + httpContext.User = SecurityHelper.MergeUserPrincipal(existingPrincipal, winPrincipal); + } + else + { + httpContext.User = winPrincipal; + } } } - return _next(httpContext); + return winPrincipal; + } + + private void AttachAuthenticationHandler(AuthenticationHandler handler) + { + var auth = handler.HttpContext.Features.Get(); + if (auth == null) + { + auth = new HttpAuthenticationFeature(); + handler.HttpContext.Features.Set(auth); + } + handler.PriorHandler = auth.Handler; + auth.Handler = handler; + } + + private void DetachAuthenticationhandler(AuthenticationHandler handler) + { + var auth = handler.HttpContext.Features.Get(); + if (auth != null) + { + auth.Handler = handler.PriorHandler; + } } } } diff --git a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddlewareExtensions.cs b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddlewareExtensions.cs index 2525cd280d..cd305543e0 100644 --- a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddlewareExtensions.cs +++ b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerMiddlewareExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using Microsoft.AspNet.IISPlatformHandler; namespace Microsoft.AspNet.Builder @@ -13,9 +14,56 @@ namespace Microsoft.AspNet.Builder /// /// /// - public static IApplicationBuilder UseIISPlatformHandler(this IApplicationBuilder builder) + public static IApplicationBuilder UseIISPlatformHandler(this IApplicationBuilder app) { - return builder.UseMiddleware(); + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.UseMiddleware(new IISPlatformHandlerOptions()); + } + + /// + /// Adds middleware for interacting with the IIS HttpPlatformHandler reverse proxy module. + /// This will handle forwarded Windows Authentication, request scheme, remote IPs, etc.. + /// + /// + /// + public static IApplicationBuilder UseIISPlatformHandler(this IApplicationBuilder app, IISPlatformHandlerOptions options) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + return app.UseMiddleware(options); + } + + /// + /// Adds middleware for interacting with the IIS HttpPlatformHandler reverse proxy module. + /// This will handle forwarded Windows Authentication, request scheme, remote IPs, etc.. + /// + /// + /// + public static IApplicationBuilder UseIISPlatformHandler(this IApplicationBuilder app, Action configureOptions) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + var options = new IISPlatformHandlerOptions(); + if (configureOptions != null) + { + configureOptions(options); + } + + return app.UseIISPlatformHandler(options); } } } diff --git a/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerOptions.cs b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerOptions.cs new file mode 100644 index 0000000000..96e005c250 --- /dev/null +++ b/src/Microsoft.AspNet.IISPlatformHandler/IISPlatformHandlerOptions.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Http.Authentication; + +namespace Microsoft.AspNet.IISPlatformHandler +{ + public class IISPlatformHandlerOptions + { + /// + /// If true the authentication middleware alter the request user coming in and respond to generic challenges. + /// If false the authentication middleware will only provide identity and respond to challenges when explicitly indicated + /// by the AuthenticationScheme. + /// + public bool AutomaticAuthentication { get; set; } = true; + + /// + /// Additional information about the authentication type which is made available to the application. + /// + public IList AuthenticationDescriptions { get; } = new List() + { + new AuthenticationDescription() + { + AuthenticationScheme = IISPlatformHandlerDefaults.Negotiate, + DisplayName = IISPlatformHandlerDefaults.Negotiate + }, + new AuthenticationDescription() + { + AuthenticationScheme = IISPlatformHandlerDefaults.Ntlm, + DisplayName = IISPlatformHandlerDefaults.Ntlm + } + }; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.IISPlatformHandler/Microsoft.AspNet.IISPlatformHandler.xproj b/src/Microsoft.AspNet.IISPlatformHandler/Microsoft.AspNet.IISPlatformHandler.xproj index d8a0feec36..69ed25477d 100644 --- a/src/Microsoft.AspNet.IISPlatformHandler/Microsoft.AspNet.IISPlatformHandler.xproj +++ b/src/Microsoft.AspNet.IISPlatformHandler/Microsoft.AspNet.IISPlatformHandler.xproj @@ -4,17 +4,15 @@ 14.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - abe53415-83ce-4af0-af67-e52160c7862b - Microsoft.AspNet.PlatformHandler + Microsoft.AspNet.IISPlatformHandler ..\..\artifacts\obj\$(MSBuildProjectName) ..\..\artifacts\bin\$(MSBuildProjectName)\ - 2.0 - + \ No newline at end of file diff --git a/src/Microsoft.AspNet.IISPlatformHandler/project.json b/src/Microsoft.AspNet.IISPlatformHandler/project.json index f2df648e4c..a0b55b5d75 100644 --- a/src/Microsoft.AspNet.IISPlatformHandler/project.json +++ b/src/Microsoft.AspNet.IISPlatformHandler/project.json @@ -6,6 +6,7 @@ "url": "git://github.com/aspnet/IISIntegration" }, "dependencies": { + "Microsoft.AspNet.Http": "1.0.0-*", "Microsoft.AspNet.Http.Extensions": "1.0.0-*", "Microsoft.Extensions.SecurityHelper.Sources": { "type": "build", diff --git a/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentation.config b/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentation.config index 8f43b98ced..74e50ca34d 100644 --- a/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentation.config +++ b/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentation.config @@ -1027,12 +1027,11 @@ - - + diff --git a/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentationTest.cs b/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentationTest.cs index 04e281f84f..893550bc2f 100644 --- a/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentationTest.cs +++ b/test/Microsoft.AspNet.IISPlatformHandler.FunctionalTests/NtlmAuthentationTest.cs @@ -17,8 +17,7 @@ namespace Microsoft.AspNet.IISPlatformHandler.FunctionalTests // Uses ports ranging 5050 - 5060. public class NtlmAuthenticationTests { - // TODO: The middleware needs to implement auth handlers. - [ConditionalTheory, Trait("ServerComparison.FunctionalTests", "ServerComparison.FunctionalTests")] + [ConditionalTheory] [OSSkipCondition(OperatingSystems.Linux)] [OSSkipCondition(OperatingSystems.MacOSX)] [InlineData(ServerType.IISExpress, RuntimeFlavor.CoreClr, RuntimeArchitecture.x86, "http://localhost:5050/")] @@ -42,7 +41,7 @@ namespace Microsoft.AspNet.IISPlatformHandler.FunctionalTests using (var deployer = ApplicationDeployerFactory.Create(deploymentParameters, logger)) { var deploymentResult = deployer.Deploy(); - var httpClientHandler = new HttpClientHandler() { UseDefaultCredentials = true }; + var httpClientHandler = new HttpClientHandler(); var httpClient = new HttpClient(httpClientHandler) { BaseAddress = new Uri(deploymentResult.ApplicationBaseUri) }; // Request to base address and check if various parts of the body are rendered & measure the cold startup time. @@ -54,54 +53,60 @@ namespace Microsoft.AspNet.IISPlatformHandler.FunctionalTests var responseText = await response.Content.ReadAsStringAsync(); try { - // TODO: Currently we do not implement mixed auth. - // https://github.com/aspnet/IISIntegration/issues/1 Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Assert.Contains("NTLM", response.Headers.WwwAuthenticate.ToString()); - // Assert.Contains("Negotiate", response.Headers.WwwAuthenticate.ToString()); - - /* Assert.Equal("Hello World", responseText); - responseText = await httpClient.GetStringAsync("/Anonymous"); + response = await httpClient.GetAsync("/Anonymous"); + responseText = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("Anonymous?True", responseText); response = await httpClient.GetAsync("/Restricted"); + responseText = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains("NTLM", response.Headers.WwwAuthenticate.ToString()); Assert.Contains("Negotiate", response.Headers.WwwAuthenticate.ToString()); response = await httpClient.GetAsync("/RestrictedNTLM"); + responseText = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Contains("NTLM", response.Headers.WwwAuthenticate.ToString()); // Note we can't restrict a challenge to a specific auth type, the native auth modules always add themselves. Assert.Contains("Negotiate", response.Headers.WwwAuthenticate.ToString()); response = await httpClient.GetAsync("/Forbidden"); + responseText = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - */ - // httpClientHandler = new HttpClientHandler() { UseDefaultCredentials = true }; - // httpClient = new HttpClient(httpClientHandler) { BaseAddress = new Uri(deploymentResult.ApplicationBaseUri) }; - responseText = await httpClient.GetStringAsync("/Anonymous"); - Assert.Equal("Anonymous?False", responseText); + httpClientHandler = new HttpClientHandler() { UseDefaultCredentials = true }; + httpClient = new HttpClient(httpClientHandler) { BaseAddress = new Uri(deploymentResult.ApplicationBaseUri) }; + + response = await httpClient.GetAsync("/Anonymous"); + responseText = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Anonymous?True", responseText); - /* response = await httpClient.GetAsync("/AutoForbid"); + responseText = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - responseText = await httpClient.GetStringAsync("/Restricted"); - Assert.Equal("Negotiate", responseText); + response = await httpClient.GetAsync("/Restricted"); + responseText = await response.Content.ReadAsStringAsync(); + Assert.Equal("Kerberos", responseText); - responseText = await httpClient.GetStringAsync("/RestrictedNegotiate"); - Assert.Equal("Negotiate", responseText); - - response = await httpClient.GetAsync("/RestrictedNTLM"); - // This isn't a Forbidden because we authenticate with Negotiate and challenge for NTLM. + response = await httpClient.GetAsync("/RestrictedNegotiate"); + responseText = await response.Content.ReadAsStringAsync(); + // This is Forbidden because we authenticate with Kerberos and challenge for Negotiate. // Note we can't restrict a challenge to a specific auth type, the native auth modules always add themselves, // so both Negotiate and NTLM get sent again. - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - */ + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + + response = await httpClient.GetAsync("/RestrictedNTLM"); + responseText = await response.Content.ReadAsStringAsync(); + // This is Forbidden because we authenticate with Kerberos and challenge for NTLM. + // Note we can't restrict a challenge to a specific auth type, the native auth modules always add themselves, + // so both Negotiate and NTLM get sent again. + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } catch (XunitException) {