diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Certificate.Optional.Sample.csproj b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Certificate.Optional.Sample.csproj
new file mode 100644
index 0000000000..c89c416ab8
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Certificate.Optional.Sample.csproj
@@ -0,0 +1,18 @@
+
+
+
+ $(DefaultNetCoreTargetFramework)
+ OutOfProcess
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Program.cs b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Program.cs
new file mode 100644
index 0000000000..0edba882f7
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Program.cs
@@ -0,0 +1,44 @@
+using System.Net;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Https;
+using Microsoft.Extensions.Hosting;
+
+namespace Certificate.Optional.Sample
+{
+ public class Program
+ {
+ public const string HostWithoutCert = "127.0.0.1";
+ public const string HostWithCert = "127.0.0.2";
+
+ public static void Main(string[] args)
+ {
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ webBuilder.ConfigureKestrel((context, options) =>
+ {
+ // Kestrel can't have different ssl settings for different hosts on the same IP because there's no way to change them based on SNI.
+ // https://github.com/dotnet/runtime/issues/31097
+ options.Listen(IPAddress.Parse(HostWithoutCert), 5001, listenOptions =>
+ {
+ listenOptions.UseHttps(httpsOptions =>
+ {
+ httpsOptions.ClientCertificateMode = ClientCertificateMode.NoCertificate;
+ });
+ });
+ options.Listen(IPAddress.Parse(HostWithCert), 5001, listenOptions =>
+ {
+ listenOptions.UseHttps(httpsOptions =>
+ {
+ httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
+ });
+ });
+ });
+ });
+ }
+}
diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Properties/launchSettings.json b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Properties/launchSettings.json
new file mode 100644
index 0000000000..eae26333a3
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Properties/launchSettings.json
@@ -0,0 +1,20 @@
+{
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "https://localhost:44331/",
+ "sslPort": 44331
+ }
+ },
+ "profiles": {
+ "Certificate.Optional.Sample": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://127.0.0.1:5001/"
+ }
+ }
+}
diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/README.md b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/README.md
new file mode 100644
index 0000000000..805fe3f2d7
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/README.md
@@ -0,0 +1,12 @@
+Optional certificates sample
+============================
+
+Client certificates are relatively easy to configure when they're required for all requests, you configure it in the server bindings as required and add the auth handler to validate it. Things are much trickier when you only want to require client certificates for some parts of your application.
+
+Client certificates are not an HTTP feature, they're a TLS feature. As such they're not included in the HTTP request structure like headers, they're negotiated when establishing the connection. This makes it impossible to require a certificate for some requests but not others on a given connection.
+
+There's an old way to renegotiate a connection if you find you need a client cert after it's established. It's a TLS action that pauses all traffic, redoes the TLS handshake, and allows you to request a client certificate. This caused a number of problems including weakening security, TCP deadlocks for POST requests, etc.. HTTP/2 has since disallowed this mechanism.
+
+This example shows an pattern for requiring client certificates only in some parts of your site by using different host bindings. The application is set up using two host names, mydomain.com and cert.mydomain.com (I've cheated and used 127.0.0.1 and 127.0.0.2 here instead to avoid setting up DNS). cert.mydomain.com is configured in the server to require client certificates, but mydomain.com is not. When you request part of the site that requires a client certificate it can redirect to the cert.mydomain.com while preserving the request path and query and the client will prompt for a certificate.
+
+Redirecting back to mydomain.com does not accomplish a real sign-out because the browser still caches the client cert selected for cert.mydomain.com. The only way to clear the browser cache is to close the browser.
diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Startup.cs b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Startup.cs
new file mode 100644
index 0000000000..df4204b966
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/Startup.cs
@@ -0,0 +1,61 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authentication.Certificate;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Certificate.Optional.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()
+ {
+ // If there is no certificate we must be on HostWithoutCert that does not require one. Redirect to HostWithCert to prompt for a certificate.
+ OnChallenge = context =>
+ {
+ var request = context.Request;
+ var redirect = UriHelper.BuildAbsolute("https",
+ new HostString(Program.HostWithCert, context.HttpContext.Connection.LocalPort),
+ request.PathBase, request.Path, request.QueryString);
+ context.Response.Redirect(redirect, permanent: false, preserveMethod: true);
+ context.HandleResponse(); // Don't do the default behavior that would send a 403 response.
+ return Task.CompletedTask;
+ }
+ };
+ });
+
+ services.AddAuthorization();
+ }
+
+ // 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.UseAuthentication();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.Map("/auth", context =>
+ {
+ return context.Response.WriteAsync($"Hello {context.User.Identity.Name} at {context.Request.Host}");
+ }).RequireAuthorization();
+
+ endpoints.Map("{*url}", context =>
+ {
+ return context.Response.WriteAsync($"Hello {context.User.Identity.Name} at {context.Request.Host}. Try /auth");
+ });
+ });
+ }
+ }
+}
diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/appsettings.json b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/appsettings.json
new file mode 100644
index 0000000000..5ad1d76f0e
--- /dev/null
+++ b/src/Security/Authentication/Certificate/samples/Certificate.Optional.Sample/appsettings.json
@@ -0,0 +1,10 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Debug",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs
index a33b05ceb8..e8f053d7ed 100644
--- a/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs
+++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs
@@ -130,11 +130,19 @@ namespace Microsoft.AspNetCore.Authentication.Certificate
}
}
- protected override Task HandleChallengeAsync(AuthenticationProperties properties)
+ protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
+ var authenticationChallengedContext = new CertificateChallengeContext(Context, Scheme, Options, properties);
+ await Events.Challenge(authenticationChallengedContext);
+
+ if (authenticationChallengedContext.Handled)
+ {
+ return;
+ }
+
// 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);
+ await HandleForbiddenAsync(properties);
}
private X509ChainPolicy BuildChainPolicy(X509Certificate2 certificate)
diff --git a/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs
index bf6e559e5f..b839aca90f 100644
--- a/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs
+++ b/src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs
@@ -28,6 +28,11 @@ namespace Microsoft.AspNetCore.Authentication.Certificate
///
public Func OnCertificateValidated { get; set; } = context => Task.CompletedTask;
+ ///
+ /// Invoked before a challenge is sent back to the caller.
+ ///
+ public Func OnChallenge { get; set; } = context => Task.CompletedTask;
+
///
/// Invoked when a certificate fails authentication.
///
@@ -41,5 +46,10 @@ namespace Microsoft.AspNetCore.Authentication.Certificate
///
///
public virtual Task CertificateValidated(CertificateValidatedContext context) => OnCertificateValidated(context);
+
+ ///
+ /// Invoked before a challenge is sent back to the caller.
+ ///
+ public virtual Task Challenge(CertificateChallengeContext context) => OnChallenge(context);
}
}
diff --git a/src/Security/Authentication/Certificate/src/Events/CertificateChallengeContext.cs b/src/Security/Authentication/Certificate/src/Events/CertificateChallengeContext.cs
new file mode 100644
index 0000000000..e7f9b8f207
--- /dev/null
+++ b/src/Security/Authentication/Certificate/src/Events/CertificateChallengeContext.cs
@@ -0,0 +1,37 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Authentication.Certificate
+{
+ ///
+ /// State for the Challenge event.
+ ///
+ public class CertificateChallengeContext : PropertiesContext
+ {
+ ///
+ /// Creates a new .
+ ///
+ ///
+ ///
+ ///
+ ///
+ public CertificateChallengeContext(
+ HttpContext context,
+ AuthenticationScheme scheme,
+ CertificateAuthenticationOptions options,
+ AuthenticationProperties properties)
+ : base(context, scheme, options, properties) { }
+
+ ///
+ /// If true, will skip any default logic for this challenge.
+ ///
+ public bool Handled { get; private set; }
+
+ ///
+ /// Skips any default logic for this challenge.
+ ///
+ public void HandleResponse() => Handled = true;
+ }
+}
diff --git a/src/Security/Security.sln b/src/Security/Security.sln
index 32566c7143..30eafcc0ee 100644
--- a/src/Security/Security.sln
+++ b/src/Security/Security.sln
@@ -164,6 +164,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.HttpSys", "..\Servers\HttpSys\src\Microsoft.AspNetCore.Server.HttpSys.csproj", "{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certificate.Optional.Sample", "Authentication\Certificate\samples\Certificate.Optional.Sample\Certificate.Optional.Sample.csproj", "{1B6960CF-0421-405A-B357-4CCC42255CA7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -426,6 +428,10 @@ Global
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1B6960CF-0421-405A-B357-4CCC42255CA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1B6960CF-0421-405A-B357-4CCC42255CA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1B6960CF-0421-405A-B357-4CCC42255CA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1B6960CF-0421-405A-B357-4CCC42255CA7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -507,6 +513,7 @@ Global
{A665A1F8-D1A4-42AC-B8E9-71B6F57481D8} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
{666AFB4D-68A5-4621-BB55-2CD82F0FB1F8} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
+ {1B6960CF-0421-405A-B357-4CCC42255CA7} = {4DF524BF-D9A9-46F2-882C-68C48FF5FF33}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {ABF8089E-43D0-4010-84A7-7A9DCFE49357}