Skip to content

Commit 2bf3960

Browse files
authored
Optional client certificates sample (#21484)
* Add an optional client certs example * Add the Challenge event * PR cleanup
1 parent da52d6b commit 2bf3960

File tree

10 files changed

+229
-2
lines changed

10 files changed

+229
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5+
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<Reference Include="Microsoft.AspNetCore" />
10+
<Reference Include="Microsoft.AspNetCore.Authentication.Certificate" />
11+
<Reference Include="Microsoft.AspNetCore.Authorization.Policy" />
12+
<Reference Include="Microsoft.AspNetCore.Diagnostics" />
13+
<Reference Include="Microsoft.AspNetCore.Hosting" />
14+
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
15+
<Reference Include="Microsoft.Extensions.Hosting" />
16+
</ItemGroup>
17+
18+
</Project>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.AspNetCore.Server.Kestrel.Https;
4+
using Microsoft.Extensions.Hosting;
5+
6+
namespace Certificate.Optional.Sample
7+
{
8+
public class Program
9+
{
10+
public const string HostWithoutCert = "127.0.0.1";
11+
public const string HostWithCert = "127.0.0.2";
12+
13+
public static void Main(string[] args)
14+
{
15+
CreateHostBuilder(args).Build().Run();
16+
}
17+
18+
public static IHostBuilder CreateHostBuilder(string[] args) =>
19+
Host.CreateDefaultBuilder(args)
20+
.ConfigureWebHostDefaults(webBuilder =>
21+
{
22+
webBuilder.UseStartup<Startup>();
23+
webBuilder.ConfigureKestrel((context, options) =>
24+
{
25+
// 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.
26+
// https://github.com/dotnet/runtime/issues/31097
27+
options.Listen(IPAddress.Parse(HostWithoutCert), 5001, listenOptions =>
28+
{
29+
listenOptions.UseHttps(httpsOptions =>
30+
{
31+
httpsOptions.ClientCertificateMode = ClientCertificateMode.NoCertificate;
32+
});
33+
});
34+
options.Listen(IPAddress.Parse(HostWithCert), 5001, listenOptions =>
35+
{
36+
listenOptions.UseHttps(httpsOptions =>
37+
{
38+
httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
39+
});
40+
});
41+
});
42+
});
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"iisSettings": {
3+
"windowsAuthentication": false,
4+
"anonymousAuthentication": true,
5+
"iisExpress": {
6+
"applicationUrl": "https://localhost:44331/",
7+
"sslPort": 44331
8+
}
9+
},
10+
"profiles": {
11+
"Certificate.Optional.Sample": {
12+
"commandName": "Project",
13+
"launchBrowser": true,
14+
"environmentVariables": {
15+
"ASPNETCORE_ENVIRONMENT": "Development"
16+
},
17+
"applicationUrl": "https://127.0.0.1:5001/"
18+
}
19+
}
20+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Optional certificates sample
2+
============================
3+
4+
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.
5+
6+
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.
7+
8+
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.
9+
10+
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.
11+
12+
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.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Threading.Tasks;
2+
using Microsoft.AspNetCore.Authentication.Certificate;
3+
using Microsoft.AspNetCore.Builder;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Http.Extensions;
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace Certificate.Optional.Sample
10+
{
11+
public class Startup
12+
{
13+
// This method gets called by the runtime. Use this method to add services to the container.
14+
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
15+
public void ConfigureServices(IServiceCollection services)
16+
{
17+
services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
18+
.AddCertificate(options =>
19+
{
20+
options.Events = new CertificateAuthenticationEvents()
21+
{
22+
// If there is no certificate we must be on HostWithoutCert that does not require one. Redirect to HostWithCert to prompt for a certificate.
23+
OnChallenge = context =>
24+
{
25+
var request = context.Request;
26+
var redirect = UriHelper.BuildAbsolute("https",
27+
new HostString(Program.HostWithCert, context.HttpContext.Connection.LocalPort),
28+
request.PathBase, request.Path, request.QueryString);
29+
context.Response.Redirect(redirect, permanent: false, preserveMethod: true);
30+
context.HandleResponse(); // Don't do the default behavior that would send a 403 response.
31+
return Task.CompletedTask;
32+
}
33+
};
34+
});
35+
36+
services.AddAuthorization();
37+
}
38+
39+
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
40+
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
41+
{
42+
app.UseRouting();
43+
44+
app.UseAuthentication();
45+
app.UseAuthorization();
46+
47+
app.UseEndpoints(endpoints =>
48+
{
49+
endpoints.Map("/auth", context =>
50+
{
51+
return context.Response.WriteAsync($"Hello {context.User.Identity.Name} at {context.Request.Host}");
52+
}).RequireAuthorization();
53+
54+
endpoints.Map("{*url}", context =>
55+
{
56+
return context.Response.WriteAsync($"Hello {context.User.Identity.Name} at {context.Request.Host}. Try /auth");
57+
});
58+
});
59+
}
60+
}
61+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft": "Debug",
6+
"Microsoft.Hosting.Lifetime": "Information"
7+
}
8+
},
9+
"AllowedHosts": "*"
10+
}

src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,19 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
130130
}
131131
}
132132

133-
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
133+
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
134134
{
135+
var authenticationChallengedContext = new CertificateChallengeContext(Context, Scheme, Options, properties);
136+
await Events.Challenge(authenticationChallengedContext);
137+
138+
if (authenticationChallengedContext.Handled)
139+
{
140+
return;
141+
}
142+
135143
// Certificate authentication takes place at the connection level. We can't prompt once we're in
136144
// user code, so the best thing to do is Forbid, not Challenge.
137-
return HandleForbiddenAsync(properties);
145+
await HandleForbiddenAsync(properties);
138146
}
139147

140148
private X509ChainPolicy BuildChainPolicy(X509Certificate2 certificate)

src/Security/Authentication/Certificate/src/Events/CertificateAuthenticationEvents.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ public class CertificateAuthenticationEvents
2828
/// </remarks>
2929
public Func<CertificateValidatedContext, Task> OnCertificateValidated { get; set; } = context => Task.CompletedTask;
3030

31+
/// <summary>
32+
/// Invoked before a challenge is sent back to the caller.
33+
/// </summary>
34+
public Func<CertificateChallengeContext, Task> OnChallenge { get; set; } = context => Task.CompletedTask;
35+
3136
/// <summary>
3237
/// Invoked when a certificate fails authentication.
3338
/// </summary>
@@ -41,5 +46,10 @@ public class CertificateAuthenticationEvents
4146
/// <param name="context"></param>
4247
/// <returns></returns>
4348
public virtual Task CertificateValidated(CertificateValidatedContext context) => OnCertificateValidated(context);
49+
50+
/// <summary>
51+
/// Invoked before a challenge is sent back to the caller.
52+
/// </summary>
53+
public virtual Task Challenge(CertificateChallengeContext context) => OnChallenge(context);
4454
}
4555
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Http;
5+
6+
namespace Microsoft.AspNetCore.Authentication.Certificate
7+
{
8+
/// <summary>
9+
/// State for the Challenge event.
10+
/// </summary>
11+
public class CertificateChallengeContext : PropertiesContext<CertificateAuthenticationOptions>
12+
{
13+
/// <summary>
14+
/// Creates a new <see cref="CertificateChallengeContext"/>.
15+
/// </summary>
16+
/// <param name="context"></param>
17+
/// <param name="scheme"></param>
18+
/// <param name="options"></param>
19+
/// <param name="properties"></param>
20+
public CertificateChallengeContext(
21+
HttpContext context,
22+
AuthenticationScheme scheme,
23+
CertificateAuthenticationOptions options,
24+
AuthenticationProperties properties)
25+
: base(context, scheme, options, properties) { }
26+
27+
/// <summary>
28+
/// If true, will skip any default logic for this challenge.
29+
/// </summary>
30+
public bool Handled { get; private set; }
31+
32+
/// <summary>
33+
/// Skips any default logic for this challenge.
34+
/// </summary>
35+
public void HandleResponse() => Handled = true;
36+
}
37+
}

src/Security/Security.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server
164164
EndProject
165165
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.HttpSys", "..\Servers\HttpSys\src\Microsoft.AspNetCore.Server.HttpSys.csproj", "{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}"
166166
EndProject
167+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certificate.Optional.Sample", "Authentication\Certificate\samples\Certificate.Optional.Sample\Certificate.Optional.Sample.csproj", "{1B6960CF-0421-405A-B357-4CCC42255CA7}"
168+
EndProject
167169
Global
168170
GlobalSection(SolutionConfigurationPlatforms) = preSolution
169171
Debug|Any CPU = Debug|Any CPU
@@ -426,6 +428,10 @@ Global
426428
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Debug|Any CPU.Build.0 = Debug|Any CPU
427429
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Release|Any CPU.ActiveCfg = Release|Any CPU
428430
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22}.Release|Any CPU.Build.0 = Release|Any CPU
431+
{1B6960CF-0421-405A-B357-4CCC42255CA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
432+
{1B6960CF-0421-405A-B357-4CCC42255CA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
433+
{1B6960CF-0421-405A-B357-4CCC42255CA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
434+
{1B6960CF-0421-405A-B357-4CCC42255CA7}.Release|Any CPU.Build.0 = Release|Any CPU
429435
EndGlobalSection
430436
GlobalSection(SolutionProperties) = preSolution
431437
HideSolutionNode = FALSE
@@ -507,6 +513,7 @@ Global
507513
{A665A1F8-D1A4-42AC-B8E9-71B6F57481D8} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
508514
{666AFB4D-68A5-4621-BB55-2CD82F0FB1F8} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
509515
{D6C3C4A9-197B-47B5-8B72-35047CBC4F22} = {A3766414-EB5C-40F7-B031-121804ED5D0A}
516+
{1B6960CF-0421-405A-B357-4CCC42255CA7} = {4DF524BF-D9A9-46F2-882C-68C48FF5FF33}
510517
EndGlobalSection
511518
GlobalSection(ExtensibilityGlobals) = postSolution
512519
SolutionGuid = {ABF8089E-43D0-4010-84A7-7A9DCFE49357}

0 commit comments

Comments
 (0)