Optional client certificates sample (#21484)

* Add an optional client certs example

* Add the Challenge event

* PR cleanup
This commit is contained in:
Chris Ross 2020-06-04 18:34:21 -07:00 committed by GitHub
parent da52d6b636
commit 2bf3960dea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 229 additions and 2 deletions

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.Authentication.Certificate" />
<Reference Include="Microsoft.AspNetCore.Authorization.Policy" />
<Reference Include="Microsoft.AspNetCore.Diagnostics" />
<Reference Include="Microsoft.AspNetCore.Hosting" />
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
<Reference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
</Project>

View File

@ -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<Startup>();
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;
});
});
});
});
}
}

View File

@ -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/"
}
}
}

View File

@ -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.

View File

@ -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");
});
});
}
}
}

View File

@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Debug",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

View File

@ -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)

View File

@ -28,6 +28,11 @@ namespace Microsoft.AspNetCore.Authentication.Certificate
/// </remarks>
public Func<CertificateValidatedContext, Task> OnCertificateValidated { get; set; } = context => Task.CompletedTask;
/// <summary>
/// Invoked before a challenge is sent back to the caller.
/// </summary>
public Func<CertificateChallengeContext, Task> OnChallenge { get; set; } = context => Task.CompletedTask;
/// <summary>
/// Invoked when a certificate fails authentication.
/// </summary>
@ -41,5 +46,10 @@ namespace Microsoft.AspNetCore.Authentication.Certificate
/// <param name="context"></param>
/// <returns></returns>
public virtual Task CertificateValidated(CertificateValidatedContext context) => OnCertificateValidated(context);
/// <summary>
/// Invoked before a challenge is sent back to the caller.
/// </summary>
public virtual Task Challenge(CertificateChallengeContext context) => OnChallenge(context);
}
}

View File

@ -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
{
/// <summary>
/// State for the Challenge event.
/// </summary>
public class CertificateChallengeContext : PropertiesContext<CertificateAuthenticationOptions>
{
/// <summary>
/// Creates a new <see cref="CertificateChallengeContext"/>.
/// </summary>
/// <param name="context"></param>
/// <param name="scheme"></param>
/// <param name="options"></param>
/// <param name="properties"></param>
public CertificateChallengeContext(
HttpContext context,
AuthenticationScheme scheme,
CertificateAuthenticationOptions options,
AuthenticationProperties properties)
: base(context, scheme, options, properties) { }
/// <summary>
/// If true, will skip any default logic for this challenge.
/// </summary>
public bool Handled { get; private set; }
/// <summary>
/// Skips any default logic for this challenge.
/// </summary>
public void HandleResponse() => Handled = true;
}
}

View File

@ -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}