diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index fa3ee5688b9e..9df79e4053be 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -57,6 +57,7 @@ + diff --git a/src/Middleware/HttpOverrides/ref/Microsoft.AspNetCore.HttpOverrides.netcoreapp3.0.cs b/src/Middleware/HttpOverrides/ref/Microsoft.AspNetCore.HttpOverrides.netcoreapp3.0.cs index cb7ee21e145a..f21794c54bdc 100644 --- a/src/Middleware/HttpOverrides/ref/Microsoft.AspNetCore.HttpOverrides.netcoreapp3.0.cs +++ b/src/Middleware/HttpOverrides/ref/Microsoft.AspNetCore.HttpOverrides.netcoreapp3.0.cs @@ -3,6 +3,10 @@ namespace Microsoft.AspNetCore.Builder { + public static partial class CertificateForwardingBuilderExtensions + { + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseCertificateForwarding(this Microsoft.AspNetCore.Builder.IApplicationBuilder app) { throw null; } + } public static partial class ForwardedHeadersExtensions { public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseForwardedHeaders(this Microsoft.AspNetCore.Builder.IApplicationBuilder builder) { throw null; } @@ -37,6 +41,17 @@ public HttpMethodOverrideOptions() { } } namespace Microsoft.AspNetCore.HttpOverrides { + public partial class CertificateForwardingMiddleware + { + public CertificateForwardingMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Options.IOptions options) { } + public System.Threading.Tasks.Task Invoke(Microsoft.AspNetCore.Http.HttpContext httpContext) { throw null; } + } + public partial class CertificateForwardingOptions + { + public System.Func HeaderConverter; + public CertificateForwardingOptions() { } + public string CertificateHeader { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } [System.FlagsAttribute] public enum ForwardedHeaders { @@ -75,3 +90,10 @@ public IPNetwork(System.Net.IPAddress prefix, int prefixLength) { } public bool Contains(System.Net.IPAddress address) { throw null; } } } +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class CertificateForwardingServiceExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddCertificateForwarding(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action configure) { throw null; } + } +} diff --git a/src/Middleware/HttpOverrides/src/CertificateForwardingBuilderExtensions.cs b/src/Middleware/HttpOverrides/src/CertificateForwardingBuilderExtensions.cs new file mode 100644 index 000000000000..038b19b63704 --- /dev/null +++ b/src/Middleware/HttpOverrides/src/CertificateForwardingBuilderExtensions.cs @@ -0,0 +1,30 @@ +// 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.HttpOverrides; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for using certificate fowarding. + /// + public static class CertificateForwardingBuilderExtensions + { + /// + /// Adds a middleware to the pipeline that will look for a certificate in a request header + /// decode it, and updates HttpContext.Connection.ClientCertificate. + /// + /// + /// + public static IApplicationBuilder UseCertificateForwarding(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.UseMiddleware(); + } + } +} diff --git a/src/Middleware/HttpOverrides/src/CertificateForwardingFeature.cs b/src/Middleware/HttpOverrides/src/CertificateForwardingFeature.cs new file mode 100644 index 000000000000..a7d284a0cc7a --- /dev/null +++ b/src/Middleware/HttpOverrides/src/CertificateForwardingFeature.cs @@ -0,0 +1,51 @@ +// 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.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HttpOverrides +{ + internal class CertificateForwardingFeature : ITlsConnectionFeature + { + private ILogger _logger; + private StringValues _header; + private CertificateForwardingOptions _options; + private X509Certificate2 _certificate; + + public CertificateForwardingFeature(ILogger logger, StringValues header, CertificateForwardingOptions options) + { + _logger = logger; + _options = options; + _header = header; + } + + public X509Certificate2 ClientCertificate + { + get + { + if (_certificate == null) + { + try + { + _certificate = _options.HeaderConverter(_header); + } + catch (Exception e) + { + _logger.NoCertificate(e); + } + } + return _certificate; + } + set => _certificate = value; + } + + public Task GetClientCertificateAsync(CancellationToken cancellationToken) + => Task.FromResult(ClientCertificate); + } +} diff --git a/src/Middleware/HttpOverrides/src/CertificateForwardingMiddleware.cs b/src/Middleware/HttpOverrides/src/CertificateForwardingMiddleware.cs new file mode 100644 index 000000000000..77ff2823612d --- /dev/null +++ b/src/Middleware/HttpOverrides/src/CertificateForwardingMiddleware.cs @@ -0,0 +1,66 @@ +// 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; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.HttpOverrides +{ + /// + /// Middleware that converts a forward header into a client certificate if found. + /// + public class CertificateForwardingMiddleware + { + private readonly RequestDelegate _next; + private readonly CertificateForwardingOptions _options; + private readonly ILogger _logger; + + /// + /// Constructor. + /// + /// + /// + /// + public CertificateForwardingMiddleware( + RequestDelegate next, + ILoggerFactory loggerFactory, + IOptions options) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _options = options.Value; + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Looks for the presence of a header in the request, + /// if found, converts this header to a ClientCertificate set on the connection. + /// + /// The . + /// A . + public Task Invoke(HttpContext httpContext) + { + var header = httpContext.Request.Headers[_options.CertificateHeader]; + if (!StringValues.IsNullOrEmpty(header)) + { + httpContext.Features.Set(new CertificateForwardingFeature(_logger, header, _options)); + } + return _next(httpContext); + } + } +} diff --git a/src/Middleware/HttpOverrides/src/CertificateForwardingOptions.cs b/src/Middleware/HttpOverrides/src/CertificateForwardingOptions.cs new file mode 100644 index 000000000000..4dccdda3b154 --- /dev/null +++ b/src/Middleware/HttpOverrides/src/CertificateForwardingOptions.cs @@ -0,0 +1,30 @@ +// 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.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.HttpOverrides +{ + /// + /// Used to configure the . + /// + public class CertificateForwardingOptions + { + /// + /// The name of the header containing the client certificate. + /// + /// + /// This defaults to X-Client-Cert + /// + public string CertificateHeader { get; set; } = "X-Client-Cert"; + + /// + /// The function used to convert the header to an instance of . + /// + /// + /// This defaults to a conversion from a base64 encoded string. + /// + public Func HeaderConverter = (headerValue) => new X509Certificate2(Convert.FromBase64String(headerValue)); + } +} diff --git a/src/Middleware/HttpOverrides/src/CertificateForwardingServiceExtensions.cs b/src/Middleware/HttpOverrides/src/CertificateForwardingServiceExtensions.cs new file mode 100644 index 000000000000..ffdd4e403bc8 --- /dev/null +++ b/src/Middleware/HttpOverrides/src/CertificateForwardingServiceExtensions.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.HttpOverrides; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for using certificate fowarding. + /// + public static class CertificateForwardingServiceExtensions + { + /// + /// Adds certificate forwarding to the specified . + /// + /// The . + /// An action delegate to configure the provided . + /// The so that additional calls can be chained. + public static IServiceCollection AddCertificateForwarding( + this IServiceCollection services, + Action configure) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + services.AddOptions().Validate(o => !string.IsNullOrEmpty(o.CertificateHeader), "CertificateForwarderOptions.CertificateHeader cannot be null or empty."); + return services.Configure(configure); + } + } +} diff --git a/src/Middleware/HttpOverrides/src/LoggingExtensions.cs b/src/Middleware/HttpOverrides/src/LoggingExtensions.cs new file mode 100644 index 000000000000..27a2dce49408 --- /dev/null +++ b/src/Middleware/HttpOverrides/src/LoggingExtensions.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 System; + +namespace Microsoft.Extensions.Logging +{ + internal static class LoggingExtensions + { + private static Action _noCertificate; + + static LoggingExtensions() + { + _noCertificate = LoggerMessage.Define( + eventId: new EventId(0, "NoCertificate"), + logLevel: LogLevel.Warning, + formatString: "Could not read certificate from header."); + } + + public static void NoCertificate(this ILogger logger, Exception exception) + { + _noCertificate(logger, exception); + } + } +} diff --git a/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs b/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs new file mode 100644 index 000000000000..42464ccb9895 --- /dev/null +++ b/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs @@ -0,0 +1,222 @@ +// 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.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.HttpOverrides +{ + public class CertificateForwardingTests + { + [Fact] + public void VerifySettingNullHeaderOptionThrows() + { + var services = new ServiceCollection() + .AddOptions() + .AddCertificateForwarding(o => o.CertificateHeader = null); + var options = services.BuildServiceProvider().GetRequiredService>(); + Assert.Throws(() => options.Value); + } + + [Fact] + public void VerifySettingEmptyHeaderOptionThrows() + { + var services = new ServiceCollection() + .AddOptions() + .AddCertificateForwarding(o => o.CertificateHeader = ""); + var options = services.BuildServiceProvider().GetRequiredService>(); + Assert.Throws(() => options.Value); + } + + [Fact] + public async Task VerifyHeaderIsUsedIfNoCertificateAlreadySet() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddCertificateForwarding(options => { }); + }) + .Configure(app => + { + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + app.UseCertificateForwarding(); + app.Use(async (context, next) => + { + Assert.Equal(context.Connection.ClientCertificate, Certificates.SelfSignedValidWithNoEku); + await next(); + }); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["X-Client-Cert"] = Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData); + }); + } + + [Fact] + public async Task VerifyHeaderOverridesCertificateEvenAlreadySet() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddCertificateForwarding(options => { }); + }) + .Configure(app => + { + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + context.Connection.ClientCertificate = Certificates.SelfSignedNotYetValid; + await next(); + }); + app.UseCertificateForwarding(); + app.Use(async (context, next) => + { + Assert.Equal(context.Connection.ClientCertificate, Certificates.SelfSignedValidWithNoEku); + await next(); + }); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["X-Client-Cert"] = Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData); + }); + } + + [Fact] + public async Task VerifySettingTheAzureHeaderOnTheForwarderOptionsWorks() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddCertificateForwarding(options => options.CertificateHeader = "X-ARR-ClientCert"); + }) + .Configure(app => + { + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + app.UseCertificateForwarding(); + app.Use(async (context, next) => + { + Assert.Equal(context.Connection.ClientCertificate, Certificates.SelfSignedValidWithNoEku); + await next(); + }); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["X-ARR-ClientCert"] = Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData); + }); + } + + [Fact] + public async Task VerifyACustomHeaderFailsIfTheHeaderIsNotPresent() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddCertificateForwarding(options => options.CertificateHeader = "some-random-header"); + }) + .Configure(app => + { + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + app.UseCertificateForwarding(); + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["not-the-right-header"] = Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData); + }); + } + + [Fact] + public async Task VerifyArrHeaderEncodedCertFailsOnBadEncoding() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddCertificateForwarding(options => { }); + }) + .Configure(app => + { + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + app.UseCertificateForwarding(); + app.Use(async (context, next) => + { + Assert.Null(context.Connection.ClientCertificate); + await next(); + }); + }); + var server = new TestServer(builder); + + var context = await server.SendAsync(c => + { + c.Request.Headers["X-Client-Cert"] = "OOPS" + Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData); + }); + } + + private static class Certificates + { + public static X509Certificate2 SelfSignedValidWithClientEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedClientEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedValidWithNoEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedNoEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedValidWithServerEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedServerEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedNotYetValid { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("selfSignedNoEkuCertificateNotValidYet.cer")); + + public static X509Certificate2 SelfSignedExpired { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("selfSignedNoEkuCertificateExpired.cer")); + + private static string GetFullyQualifiedFilePath(string filename) + { + var filePath = Path.Combine(AppContext.BaseDirectory, filename); + if (!File.Exists(filePath)) + { + throw new FileNotFoundException(filePath); + } + return filePath; + } + } + + } +} diff --git a/src/Middleware/HttpOverrides/test/Microsoft.AspNetCore.HttpOverrides.Tests.csproj b/src/Middleware/HttpOverrides/test/Microsoft.AspNetCore.HttpOverrides.Tests.csproj index da3cde94cb41..c5f9652ddca2 100644 --- a/src/Middleware/HttpOverrides/test/Microsoft.AspNetCore.HttpOverrides.Tests.csproj +++ b/src/Middleware/HttpOverrides/test/Microsoft.AspNetCore.HttpOverrides.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0 @@ -8,6 +8,7 @@ + diff --git a/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.csproj b/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.csproj new file mode 100644 index 000000000000..3cf6d5107924 --- /dev/null +++ b/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.csproj @@ -0,0 +1,10 @@ + + + + netcoreapp3.0 + + + + + + diff --git a/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.netcoreapp3.0.cs b/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.netcoreapp3.0.cs new file mode 100644 index 000000000000..7fa9e147ab26 --- /dev/null +++ b/src/Security/Authentication/Certificate/ref/Microsoft.AspNetCore.Authentication.Certificate.netcoreapp3.0.cs @@ -0,0 +1,59 @@ +// 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.Certificate +{ + public static partial class CertificateAuthenticationDefaults + { + public const string AuthenticationScheme = "Certificate"; + } + public partial class CertificateAuthenticationEvents + { + public CertificateAuthenticationEvents() { } + public System.Func OnAuthenticationFailed { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public System.Func OnCertificateValidated { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public virtual System.Threading.Tasks.Task AuthenticationFailed(Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationFailedContext context) { throw null; } + public virtual System.Threading.Tasks.Task CertificateValidated(Microsoft.AspNetCore.Authentication.Certificate.CertificateValidatedContext context) { throw null; } + } + public partial class CertificateAuthenticationFailedContext : Microsoft.AspNetCore.Authentication.ResultContext + { + public CertificateAuthenticationFailedContext(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationOptions options) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationOptions)) { } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } + public partial class CertificateAuthenticationOptions : Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions + { + public CertificateAuthenticationOptions() { } + public Microsoft.AspNetCore.Authentication.Certificate.CertificateTypes AllowedCertificateTypes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public new Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationEvents Events { get { throw null; } set { } } + public System.Security.Cryptography.X509Certificates.X509RevocationFlag RevocationFlag { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public System.Security.Cryptography.X509Certificates.X509RevocationMode RevocationMode { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public bool ValidateCertificateUse { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public bool ValidateValidityPeriod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } + [System.FlagsAttribute] + public enum CertificateTypes + { + Chained = 1, + SelfSigned = 2, + All = 3, + } + public partial class CertificateValidatedContext : Microsoft.AspNetCore.Authentication.ResultContext + { + public CertificateValidatedContext(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationOptions options) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.Certificate.CertificateAuthenticationOptions)) { } + public System.Security.Cryptography.X509Certificates.X509Certificate2 ClientCertificate { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + } + public static partial class X509Certificate2Extensions + { + public static bool IsSelfSigned(this System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { throw null; } + } +} +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class CertificateAuthenticationAppBuilderExtensions + { + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddCertificate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddCertificate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, System.Action configureOptions) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddCertificate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, string authenticationScheme) { throw null; } + public static Microsoft.AspNetCore.Authentication.AuthenticationBuilder AddCertificate(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder builder, string authenticationScheme, System.Action configureOptions) { throw null; } + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Certificate.Sample.csproj b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Certificate.Sample.csproj new file mode 100644 index 000000000000..2f085e5c416f --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Certificate.Sample.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.0 + OutOfProcess + + + + + + + + + + + + + + + + diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Controllers/HomeController.cs b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Controllers/HomeController.cs new file mode 100644 index 000000000000..60be48074b67 --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Controllers/HomeController.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Certificate.Sample.Controllers +{ + public class HomeController : Controller + { + public IActionResult Index() + { + return View(); + } + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Program.cs b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Program.cs new file mode 100644 index 000000000000..1c4a2d295808 --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Program.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Https; + +namespace Certificate.Sample +{ + public class Program + { + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) + => WebHost.CreateDefaultBuilder(args) + .UseStartup() + .ConfigureKestrel(options => + { + options.ConfigureHttpsDefaults(opt => + { + opt.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + }); + }) + .Build(); + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Properties/launchSettings.json b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Properties/launchSettings.json new file mode 100644 index 000000000000..e796cb6c7ecf --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Properties/launchSettings.json @@ -0,0 +1,20 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "https://localhost:44331/", + "sslPort": 44331 + } + }, + "profiles": { + "Certificate.Sample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001/" + } + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs new file mode 100644 index 000000000000..14e2702e072c --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs @@ -0,0 +1,61 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Certificate; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Certificate.Sample +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) + .AddCertificate(options => + { + options.Events = new CertificateAuthenticationEvents + { + OnCertificateValidated = context => + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer), + new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + + return Task.CompletedTask; + } + }; + }); + + services.AddAuthorization(); + + services.AddMvc(config => { }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + app.UseRouting(); + + app.UseStatusCodePages(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => + { + endpoints.MapDefaultControllerRoute(); + }); + } + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Views/Home/Index.cshtml b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Views/Home/Index.cshtml new file mode 100644 index 000000000000..5247bfe9c6d4 --- /dev/null +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Views/Home/Index.cshtml @@ -0,0 +1 @@ +

Hello @User.Identity.Name

\ No newline at end of file diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationDefaults.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationDefaults.cs new file mode 100644 index 000000000000..d085dd3b70ae --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationDefaults.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.Certificate +{ + /// + /// Default values related to certificate authentication middleware + /// + public static class CertificateAuthenticationDefaults + { + /// + /// The default value used for CertificateAuthenticationOptions.AuthenticationScheme + /// + public const string AuthenticationScheme = "Certificate"; + } +} diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs new file mode 100644 index 000000000000..d49f2c274ba3 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs @@ -0,0 +1,55 @@ +// 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.Certificate; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to add Certificate authentication capabilities to an HTTP application pipeline. + /// + public static class CertificateAuthenticationAppBuilderExtensions + { + /// + /// Adds certificate authentication. + /// + /// The . + /// The . + public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder) + => builder.AddCertificate(CertificateAuthenticationDefaults.AuthenticationScheme); + + /// + /// Adds certificate authentication. + /// + /// The . + /// + /// The . + public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder, string authenticationScheme) + => builder.AddCertificate(authenticationScheme, configureOptions: null); + + /// + /// Adds certificate authentication. + /// + /// The . + /// + /// The . + public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder, Action configureOptions) + => builder.AddCertificate(CertificateAuthenticationDefaults.AuthenticationScheme, configureOptions); + + /// + /// Adds certificate authentication. + /// + /// The . + /// + /// + /// The . + public static AuthenticationBuilder AddCertificate( + this AuthenticationBuilder builder, + string authenticationScheme, + Action configureOptions) + => builder.AddScheme(authenticationScheme, configureOptions); + } +} diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs new file mode 100644 index 000000000000..68a7abdde0a2 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs @@ -0,0 +1,235 @@ +// 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.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + internal class CertificateAuthenticationHandler : AuthenticationHandler + { + private static readonly Oid ClientCertificateOid = new Oid("1.3.6.1.5.5.7.3.2"); + + public CertificateAuthenticationHandler( + 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 CertificateAuthenticationEvents Events + { + get { return (CertificateAuthenticationEvents)base.Events; } + set { base.Events = value; } + } + + /// + /// Creates a new instance of the events instance. + /// + /// A new instance of the events instance. + protected override Task CreateEventsAsync() => Task.FromResult(new CertificateAuthenticationEvents()); + + protected override async Task HandleAuthenticateAsync() + { + // You only get client certificates over HTTPS + if (!Context.Request.IsHttps) + { + return AuthenticateResult.NoResult(); + } + + try + { + var clientCertificate = await Context.Connection.GetClientCertificateAsync(); + + // This should never be the case, as cert authentication happens long before ASP.NET kicks in. + if (clientCertificate == null) + { + Logger.NoCertificate(); + return AuthenticateResult.NoResult(); + } + + // If we have a self signed cert, and they're not allowed, exit early and not bother with + // any other validations. + if (clientCertificate.IsSelfSigned() && + !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.SelfSigned)) + { + Logger.CertificateRejected("Self signed", clientCertificate.Subject); + return AuthenticateResult.Fail("Options do not allow self signed certificates."); + } + + // If we have a chained cert, and they're not allowed, exit early and not bother with + // any other validations. + if (!clientCertificate.IsSelfSigned() && + !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.Chained)) + { + Logger.CertificateRejected("Chained", clientCertificate.Subject); + return AuthenticateResult.Fail("Options do not allow chained certificates."); + } + + var chainPolicy = BuildChainPolicy(clientCertificate); + var chain = new X509Chain + { + ChainPolicy = chainPolicy + }; + + var certificateIsValid = chain.Build(clientCertificate); + if (!certificateIsValid) + { + var chainErrors = new List(); + foreach (var validationFailure in chain.ChainStatus) + { + chainErrors.Add($"{validationFailure.Status} {validationFailure.StatusInformation}"); + } + Logger.CertificateFailedValidation(clientCertificate.Subject, chainErrors); + return AuthenticateResult.Fail("Client certificate failed validation."); + } + + var certificateValidatedContext = new CertificateValidatedContext(Context, Scheme, Options) + { + ClientCertificate = clientCertificate, + Principal = CreatePrincipal(clientCertificate) + }; + + await Events.CertificateValidated(certificateValidatedContext); + + if (certificateValidatedContext.Result != null) + { + return certificateValidatedContext.Result; + } + + certificateValidatedContext.Success(); + return certificateValidatedContext.Result; + } + catch (Exception ex) + { + var authenticationFailedContext = new CertificateAuthenticationFailedContext(Context, Scheme, Options) + { + Exception = ex + }; + + await Events.AuthenticationFailed(authenticationFailedContext); + + if (authenticationFailedContext.Result != null) + { + return authenticationFailedContext.Result; + } + + throw; + } + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // Certificate authentication takes place at the connection level. We can't prompt once we're in + // user code, so the best thing to do is Forbid, not Challenge. + return HandleForbiddenAsync(properties); + } + + private X509ChainPolicy BuildChainPolicy(X509Certificate2 certificate) + { + // Now build the chain validation options. + X509RevocationFlag revocationFlag = Options.RevocationFlag; + X509RevocationMode revocationMode = Options.RevocationMode; + + if (certificate.IsSelfSigned()) + { + // Turn off chain validation, because we have a self signed certificate. + revocationFlag = X509RevocationFlag.EntireChain; + revocationMode = X509RevocationMode.NoCheck; + } + + var chainPolicy = new X509ChainPolicy + { + RevocationFlag = revocationFlag, + RevocationMode = revocationMode, + }; + + if (Options.ValidateCertificateUse) + { + chainPolicy.ApplicationPolicy.Add(ClientCertificateOid); + } + + if (certificate.IsSelfSigned()) + { + chainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority; + chainPolicy.VerificationFlags |= X509VerificationFlags.IgnoreEndRevocationUnknown; + chainPolicy.ExtraStore.Add(certificate); + } + + if (!Options.ValidateValidityPeriod) + { + chainPolicy.VerificationFlags |= X509VerificationFlags.IgnoreNotTimeValid; + } + + return chainPolicy; + } + + private ClaimsPrincipal CreatePrincipal(X509Certificate2 certificate) + { + var claims = new List(); + + var issuer = certificate.Issuer; + claims.Add(new Claim("issuer", issuer, ClaimValueTypes.String, Options.ClaimsIssuer)); + + var thumbprint = certificate.Thumbprint; + claims.Add(new Claim(ClaimTypes.Thumbprint, thumbprint, ClaimValueTypes.Base64Binary, Options.ClaimsIssuer)); + + var value = certificate.SubjectName.Name; + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.X500DistinguishedName, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.SerialNumber; + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.SerialNumber, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.GetNameInfo(X509NameType.DnsName, false); + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.Dns, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.GetNameInfo(X509NameType.SimpleName, false); + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.Name, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.GetNameInfo(X509NameType.EmailName, false); + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.Email, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.GetNameInfo(X509NameType.UpnName, false); + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.Upn, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + value = certificate.GetNameInfo(X509NameType.UrlName, false); + if (!string.IsNullOrWhiteSpace(value)) + { + claims.Add(new Claim(ClaimTypes.Uri, value, ClaimValueTypes.String, Options.ClaimsIssuer)); + } + + var identity = new ClaimsIdentity(claims, CertificateAuthenticationDefaults.AuthenticationScheme); + return new ClaimsPrincipal(identity); + } + } +} diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationOptions.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationOptions.cs new file mode 100644 index 000000000000..1b8eebfa6feb --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationOptions.cs @@ -0,0 +1,53 @@ +// 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.Security.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Options used to configure certificate authentication. + /// + public class CertificateAuthenticationOptions : AuthenticationSchemeOptions + { + /// + /// Value indicating the types of certificates accepted by the authentication middleware. + /// + public CertificateTypes AllowedCertificateTypes { get; set; } = CertificateTypes.Chained; + + /// + /// Flag indicating whether the client certificate must be suitable for client + /// authentication, either via the Client Authentication EKU, or having no EKUs + /// at all. If the certificate chains to a root CA all certificates in the chain must be validate + /// for the client authentication EKU. + /// + public bool ValidateCertificateUse { get; set; } = true; + + /// + /// Flag indicating whether the client certificate validity period should be checked. + /// + public bool ValidateValidityPeriod { get; set; } = true; + + /// + /// Specifies which X509 certificates in the chain should be checked for revocation. + /// + public X509RevocationFlag RevocationFlag { get; set; } = X509RevocationFlag.ExcludeRoot; + + /// + /// Specifies conditions under which verification of certificates in the X509 chain should be conducted. + /// + public X509RevocationMode RevocationMode { get; set; } = X509RevocationMode.Online; + + /// + /// The object provided by the application to process events raised by the certificate authentication middleware. + /// The application may implement the interface fully, or it may create an instance of CertificateAuthenticationEvents + /// and assign delegates only to the events it wants to process. + /// + public new CertificateAuthenticationEvents Events + { + get { return (CertificateAuthenticationEvents)base.Events; } + + set { base.Events = value; } + } + } +} diff --git a/src/Security/Authentication/Certificate/src/CertificateTypes.cs b/src/Security/Authentication/Certificate/src/CertificateTypes.cs new file mode 100644 index 000000000000..ab238ce3e62f --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateTypes.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Enum representing certificate types. + /// + [Flags] + public enum CertificateTypes + { + /// + /// Chained certificates. + /// + Chained = 1, + + /// + /// SelfSigned certificates. + /// + SelfSigned = 2, + + /// + /// All certificates. + /// + All = Chained | SelfSigned + } +} diff --git a/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs new file mode 100644 index 000000000000..bf6e559e5f7c --- /dev/null +++ b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs @@ -0,0 +1,45 @@ +// 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.Certificate +{ + /// + /// This default implementation of the IBasicAuthenticationEvents may be used if the + /// application only needs to override a few of the interface methods. + /// This may be used as a base class or may be instantiated directly. + /// + public class CertificateAuthenticationEvents + { + /// + /// A delegate assigned to this property will be invoked when the authentication fails. + /// + public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + + /// + /// A delegate assigned to this property will be invoked when a certificate has passed basic validation, but where custom validation may be needed. + /// + /// + /// You must provide a delegate for this property for authentication to occur. + /// In your delegate you should construct an authentication principal from the user details, + /// attach it to the context.Principal property and finally call context.Success(); + /// + public Func OnCertificateValidated { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked when a certificate fails authentication. + /// + /// + /// + public virtual Task AuthenticationFailed(CertificateAuthenticationFailedContext context) => OnAuthenticationFailed(context); + + /// + /// Invoked after a certificate has been validated + /// + /// + /// + public virtual Task CertificateValidated(CertificateValidatedContext context) => OnCertificateValidated(context); + } +} diff --git a/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationFailedContext.cs b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationFailedContext.cs new file mode 100644 index 000000000000..9742a149c2d9 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationFailedContext.cs @@ -0,0 +1,33 @@ +// 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.Certificate +{ + /// + /// Context used when a failure occurs. + /// + public class CertificateAuthenticationFailedContext : ResultContext + { + /// + /// Constructor. + /// + /// + /// + /// + public CertificateAuthenticationFailedContext( + HttpContext context, + AuthenticationScheme scheme, + CertificateAuthenticationOptions options) + : base(context, scheme, options) + { + } + + /// + /// The exception. + /// + public Exception Exception { get; set; } + } +} diff --git a/src/Security/Authentication/Certificate/src/Events/CertificateValidatedContext.cs b/src/Security/Authentication/Certificate/src/Events/CertificateValidatedContext.cs new file mode 100644 index 000000000000..9a3870b3bdf1 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/Events/CertificateValidatedContext.cs @@ -0,0 +1,33 @@ +// 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.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Context used when certificates are being validated. + /// + public class CertificateValidatedContext : ResultContext + { + /// + /// Creates a new instance of . + /// + /// The HttpContext the validate context applies too. + /// The scheme used when the Certificate Authentication handler was registered. + /// The . + public CertificateValidatedContext( + HttpContext context, + AuthenticationScheme scheme, + CertificateAuthenticationOptions options) + : base(context, scheme, options) + { + } + + /// + /// The certificate to validate. + /// + public X509Certificate2 ClientCertificate { get; set; } + } +} diff --git a/src/Security/Authentication/Certificate/src/LoggingExtensions.cs b/src/Security/Authentication/Certificate/src/LoggingExtensions.cs new file mode 100644 index 000000000000..2219a349b69e --- /dev/null +++ b/src/Security/Authentication/Certificate/src/LoggingExtensions.cs @@ -0,0 +1,48 @@ +// 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; + +namespace Microsoft.Extensions.Logging +{ + internal static class LoggingExtensions + { + private static Action _noCertificate; + private static Action _certRejected; + private static Action _certFailedValidation; + + static LoggingExtensions() + { + _noCertificate = LoggerMessage.Define( + eventId: new EventId(0, "NoCertificate"), + logLevel: LogLevel.Debug, + formatString: "No client certificate found."); + + _certRejected = LoggerMessage.Define( + eventId: new EventId(1, "CertificateRejected"), + logLevel: LogLevel.Warning, + formatString: "{CertificateType} certificate rejected, subject was {Subject}."); + + _certFailedValidation = LoggerMessage.Define( + eventId: new EventId(2, "CertificateFailedValidation"), + logLevel: LogLevel.Warning, + formatString: "Certificate validation failed, subject was {Subject}." + Environment.NewLine + "{ChainErrors}"); + } + + public static void NoCertificate(this ILogger logger) + { + _noCertificate(logger, null); + } + + public static void CertificateRejected(this ILogger logger, string certificateType, string subject) + { + _certRejected(logger, certificateType, subject, null); + } + + public static void CertificateFailedValidation(this ILogger logger, string subject, IEnumerable chainedErrors) + { + _certFailedValidation(logger, subject, String.Join(Environment.NewLine, chainedErrors), null); + } + } +} diff --git a/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj b/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj new file mode 100644 index 000000000000..1795d6887742 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj @@ -0,0 +1,16 @@ + + + + ASP.NET Core middleware that enables an application to support certificate authentication. + netcoreapp3.0 + $(DefineConstants);SECURITY + true + aspnetcore;authentication;security;x509;certificate + true + + + + + + + diff --git a/src/Security/Authentication/Certificate/src/README-IISConfig.png b/src/Security/Authentication/Certificate/src/README-IISConfig.png new file mode 100644 index 000000000000..3af15e9d0652 Binary files /dev/null and b/src/Security/Authentication/Certificate/src/README-IISConfig.png differ diff --git a/src/Security/Authentication/Certificate/src/README.md b/src/Security/Authentication/Certificate/src/README.md new file mode 100644 index 000000000000..542131fdf15e --- /dev/null +++ b/src/Security/Authentication/Certificate/src/README.md @@ -0,0 +1,234 @@ +# Microsoft.AspNetCore.Authentication.Certificate + +This project sort of contains an implementation of [Certificate Authentication](https://tools.ietf.org/html/rfc5246#section-7.4.4) for ASP.NET Core. +Certificate authentication happens at the TLS level, long before it ever gets to ASP.NET Core, so, more accurately this is an authentication handler +that validates the certificate and then gives you an event where you can resolve that certificate to a ClaimsPrincipal. + +You **must** [configure your host](#hostConfiguration) for certificate authentication, be it IIS, Kestrel, Azure Web Applications or whatever else you're using. + +## Getting started + +First acquire an HTTPS certificate, apply it and then [configure your host](#hostConfiguration) to require certificates. + +In your web application add a reference to the package, then in the `ConfigureServices` method in `startup.cs` call +`app.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).UseCertificateAuthentication(...);` with your options, +providing a delegate for `OnValidateCertificate` to validate the client certificate sent with requests and turn that information +into an `ClaimsPrincipal`, set it on the `context.Principal` property and call `context.Success()`. + +If you change your scheme name in the options for the authentication handler you need to change the scheme name in +`AddAuthentication()` to ensure it's used on every request which ends in an endpoint that requires authorization. + +If authentication fails this handler will return a `403 (Forbidden)` response rather a `401 (Unauthorized)` as you +might expect - this is because the authentication should happen during the initial TLS connection - by the time it +reaches the handler it's too late, and there's no way to actually upgrade the connection from an anonymous connection +to one with a certificate. + +You must also add `app.UseAuthentication();` in the `Configure` method, otherwise nothing will ever get called. + +For example; + +```c# +public void ConfigureServices(IServiceCollection services) +{ + services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) + .AddCertificate(); + // All the other service configuration. +} + +public void Configure(IApplicationBuilder app, IHostingEnvironment env) +{ + app.UseAuthentication(); + + // All the other app configuration. +} +``` + +In the sample above you can see the default way to add certificate authentication. The handler will construct a user principal using the common certificate properties for you. + +## Configuring Certificate Validation + +The `CertificateAuthenticationOptions` handler has some built in validations that are the minimium validations you should perform on +a certificate. Each of these settings are turned on by default. + +### ValidateCertificateChain + +This check validates that the issuer for the certificate is trusted by the application host OS. If +you are going to accept self-signed certificates you must disable this check. + +### ValidateCertificateUse + +This check validates that the certificate presented by the client has the Client Authentication +extended key use, or no EKUs at all (as the specifications say if no EKU is specified then all EKUs +are valid). + +### ValidateValidityPeriod + +This check validates that the certificate is within its validity period. As the handler runs on every +request this ensures that a certificate that was valid when it was presented has not expired during +its current session. + +### RevocationFlag + +A flag which specifies which certificates in the chain are checked for revocation. + +Revocation checks are only performed when the certificate is chained to a root certificate. + +### RevocationMode + +A flag which specifies how revocation checks are performed. +Specifying an on-line check can result in a long delay while the certificate authority is contacted. + +Revocation checks are only performed when the certificate is chained to a root certificate. + +### Can I configure my application to require a certificate only on certain paths? + +Not possible, remember the certificate exchange is done that the start of the HTTPS conversation, +it's done by the host, not the application. Kestrel, IIS, Azure Web Apps don't have any configuration for +this sort of thing. + +# Handler events + +The handler has two events, `OnAuthenticationFailed()`, which is called if an exception happens during authentication and allows you to react, and `OnValidateCertificate()` which is +called after certificate has been validated, passed validation, abut before the default principal has been created. This allows you to perform your own validation, for example +checking if the certificate is one your services knows about, and to construct your own principal. For example, + +```c# +services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) + .AddCertificate(options => + { + options.Events = new CertificateAuthenticationEvents + { + OnValidateCertificate = context => + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer), + new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + + return Task.CompletedTask; + } + }; + }); +``` + +If you find the inbound certificate doesn't meet your extra validation call `context.Fail("failure Reason")` with a failure reason. + +For real functionality you will probably want to call a service registered in DI which talks to a database or other type of +user store. You can grab your service by using the context passed into your delegates, like so + +```c# +services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme) + .AddCertificate(options => + { + options.Events = new CertificateAuthenticationEvents + { + OnCertificateValidated = context => + { + var validationService = + context.HttpContext.RequestServices.GetService(); + + if (validationService.ValidateCertificate(context.ClientCertificate)) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer), + new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + } + + return Task.CompletedTask; + } + }; + }); +``` +Note that conceptually the validation of the certification is an authorization concern, and putting a check on, for example, an issuer or thumbprint in an authorization policy rather +than inside OnCertificateValidated() is perfectly acceptable. + +## Configuring your host to require certificates + +### Kestrel + +In program.cs configure `UseKestrel()` as follows. + +```c# +public static IWebHost BuildWebHost(string[] args) + => WebHost.CreateDefaultBuilder(args) + .UseStartup() + .ConfigureKestrel(options => + { + options.ConfigureHttpsDefaults(opt => + { + opt.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + }); + }) + .Build(); +``` +You must set the `ClientCertificateValidation` delegate to `CertificateValidator.DisableChannelValidation` in order to stop Kestrel using the default OS certificate validation routine and, +instead, letting the authentication handler perform the validation. + +### IIS + +In the IIS Manager + +1. Select your Site in the Connections tab. +2. Double click the SSL Settings in the Features View window. +3. Check the `Require SSL` Check Box and select the `Require` radio button under Client Certificates. + +![Client Certificate Settings in IIS](README-IISConfig.png "Client Certificate Settings in IIS") + +### Azure + +See the [Azure documentation](https://docs.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth) +to configure Azure Web Apps then add the following to your application startup method, `Configure(IApplicationBuilder app)` add the +following line before the call to `app.UseAuthentication();` + +```c# +app.UseCertificateHeaderForwarding(); +``` + +### Random custom web proxies + +If you're using a proxy which isn't IIS or Azure's Web Apps Application Request Routing you will need to configure your proxy +to forward the certificate it received in an HTTP header. +In your application startup method, `Configure(IApplicationBuilder app)`, add the +following line before the call to `app.UseAuthentication();` + +```c# +app.UseCertificateForwarding(); +``` + +You will also need to configure the Certificate Forwarding middleware to specify the header name. +In your service configuration method, `ConfigureServices(IServiceCollection services)` add +the following code to configure the header the forwarding middleware will build a certificate from; + +```c# +services.AddCertificateForwarding(options => +{ + options.CertificateHeader = "YOUR_CUSTOM_HEADER_NAME"; +}); +``` + +Finally, if your proxy is doing something weird to pass the header on, rather than base 64 encoding it +(looking at you nginx (╯°□°)╯︵ ┻━┻) you can override the converter option to be a func that will +perform the optional conversion, for example + +```c# +services.AddCertificateForwarding(options => +{ + options.CertificateHeader = "YOUR_CUSTOM_HEADER_NAME"; + options.HeaderConverter = (headerValue) => + { + var clientCertificate = + /* some weird conversion logic to create an X509Certificate2 */ + return clientCertificate; + } +}); +``` + diff --git a/src/Security/Authentication/Certificate/src/X509CertificateExtensions.cs b/src/Security/Authentication/Certificate/src/X509CertificateExtensions.cs new file mode 100644 index 000000000000..de8f0d3df246 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/X509CertificateExtensions.cs @@ -0,0 +1,27 @@ +// 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.Cryptography.X509Certificates; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Extension methods for . + /// + public static class X509Certificate2Extensions + { + /// + /// Determines if the certificate is self signed. + /// + /// The . + /// True if the certificate is self signed. + public static bool IsSelfSigned(this X509Certificate2 certificate) + { + Span subject = certificate.SubjectName.RawData; + Span issuer = certificate.IssuerName.RawData; + return subject.SequenceEqual(issuer); + } + } +} diff --git a/src/Security/Authentication/test/CertificateTests.cs b/src/Security/Authentication/test/CertificateTests.cs new file mode 100644 index 000000000000..a5a2d0294d64 --- /dev/null +++ b/src/Security/Authentication/test/CertificateTests.cs @@ -0,0 +1,628 @@ +// Copyright (c) Barry Dorrans. 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.IO; +using System.Linq; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.AspNetCore.Testing.xunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.Certificate.Test +{ + public class ClientCertificateAuthenticationTests + { + + [Fact] + public async Task VerifySchemeDefaults() + { + var services = new ServiceCollection(); + services.AddAuthentication().AddCertificate(); + var sp = services.BuildServiceProvider(); + var schemeProvider = sp.GetRequiredService(); + var scheme = await schemeProvider.GetSchemeAsync(CertificateAuthenticationDefaults.AuthenticationScheme); + Assert.NotNull(scheme); + Assert.Equal("CertificateAuthenticationHandler", scheme.HandlerType.Name); + Assert.Null(scheme.DisplayName); + } + + [Fact] + public void VerifyIsSelfSignedExtensionMethod() + { + Assert.True(Certificates.SelfSignedValidWithNoEku.IsSelfSigned()); + } + + [Fact] + public async Task VerifyValidSelfSignedWithClientEkuAuthenticates() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithClientEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithNoEkuAuthenticates() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithNoEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithClientEkuFailsWhenSelfSignedCertsNotAllowed() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.Chained + }, + Certificates.SelfSignedValidWithClientEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithNoEkuFailsWhenSelfSignedCertsNotAllowed() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.Chained, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithNoEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithServerFailsEvenIfSelfSignedCertsAreAllowed() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithServerEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithServerPassesWhenSelfSignedCertsAreAllowedAndPurposeValidationIsOff() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateCertificateUse = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithServerEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyValidSelfSignedWithServerFailsPurposeValidationIsOffButSelfSignedCertsAreNotAllowed() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.Chained, + ValidateCertificateUse = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedValidWithServerEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyExpiredSelfSignedFails() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateCertificateUse = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedExpired); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyExpiredSelfSignedPassesIfDateRangeValidationIsDisabled() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateValidityPeriod = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedExpired); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyNotYetValidSelfSignedFails() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateCertificateUse = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedNotYetValid); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyNotYetValidSelfSignedPassesIfDateRangeValidationIsDisabled() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateValidityPeriod = false, + Events = sucessfulValidationEvents + }, + Certificates.SelfSignedNotYetValid); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyFailingInTheValidationEventReturnsForbidden() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + ValidateCertificateUse = false, + Events = failedValidationEvents + }, + Certificates.SelfSignedValidWithServerEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DoingNothingInTheValidationEventReturnsOK() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + ValidateCertificateUse = false, + Events = unprocessedValidationEvents + }, + Certificates.SelfSignedValidWithServerEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyNotSendingACertificateEndsUpInForbidden() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + Events = sucessfulValidationEvents + }); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyHeaderIsUsedIfCertIsNotPresent() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = sucessfulValidationEvents + }, + wireUpHeaderMiddleware : true); + + var client = server.CreateClient(); + client.DefaultRequestHeaders.Add("X-Client-Cert", Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData)); + var response = await client.GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyHeaderEncodedCertFailsOnBadEncoding() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + Events = sucessfulValidationEvents + }, + wireUpHeaderMiddleware: true); + + var client = server.CreateClient(); + client.DefaultRequestHeaders.Add("X-Client-Cert", "OOPS" + Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData)); + var response = await client.GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifySettingTheAzureHeaderOnTheForwarderOptionsWorks() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = sucessfulValidationEvents + }, + wireUpHeaderMiddleware: true, + headerName: "X-ARR-ClientCert"); + + var client = server.CreateClient(); + client.DefaultRequestHeaders.Add("X-ARR-ClientCert", Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData)); + var response = await client.GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task VerifyACustomHeaderFailsIfTheHeaderIsNotPresent() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + Events = sucessfulValidationEvents + }, + wireUpHeaderMiddleware: true, + headerName: "X-ARR-ClientCert"); + + var client = server.CreateClient(); + client.DefaultRequestHeaders.Add("random-Weird-header", Convert.ToBase64String(Certificates.SelfSignedValidWithNoEku.RawData)); + var response = await client.GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task VerifyNoEventWireupWithAValidCertificateCreatesADefaultUser() + { + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned + }, + Certificates.SelfSignedValidWithNoEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + XElement responseAsXml = null; + if (response.Content != null && + response.Content.Headers.ContentType != null && + response.Content.Headers.ContentType.MediaType == "text/xml") + { + var responseContent = await response.Content.ReadAsStringAsync(); + responseAsXml = XElement.Parse(responseContent); + } + + Assert.NotNull(responseAsXml); + + // There should always be an Issuer and a Thumbprint. + var actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == "issuer"); + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.Issuer, actual.First().Value); + + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Thumbprint); + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.Thumbprint, actual.First().Value); + + // Now the optional ones + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.SubjectName.Name)) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.X500DistinguishedName); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.SubjectName.Name, actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.SerialNumber)) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.SerialNumber); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.SerialNumber, actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.DnsName, false))) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Dns); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.DnsName, false), actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.EmailName, false))) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Email); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.EmailName, false), actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.SimpleName, false))) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Name); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.SimpleName, false), actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.UpnName, false))) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Upn); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.UpnName, false), actual.First().Value); + } + } + + if (!string.IsNullOrEmpty(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.UrlName, false))) + { + actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Uri); + if (actual.Count() > 0) + { + Assert.Single(actual); + Assert.Equal(Certificates.SelfSignedValidWithNoEku.GetNameInfo(X509NameType.UrlName, false), actual.First().Value); + } + } + } + + [Fact] + public async Task VerifyValidationEventPrincipalIsPropogated() + { + const string Expected = "John Doe"; + + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = new CertificateAuthenticationEvents + { + OnCertificateValidated = context => + { + // Make sure we get the validated principal + Assert.NotNull(context.Principal); + var claims = new[] + { + new Claim(ClaimTypes.Name, Expected, ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + return Task.CompletedTask; + } + } + }, + Certificates.SelfSignedValidWithNoEku); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + XElement responseAsXml = null; + if (response.Content != null && + response.Content.Headers.ContentType != null && + response.Content.Headers.ContentType.MediaType == "text/xml") + { + var responseContent = await response.Content.ReadAsStringAsync(); + responseAsXml = XElement.Parse(responseContent); + } + + Assert.NotNull(responseAsXml); + var actual = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Name); + Assert.Single(actual); + Assert.Equal(Expected, actual.First().Value); + Assert.Single(responseAsXml.Elements("claim")); + } + + private static TestServer CreateServer( + CertificateAuthenticationOptions configureOptions, + X509Certificate2 clientCertificate = null, + Func handler = null, + Uri baseAddress = null, + bool wireUpHeaderMiddleware = false, + string headerName = "") + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.Use((context, next) => + { + if (clientCertificate != null) + { + context.Connection.ClientCertificate = clientCertificate; + } + return next(); + }); + + + if (wireUpHeaderMiddleware) + { + app.UseCertificateForwarding(); + } + + app.UseAuthentication(); + + app.Use(async (context, next) => + { + var request = context.Request; + var response = context.Response; + + var authenticationResult = await context.AuthenticateAsync(); + + if (authenticationResult.Succeeded) + { + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/xml"; + + await response.WriteAsync(""); + foreach (Claim claim in context.User.Claims) + { + await response.WriteAsync($"{claim.Value}"); + } + await response.WriteAsync(""); + } + else + { + await context.ChallengeAsync(); + } + }); + }) + .ConfigureServices(services => + { + if (configureOptions != null) + { + services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(options => + { + options.AllowedCertificateTypes = configureOptions.AllowedCertificateTypes; + options.Events = configureOptions.Events; + options.ValidateCertificateUse = configureOptions.ValidateCertificateUse; + options.RevocationFlag = options.RevocationFlag; + options.RevocationMode = options.RevocationMode; + options.ValidateValidityPeriod = configureOptions.ValidateValidityPeriod; + }); + } + else + { + services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(); + } + + if (wireUpHeaderMiddleware && !string.IsNullOrEmpty(headerName)) + { + services.AddCertificateForwarding(options => + { + options.CertificateHeader = headerName; + }); + } + }); + + var server = new TestServer(builder) + { + BaseAddress = baseAddress + }; + + return server; + } + + private CertificateAuthenticationEvents sucessfulValidationEvents = new CertificateAuthenticationEvents() + { + OnCertificateValidated = context => + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer), + new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + return Task.CompletedTask; + } + }; + + private CertificateAuthenticationEvents failedValidationEvents = new CertificateAuthenticationEvents() + { + OnCertificateValidated = context => + { + context.Fail("Not validated"); + return Task.CompletedTask; + } + }; + + private CertificateAuthenticationEvents unprocessedValidationEvents = new CertificateAuthenticationEvents() + { + OnCertificateValidated = context => + { + return Task.CompletedTask; + } + }; + + private static class Certificates + { + public static X509Certificate2 SelfSignedValidWithClientEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedClientEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedValidWithNoEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedNoEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedValidWithServerEku { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("validSelfSignedServerEkuCertificate.cer")); + + public static X509Certificate2 SelfSignedNotYetValid { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("selfSignedNoEkuCertificateNotValidYet.cer")); + + public static X509Certificate2 SelfSignedExpired { get; private set; } = + new X509Certificate2(GetFullyQualifiedFilePath("selfSignedNoEkuCertificateExpired.cer")); + + private static string GetFullyQualifiedFilePath(string filename) + { + var filePath = Path.Combine(AppContext.BaseDirectory, filename); + if (!File.Exists(filePath)) + { + throw new FileNotFoundException(filePath); + } + return filePath; + } + } + } +} diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj index f575bfa0cfa3..fd033a9169ac 100644 --- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj +++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj @@ -22,6 +22,10 @@ PreserveNewest + + PreserveNewest + PreserveNewest + @@ -34,6 +38,7 @@ + @@ -42,6 +47,7 @@ + diff --git a/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateExpired.cer b/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateExpired.cer new file mode 100644 index 000000000000..81b6326d6f1e Binary files /dev/null and b/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateExpired.cer differ diff --git a/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateNotValidYet.cer b/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateNotValidYet.cer new file mode 100644 index 000000000000..9c8cf9d71b22 Binary files /dev/null and b/src/Security/Authentication/test/TestCertificates/selfSignedNoEkuCertificateNotValidYet.cer differ diff --git a/src/Security/Authentication/test/TestCertificates/validSelfSignedClientEkuCertificate.cer b/src/Security/Authentication/test/TestCertificates/validSelfSignedClientEkuCertificate.cer new file mode 100644 index 000000000000..db4bb5b90a07 Binary files /dev/null and b/src/Security/Authentication/test/TestCertificates/validSelfSignedClientEkuCertificate.cer differ diff --git a/src/Security/Authentication/test/TestCertificates/validSelfSignedNoEkuCertificate.cer b/src/Security/Authentication/test/TestCertificates/validSelfSignedNoEkuCertificate.cer new file mode 100644 index 000000000000..2be2a46d7992 Binary files /dev/null and b/src/Security/Authentication/test/TestCertificates/validSelfSignedNoEkuCertificate.cer differ diff --git a/src/Security/Authentication/test/TestCertificates/validSelfSignedServerEkuCertificate.cer b/src/Security/Authentication/test/TestCertificates/validSelfSignedServerEkuCertificate.cer new file mode 100644 index 000000000000..823000d4a147 Binary files /dev/null and b/src/Security/Authentication/test/TestCertificates/validSelfSignedServerEkuCertificate.cer differ diff --git a/src/Security/Security.sln b/src/Security/Security.sln index 0e10f4be5ea9..1405fe94dd96 100644 --- a/src/Security/Security.sln +++ b/src/Security/Security.sln @@ -153,6 +153,13 @@ 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}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Certificate", "Certificate", "{4DF524BF-D9A9-46F2-882C-68C48FF5FF33}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.Certificate", "Authentication\Certificate\src\Microsoft.AspNetCore.Authentication.Certificate.csproj", "{2B88E3EA-6FBE-4690-A56E-0744FFAC9870}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certificate.Sample", "Authentication\Certificate\samples\Certificate.Sample\Certificate.Sample.csproj", "{11F3B44F-DE5F-42C4-8EC9-1AA51FB89158}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HeaderPropagation", "..\Middleware\HeaderPropagation\ref\Microsoft.AspNetCore.HeaderPropagation.csproj", "{9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -400,6 +407,18 @@ Global {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 + {2B88E3EA-6FBE-4690-A56E-0744FFAC9870}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B88E3EA-6FBE-4690-A56E-0744FFAC9870}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B88E3EA-6FBE-4690-A56E-0744FFAC9870}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B88E3EA-6FBE-4690-A56E-0744FFAC9870}.Release|Any CPU.Build.0 = Release|Any CPU + {11F3B44F-DE5F-42C4-8EC9-1AA51FB89158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11F3B44F-DE5F-42C4-8EC9-1AA51FB89158}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11F3B44F-DE5F-42C4-8EC9-1AA51FB89158}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11F3B44F-DE5F-42C4-8EC9-1AA51FB89158}.Release|Any CPU.Build.0 = Release|Any CPU + {9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -476,6 +495,10 @@ Global {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} + {4DF524BF-D9A9-46F2-882C-68C48FF5FF33} = {79C549BA-2932-450A-B87D-635879361343} + {2B88E3EA-6FBE-4690-A56E-0744FFAC9870} = {4DF524BF-D9A9-46F2-882C-68C48FF5FF33} + {11F3B44F-DE5F-42C4-8EC9-1AA51FB89158} = {4DF524BF-D9A9-46F2-882C-68C48FF5FF33} + {9F9CBDD0-C8B3-4E79-B2B3-9ADE4AE08AEA} = {A3766414-EB5C-40F7-B031-121804ED5D0A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {ABF8089E-43D0-4010-84A7-7A9DCFE49357} diff --git a/src/Shared/test/Certificates/selfSignedNoEkuCertificateExpired.cer b/src/Shared/test/Certificates/selfSignedNoEkuCertificateExpired.cer new file mode 100644 index 000000000000..81b6326d6f1e Binary files /dev/null and b/src/Shared/test/Certificates/selfSignedNoEkuCertificateExpired.cer differ diff --git a/src/Shared/test/Certificates/selfSignedNoEkuCertificateNotValidYet.cer b/src/Shared/test/Certificates/selfSignedNoEkuCertificateNotValidYet.cer new file mode 100644 index 000000000000..9c8cf9d71b22 Binary files /dev/null and b/src/Shared/test/Certificates/selfSignedNoEkuCertificateNotValidYet.cer differ diff --git a/src/Shared/test/Certificates/validSelfSignedClientEkuCertificate.cer b/src/Shared/test/Certificates/validSelfSignedClientEkuCertificate.cer new file mode 100644 index 000000000000..db4bb5b90a07 Binary files /dev/null and b/src/Shared/test/Certificates/validSelfSignedClientEkuCertificate.cer differ diff --git a/src/Shared/test/Certificates/validSelfSignedNoEkuCertificate.cer b/src/Shared/test/Certificates/validSelfSignedNoEkuCertificate.cer new file mode 100644 index 000000000000..2be2a46d7992 Binary files /dev/null and b/src/Shared/test/Certificates/validSelfSignedNoEkuCertificate.cer differ diff --git a/src/Shared/test/Certificates/validSelfSignedServerEkuCertificate.cer b/src/Shared/test/Certificates/validSelfSignedServerEkuCertificate.cer new file mode 100644 index 000000000000..823000d4a147 Binary files /dev/null and b/src/Shared/test/Certificates/validSelfSignedServerEkuCertificate.cer differ