在 ASP.NET Core 中配置证书身份验证

Microsoft.AspNetCore.Authentication.Certificate 包含类似于 ASP.NET Core 的证书身份验证的实现。 证书身份验证在 TLS 级别发生,远在到达 ASP.NET Core 之前。 更准确地说,这是一个身份验证处理程序,该程序验证证书,然后提供一个事件,可在其中将证书解析为 ClaimsPrincipal

必须配置服务器以执行证书身份验证,无论你使用的是 IIS、Kestrel、Azure Web 应用还是其他服务。

代理和负载均衡器方案

证书身份验证是一种有状态方案,主要用于代理或负载均衡器不处理客户端和服务器之间流量的情况。 如果使用了代理或负载平衡器,则仅当代理或负载均衡器符合以下情况时证书身份验证才起作用:

  • 会处理身份验证。
  • 将用户身份验证信息传递给应用(例如,在请求头中),应用根据身份验证信息执行操作。

在使用代理和负载平衡器的环境中,证书身份验证的另一种选择是结合使用 Active Directory 联合服务 (ADFS) 和 OpenID Connect (OIDC)。

入门

获取 HTTPS 证书,应用该证书,并配置服务器,将其配置为索要证书。

在 Web 应用中:

  • 添加对 Microsoft.AspNetCore.Authentication.Certificate NuGet 包的引用。
  • Program.cs 中,调用 builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(...);。 提供一个委托,使 OnCertificateValidated 能够对与请求一起发送的客户端证书执行任何补充验证。 将该信息转换为 ClaimsPrincipal 并在 context.Principal 属性上设置。

如果身份验证失败,此处理程序会返回一个 403 (Forbidden) 响应,而不是 401 (Unauthorized),你可能已经想到了。 原因是,身份验证应在初次 TLS 连接期间进行。 它到达处理程序的时间太晚。 无法将连接从匿名连接升级到具有证书的连接。

需要 UseAuthentication 来将 HttpContext.User 设置为从证书创建的 ClaimsPrincipal。 例如:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate();

var app = builder.Build();

app.UseAuthentication();

app.MapGet("/", () => "Hello World!");

app.Run();

前面的示例演示了添加证书身份验证的默认方法。 处理程序使用通用证书属性构造用户主体。

配置证书验证

CertificateAuthenticationOptions 处理程序具有一些内置验证,这些验证是需要对证书执行的最小程度的验证。 默认启用这些设置中的每一个。

AllowedCertificateTypes = Chained, SelfSigned, or All (Chained | SelfSigned)

默认值:30CertificateTypes.Chained

此检查验证是否只允许使用适当的证书类型。 如果应用使用自签名证书,则此选项需要设置为 CertificateTypes.AllCertificateTypes.SelfSigned

ChainTrustValidationMode

默认值:X509ChainTrustMode.System

客户端提供的证书必须链接到受信任的根证书。 此检查控制哪些信任存储包含这些根证书。

默认情况下,处理程序使用系统信任存储。 如果提供的客户端证书需要链接到系统信任存储中不包含的根证书,可以将此选项设置为 X509ChainTrustMode.CustomRootTrust,以使处理程序使用 CustomTrustStore

CustomTrustStore

默认值:空 X509Certificate2Collection

如果处理程序的 ChainTrustValidationMode 属性设置为 X509ChainTrustMode.CustomRootTrust,则此 X509Certificate2Collection 包含用于验证客户端证书的、上至(且包括)受信任的根证书的每个证书。

当客户端提供属于多级证书链的证书时,CustomTrustStore 必须包含链中的每个颁发证书。

ValidateCertificateUse

默认值:30true

此检查验证客户端提供的证书是否具有客户端身份验证“增强型密钥使用”(EKU) 或完全没有 EKU。 根据规范,如果未指定 EKU,则所有 EKU 均视为有效。

ValidateValidityPeriod

默认值:30true

此检查验证证书是否在其有效期内。 对于每次请求,处理程序都会确保在出示时有效的证书在其当前会话期间没有过期。

RevocationFlag

默认值:30X509RevocationFlag.ExcludeRoot

一个标志,该标志指定将检查链中的哪些证书,确认其的吊销状态。

仅当证书链接到根证书时才对证书执行吊销检查。

RevocationMode

默认值:30X509RevocationMode.Online

一个标志,该标志指定如何执行吊销检查。

指定联机检查可能会导致在联系证书颁发机构时发生长时间延迟。

仅当证书链接到根证书时才对证书执行吊销检查。

我可以将我的应用配置为仅在某些路径上索要证书吗?

这不可能。 请记住,证书交换是在 HTTPS 会话开始时完成的,它是由服务器在该连接上收到第一个请求之前完成的,因此无法基于任何请求字段限定作用域。

处理程序事件

处理程序有两个事件:

  • OnAuthenticationFailed:在身份验证期间发生异常且你能够作出反应时调用。
  • OnCertificateValidated:在对证书进行了验证、通过验证并创建了默认主体后调用。 此事件使你能够执行自己的验证并增强或替换主体。 示例包括:
    • 确定你的服务是否知道该证书。

    • 构造你自己的主体。 请考虑以下示例:

      builder.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;
                  }
              };
          });
      

如果发现入站证书不符合额外的验证要求,请调用 context.Fail("failure reason") 并附带失败原因。

为了获得更好的功能,请调用注册了“依赖关系注入”且连接到数据库或其他类型用户存储的服务。 通过使用传入委托的上下文来访问服务。 请考虑以下示例:

builder.Services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                var validationService = context.HttpContext.RequestServices
                    .GetRequiredService<ICertificateValidationService>();

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

从概念上讲,证书验证与授权相关。 在授权策略中而不是在 OnCertificateValidated 中添加(例如)对颁发者或指纹的检查是完全可以接受的。

将服务器配置为索要证书

Kestrel

Program.cs 中,按如下所示配置 Kestrel:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<KestrelServerOptions>(options =>
{
    options.ConfigureHttpsDefaults(options =>
        options.ClientCertificateMode = ClientCertificateMode.RequireCertificate);
});

注意

通过在调用 ConfigureHttpsDefaults 之前调用 Listen 创建的终结点将不会应用默认值。

IIS

在 IIS 管理器中完成以下步骤:

  1. 从“连接”选项卡中选择你的站点。
  2. 双击“功能视图”窗口中的”SSL 设置”选项。
  3. 选中“需要 SSL”复选框,并在“客户端证书”部分中选择“需要”单选按钮。

Client certificate settings in IIS

Azure 和自定义 Web 代理

有关如何配置证书转发中间件的信息,请参阅托管和部署文档

在 Azure Web 应用中使用证书身份验证

不需要针对 Azure 配置转发。 转发配置由证书转发中间件设置。

注意

此方案需要证书转发中间件。

有关详细信息,请参阅在 Azure 应用服务中的代码中使用 TLS/SSL 证书(Azure 文档)

在自定义 Web 代理中使用证书身份验证

AddCertificateForwarding 方法用于指定:

  • 客户端标头名称。
  • 如何加载证书(使用 HeaderConverter 属性)。

在自定义 Web 代理中,证书作为自定义请求头传递,例如 X-SSL-CERT。 要使用它,请在 Program.cs 中配置证书转发:

builder.Services.AddCertificateForwarding(options =>
{
    options.CertificateHeader = "X-SSL-CERT";

    options.HeaderConverter = headerValue =>
    {
        X509Certificate2? clientCertificate = null;

        if (!string.IsNullOrWhiteSpace(headerValue))
        {
            clientCertificate = new X509Certificate2(StringToByteArray(headerValue));
        }

        return clientCertificate!;

        static byte[] StringToByteArray(string hex)
        {
            var numberChars = hex.Length;
            var bytes = new byte[numberChars / 2];

            for (int i = 0; i < numberChars; i += 2)
            {
                bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
            }

            return bytes;
        }
    };
});

如果应用由 NGINX 通过配置 proxy_set_header ssl-client-cert $ssl_client_escaped_cert 反向代理,或使用 NGINX 入口部署在 Kubernetes 上,则客户端证书将以 URL 编码形式传递给应用。 要使用证书,请按如下方式对其进行解码:

builder.Services.AddCertificateForwarding(options =>
{
    options.CertificateHeader = "ssl-client-cert";

    options.HeaderConverter = (headerValue) =>
    {
        X509Certificate2? clientCertificate = null;

        if (!string.IsNullOrWhiteSpace(headerValue))
        {
            clientCertificate = X509Certificate2.CreateFromPem(
                WebUtility.UrlDecode(headerValue));
        }

        return clientCertificate!;
    };
});

Program.cs 中添加中间件。 UseCertificateForwarding 在调用 UseAuthenticationUseAuthorization 之前被调用:

var app = builder.Build();

app.UseCertificateForwarding();

app.UseAuthentication();
app.UseAuthorization();

可以使用单独的类来实现验证逻辑。 由于本例中使用了相同的自签名证书,因此请确保只有你的证书能使用。 验证客户端证书和服务器证书的指纹是否均匹配,否则任何证书都可以使用并足以进行身份验证。 这将在 AddCertificate 方法中使用。 如果使用的是中间证书或子证书,还可以在此处验证主题或颁发者。

using System.Security.Cryptography.X509Certificates;

namespace CertAuthSample.Snippets;

public class SampleCertificateValidationService : ICertificateValidationService
{
    public bool ValidateCertificate(X509Certificate2 clientCertificate)
    {
        // Don't hardcode passwords in production code.
        // Use a certificate thumbprint or Azure Key Vault.
        var expectedCertificate = new X509Certificate2(
            Path.Combine("/path/to/pfx"), "1234");

        return clientCertificate.Thumbprint == expectedCertificate.Thumbprint;
    }
}

使用证书和 IHttpClientFactory 实现 HttpClient

在下面的示例中,使用了处理程序中的 ClientCertificates 属性将客户端证书添加到 HttpClientHandler。 之后可以使用 ConfigurePrimaryHttpMessageHandler 方法在 HttpClient 的命名实例中使用此处理程序。 这在 Program.cs 中设置:

var clientCertificate =
    new X509Certificate2(
      Path.Combine(_environment.ContentRootPath, "sts_dev_cert.pfx"), "1234");

builder.Services.AddHttpClient("namedClient", c =>
{
}).ConfigurePrimaryHttpMessageHandler(() =>
{
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(clientCertificate);
    return handler;
});

然后可以使用 IHttpClientFactory 获取具有该处理程序和证书的命名实例。 使用具有在 Program.cs 中定义的客户端名称的 CreateClient 方法来获取实例。 可根据需要使用客户端发送 HTTP 请求:

public class SampleHttpService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public SampleHttpService(IHttpClientFactory httpClientFactory)
        => _httpClientFactory = httpClientFactory;

    public async Task<JsonDocument> GetAsync()
    {
        var httpClient = _httpClientFactory.CreateClient("namedClient");
        var httpResponseMessage = await httpClient.GetAsync("https://example.com");

        if (httpResponseMessage.IsSuccessStatusCode)
        {
            return JsonDocument.Parse(
                await httpResponseMessage.Content.ReadAsStringAsync());
        }

        throw new ApplicationException($"Status code: {httpResponseMessage.StatusCode}");
    }
}

如果将正确的证书发送到服务器,则会返回数据。 如果未发送证书或发送的证书不正确,则会返回 HTTP 403 状态代码。

在 PowerShell 中创建证书

创建证书是设置此流程最难的部分。 可以使用 New-SelfSignedCertificate PowerShell cmdlet 创建根证书。 创建证书时,请使用强密码。 如图所示添加 KeyUsageProperty 参数和 KeyUsage 参数非常重要。

创建根 CA

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath root_ca_dev_damienbod.crt

注意

-DnsName 参数值必须与应用的部署目标匹配。 例如,用于开发的“localhost”。

在受信任的根中安装

根证书需要在主机系统上受信任。 默认情况下,不会信任并非由证书颁发机构创建的根证书。 有关如何信任 Windows 上的根证书的信息,请参阅此问题

中间证书

现在可以从根证书创建中间证书。 这并不是所有用例所必需的,但有可能需要创建多个证书或需要激活或禁用证书组。 TextExtension 参数是设置证书基本约束中的路径长度所必需的。

然后可以将中间证书添加为 Windows 主机系统中受信任的中间证书。

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint of the root..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "intermediate_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "intermediate_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\intermediate_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath intermediate_dev_damienbod.crt

从中间证书创建子证书

可以从中间证书创建子证书。 这是最终实体,不需要创建更多的子证书。

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the Intermediate certificate..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

从根证书创建子证书

还可以直接从根证书创建子证书。

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the root cert..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

示例根 - 中间证书 - 证书

$mypwdroot = ConvertTo-SecureString -String "1234" -Force -AsPlainText
$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

Get-ChildItem -Path cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwdroot

Export-Certificate -Cert cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 -FilePath root_ca_dev_damienbod.crt

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\0C89639E4E2998A93E423F919B36D4009A0F9991 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 -FilePath child_a_dev_damienbod.crt

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\BA9BF91ED35538A01375EFC212A2F46104B33A44 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_b_from_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_b_from_a_dev_damienbod.com" 

Get-ChildItem -Path cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_b_from_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A -FilePath child_b_from_a_dev_damienbod.crt

使用根证书、中间证书或子证书时,可以根据需要使用指纹或公钥验证证书:

using System.Security.Cryptography.X509Certificates;

namespace CertAuthSample.Snippets;

public class SampleCertificateThumbprintsValidationService : ICertificateValidationService
{
    private readonly string[] validThumbprints = new[]
    {
        "141594A0AE38CBBECED7AF680F7945CD51D8F28A",
        "0C89639E4E2998A93E423F919B36D4009A0F9991",
        "BA9BF91ED35538A01375EFC212A2F46104B33A44"
    };

    public bool ValidateCertificate(X509Certificate2 clientCertificate)
        => validThumbprints.Contains(clientCertificate.Thumbprint);
}

证书验证缓存

ASP.NET Core 5.0 及更高版本支持启用缓存验证结果的功能。 缓存极大地提高了证书身份验证的性能,因为验证是一种高成本操作。

默认情况下,证书身份验证禁用缓存。 若要启用缓存,请调用 Program.cs 中的 AddCertificateCache

builder.Services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate()
    .AddCertificateCache(options =>
    {
        options.CacheSize = 1024;
        options.CacheEntryExpiration = TimeSpan.FromMinutes(2);
    });

默认的缓存实现将结果存储在内存中。 可以通过实现 ICertificateValidationCache 并注册“依赖项注入”来提供自己的缓存。 例如,services.AddSingleton<ICertificateValidationCache, YourCache>()

可选客户端证书

本部分提供必须使用证书来保护应用子集的应用的相关信息。 例如,应用中的 Razor 页或控制器可能要求提供客户端证书。 这会带来挑战,因为客户端证书:

  • 是 TLS 功能,而不是 HTTP 功能。
  • 是按连接进行协商的,并且通常是在连接开始时,在任何 HTTP 数据可用之前进行协商。

可以通过两种方法实现可选的客户端证书:

  1. 使用单独的主机名 (SNI) 和重定向。 虽然会增加配置工作,但仍建议使用这种方法,因为它适用于大多数环境和协议。
  2. HTTP 请求期间重新协商。 这种方法有多项限制,不建议使用。

分离主机 (SNI)

在连接开始时,仅服务器名称指示 (SNI)† 是已知的。 可以按主机名配置客户端证书,以便某台主机需要特定证书而其他主机不会需要。

ASP.NET Core 5 及更高版本针对“重定向以获取可选客户端证书”添加了更方便的的支持。 有关详细信息,请参阅可选证书的示例

  • 对于需要客户端证书但没有的 Web 应用的请求:
    • 使用客户端证书保护的子域重定向到同一页面。
    • 例如,重定向到 myClient.contoso.com/requestedPage。 由于对 myClient.contoso.com/requestedPage 的请求是与 contoso.com/requestedPage 不同的主机名,因此客户端建立了不同的连接,并提供了客户端证书。
    • 有关详细信息,请参阅 ASP.NET Core 简介

† 服务器名称指示 (SNI) 是一种 TLS 扩展,可将虚拟域作为 SSL 协商的一部分包括在内。 这实际上表示虚拟域名或主机名可用于标识网络终结点。

重新协商

TLS 重新协商是一个过程,通过该过程,客户端和服务器可以重新评估单个连接的加密要求,包括请求客户端证书(如果以前未提供)。 TLS 重新协商存在安全风险,不建议执行,因为:

  • 在 HTTP/1.1 中,服务器需要先缓冲或使用正在传输的 HTTP 数据,例如 POST 请求正文,以确保连接对于重新协商是清楚明了的。 否则,重新协商可能会停止响应或失败。
  • HTTP/2 和 HTTP/3 明确禁止重新协商。
  • 重新协商会带来安全风险。 TLS 1.3 删除了对整个连接的重新协商,并将其替换为一个新的扩展,用于在连接开始后仅请求客户端证书。 此机制通过相同的 API 公开,并且仍受之前的缓冲和 HTTP 协议版本约束的限制。

此功能的实现和配置因服务器和框架版本而异。

IIS

IIS 会代表你管理客户端证书协商。 应用程序的一个子部分可以启用 SslRequireCert 选项来协商这些请求的客户端证书。 有关详细信息,请参阅 IIS 文档中的配置

在重新协商之前,IIS 将自动缓冲任何请求正文数据,且不超过配置的大小限制。 超过限制的请求将被拒绝,并收到 413 响应。 此限额默认为 48KB,可通过设置 uploadReadAheadSize 进行配置。

HttpSys

HttpSys 有两个设置可控制客户端证书协商,这两个设置都应设置。 第一种是 http add sslcert clientcertnegotiation=enable/disable 下的 netsh.exe。 此标志指示是否应在连接开始时协商客户端证书,并且对于可选客户端证书,应将其设置为 disable。 有关详细信息,请参阅 netsh 文档

另一个设置是 ClientCertificateMethod。 当设置为 AllowRenegotation 时,可以在请求期间重新协商客户端证书。

注意 在尝试重新协商之前,应用程序应缓冲或使用任何请求正文数据,否则请求可能会失去响应。

应用程序可以先检查 ClientCertificate 属性以查看证书是否可用。 如果不可用,请确保在调用 GetClientCertificateAsync 以协商证书之前已使用请求正文。 注意,如果客户端拒绝提供证书,则 GetClientCertificateAsync 可以返回 null 证书。

注意ClientCertificate 属性的行为在 .NET 6 中发生了更改。 有关详细信息,请参阅此 GitHub 问题

Kestrel

Kestrel 使用 ClientCertificateMode 选项控制客户端证书协商。

ClientCertificateMode.DelayCertificate 是 .NET 6 或更高版本中提供的新选项。 经过相应设置后,应用可以检查 ClientCertificate 属性以查看证书是否可用。 如果不可用,请确保在调用 GetClientCertificateAsync 以协商证书之前已使用请求正文。 注意 GetClientCertificateAsync 如果客户端拒绝提供一个空证书,则可以返回该证书。

注意 在尝试重新协商之前,应用程序应缓冲或使用任何请求正文数据,否则 GetClientCertificateAsync 可能引发 InvalidOperationException: Client stream needs to be drained before renegotiation.

如果以编程方式配置每个主机的 TLS 设置,则 .NET 6 及更高版本中有一个新的 UseHttps 重载可用,它采用 TlsHandshakeCallbackOptions 并通过 TlsHandshakeCallbackContext.AllowDelayedClientCertificateNegotation 控制客户端证书重新协商。

Microsoft.AspNetCore.Authentication.Certificate 包含类似于 ASP.NET Core 的证书身份验证的实现。 证书身份验证在 TLS 级别发生,远在到达 ASP.NET Core 之前。 更准确地说,这是一个身份验证处理程序,该程序验证证书,然后提供一个事件,可在其中将证书解析为 ClaimsPrincipal

配置服务器用于执行证书身份验证,无论使用的是 IIS、Kestrel、Azure Web 应用还是其他服务。

代理和负载均衡器方案

证书身份验证是一种有状态方案,主要用于代理或负载均衡器不处理客户端和服务器之间流量的情况。 如果使用了代理或负载平衡器,则仅当代理或负载均衡器符合以下情况时证书身份验证才起作用:

  • 会处理身份验证。
  • 将用户身份验证信息传递给应用(例如,在请求头中),应用根据身份验证信息执行操作。

在使用代理和负载平衡器的环境中,证书身份验证的另一种选择是结合使用 Active Directory 联合服务 (ADFS) 和 OpenID Connect (OIDC)。

入门

获取 HTTPS 证书,应用该证书,并配置服务器,将其配置为索要证书。

在 Web 应用中,添加对 Microsoft.AspNetCore.Authentication.Certificate 包的引用。 然后在 Startup.ConfigureServices 方法中,使用选项调用 services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(...);,以提供一个委托,使 OnCertificateValidated 能够对与请求一起发送的客户端证书执行任何补充验证。 将该信息转换为 ClaimsPrincipal 并在 context.Principal 属性上设置。

如果身份验证失败,此处理程序会返回一个 403 (Forbidden) 响应,而不是 401 (Unauthorized),你可能已经想到了。 原因是,身份验证应在初次 TLS 连接期间进行。 它到达处理程序的时间太晚。 无法将连接从匿名连接升级到具有证书的连接。

还要在 Startup.Configure 方法中添加 app.UseAuthentication();。 否则,HttpContext.User 不会设置为从证书创建的 ClaimsPrincipal。 例如:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
        .AddCertificate()
        // Adding an ICertificateValidationCache results in certificate auth caching the results.
        // The default implementation uses a memory cache.
        .AddCertificateCache();

    // All other service configuration
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseAuthentication();

    // All other app configuration
}

前面的示例演示了添加证书身份验证的默认方法。 处理程序使用通用证书属性构造用户主体。

配置证书验证

CertificateAuthenticationOptions 处理程序具有一些内置验证,这些验证是需要对证书执行的最小程度的验证。 默认启用这些设置中的每一个。

AllowedCertificateTypes = Chained, SelfSigned, or All (Chained | SelfSigned)

默认值:30CertificateTypes.Chained

此检查验证是否只允许使用适当的证书类型。 如果应用使用自签名证书,则此选项需要设置为 CertificateTypes.AllCertificateTypes.SelfSigned

ValidateCertificateUse

默认值:30true

此检查验证客户端提供的证书是否具有客户端身份验证“增强型密钥使用”(EKU) 或完全没有 EKU。 根据规范,如果未指定 EKU,则所有 EKU 均视为有效。

ValidateValidityPeriod

默认值:30true

此检查验证证书是否在其有效期内。 对于每次请求,处理程序都会确保在出示时有效的证书在其当前会话期间没有过期。

RevocationFlag

默认值:30X509RevocationFlag.ExcludeRoot

一个标志,该标志指定将检查链中的哪些证书,确认其的吊销状态。

仅当证书链接到根证书时才对证书执行吊销检查。

RevocationMode

默认值:30X509RevocationMode.Online

一个标志,该标志指定如何执行吊销检查。

指定联机检查可能会导致在联系证书颁发机构时发生长时间延迟。

仅当证书链接到根证书时才对证书执行吊销检查。

我可以将我的应用配置为仅在某些路径上索要证书吗?

这不可能。 请记住,证书交换是在 HTTPS 会话开始时完成的,它是由服务器在该连接上收到第一个请求之前完成的,因此无法基于任何请求字段限定作用域。

处理程序事件

处理程序有两个事件:

  • OnAuthenticationFailed:在身份验证期间发生异常且你能够作出反应时调用。
  • OnCertificateValidated:在对证书进行了验证、通过验证并创建了默认主体后调用。 此事件使你能够执行自己的验证并增强或替换主体。 示例包括:
    • 确定你的服务是否知道该证书。

    • 构造你自己的主体。 请看下面 Startup.ConfigureServices 中的示例:

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

如果发现入站证书不符合额外的验证要求,请调用 context.Fail("failure reason") 并附带失败原因。

为了真正实现功能,很可能需要调用注册了“依赖关系注入”且连接到数据库或其他类型用户存储的服务。 通过使用传递到委托中的上下文访问服务。 请看下面 Startup.ConfigureServices 中的示例:

services.AddAuthentication(
    CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                var validationService =
                    context.HttpContext.RequestServices
                        .GetRequiredService<ICertificateValidationService>();

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

从概念上讲,证书验证与授权相关。 在授权策略中而不是在 OnCertificateValidated 中添加(例如)对颁发者或指纹的检查是完全可以接受的。

将服务器配置为索要证书

Kestrel

Program.cs 中,按如下所示配置 Kestrel:

public static void Main(string[] args)
{
    CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args)
{
    return Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
            webBuilder.ConfigureKestrel(o =>
            {
                o.ConfigureHttpsDefaults(o => 
                    o.ClientCertificateMode =  ClientCertificateMode.RequireCertificate);
            });
        });
}

注意

通过在调用 ConfigureHttpsDefaults 之前调用 Listen 创建的终结点将不会应用默认值。

IIS

在 IIS 管理器中完成以下步骤:

  1. 从“连接”选项卡中选择你的站点。
  2. 双击“功能视图”窗口中的”SSL 设置”选项。
  3. 选中“需要 SSL”复选框,并在“客户端证书”部分中选择“需要”单选按钮。

Client certificate settings in IIS

Azure 和自定义 Web 代理

有关如何配置证书转发中间件的信息,请参阅托管和部署文档

在 Azure Web 应用中使用证书身份验证

不需要针对 Azure 配置转发。 转发配置由证书转发中间件设置。

注意

此方案需要证书转发中间件。

有关详细信息,请参阅在 Azure 应用服务中的代码中使用 TLS/SSL 证书(Azure 文档)

在自定义 Web 代理中使用证书身份验证

AddCertificateForwarding 方法用于指定:

  • 客户端标头名称。
  • 如何加载证书(使用 HeaderConverter 属性)。

在自定义 Web 代理中,证书作为自定义请求头传递,例如 X-SSL-CERT。 要使用它,请在 Startup.ConfigureServices 中配置证书转发:

public void ConfigureServices(IServiceCollection services)
{
    services.AddCertificateForwarding(options =>
    {
        options.CertificateHeader = "X-SSL-CERT";
        options.HeaderConverter = (headerValue) =>
        {
            X509Certificate2 clientCertificate = null;

            if(!string.IsNullOrWhiteSpace(headerValue))
            {
                byte[] bytes = StringToByteArray(headerValue);
                clientCertificate = new X509Certificate2(bytes);
            }

            return clientCertificate;
        };
    });
}

private static byte[] StringToByteArray(string hex)
{
    int NumberChars = hex.Length;
    byte[] bytes = new byte[NumberChars / 2];

    for (int i = 0; i < NumberChars; i += 2)
    {
        bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
    }

    return bytes;
}

如果应用由 NGINX 通过配置 proxy_set_header ssl-client-cert $ssl_client_escaped_cert 反向代理,或使用 NGINX 入口部署在 Kubernetes 上,则客户端证书将以 URL 编码形式传递给应用。 要使用证书,请按如下方式对其进行解码:

Startup.ConfigureServices (Startup.cs) 中:

services.AddCertificateForwarding(options =>
{
    options.CertificateHeader = "ssl-client-cert";
    options.HeaderConverter = (headerValue) =>
    {
        X509Certificate2 clientCertificate = null;

        if (!string.IsNullOrWhiteSpace(headerValue))
        {
            string certPem = WebUtility.UrlDecode(headerValue);
            clientCertificate = X509Certificate2.CreateFromPem(certPem);
        }

        return clientCertificate;
    };
});

然后 Startup.Configure 方法会添加中间件。 UseCertificateForwarding 在调用 UseAuthenticationUseAuthorization 之前被调用:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseRouting();

    app.UseCertificateForwarding();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

可以使用单独的类来实现验证逻辑。 由于本例中使用了相同的自签名证书,因此请确保只有你的证书能使用。 验证客户端证书和服务器证书的指纹是否均匹配,否则任何证书都可以使用并足以进行身份验证。 这将在 AddCertificate 方法中使用。 如果使用的是中间证书或子证书,还可以在此处验证主题或颁发者。

using System.IO;
using System.Security.Cryptography.X509Certificates;

namespace AspNetCoreCertificateAuthApi
{
    public class MyCertificateValidationService
    {
        public bool ValidateCertificate(X509Certificate2 clientCertificate)
        {
            // Do not hardcode passwords in production code
            // Use thumbprint or key vault
            var cert = new X509Certificate2(
                Path.Combine("sts_dev_cert.pfx"), "1234");

            if (clientCertificate.Thumbprint == cert.Thumbprint)
            {
                return true;
            }

            return false;
        }
    }
}

使用证书和 IHttpClientFactory 实现 HttpClient

HttpClientHandler 可以直接添加到 HttpClient 类的构造函数中。 创建 HttpClient 的实例时应谨慎操作。 然后 HttpClient 将证书随每个请求一起发送。

private async Task<JsonDocument> GetApiDataUsingHttpClientHandler()
{
    var cert = new X509Certificate2(Path.Combine(_environment.ContentRootPath, "sts_dev_cert.pfx"), "1234");
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(cert);
    var client = new HttpClient(handler);

    var request = new HttpRequestMessage()
    {
        RequestUri = new Uri("https://localhost:44379/api/values"),
        Method = HttpMethod.Get,
    };
    var response = await client.SendAsync(request);
    if (response.IsSuccessStatusCode)
    {
        var responseContent = await response.Content.ReadAsStringAsync();
        var data = JsonDocument.Parse(responseContent);
        return data;
    }

    throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}

使用证书和来自 IHttpClientFactory 的命名的 HttpClient 实现 HttpClient

在下面的示例中,使用了处理程序中的 ClientCertificates 属性将客户端证书添加到 HttpClientHandler。 之后可以使用 ConfigurePrimaryHttpMessageHandler 方法在 HttpClient 的命名实例中使用此处理程序。 这在 Startup.ConfigureServices 中设置:

var clientCertificate = 
    new X509Certificate2(
      Path.Combine(_environment.ContentRootPath, "sts_dev_cert.pfx"), "1234");

services.AddHttpClient("namedClient", c =>
{
}).ConfigurePrimaryHttpMessageHandler(() =>
{
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(clientCertificate);
    return handler;
});

然后可以使用 IHttpClientFactory 获取具有该处理程序和证书的命名实例。 使用具有 Startup 类中定义的客户端名称的 CreateClient 方法获取实例。 可根据需要使用客户端发送 HTTP 请求。

private readonly IHttpClientFactory _clientFactory;

public ApiService(IHttpClientFactory clientFactory)
{
    _clientFactory = clientFactory;
}

private async Task<JsonDocument> GetApiDataWithNamedClient()
{
    var client = _clientFactory.CreateClient("namedClient");

    var request = new HttpRequestMessage()
    {
        RequestUri = new Uri("https://localhost:44379/api/values"),
        Method = HttpMethod.Get,
    };
    var response = await client.SendAsync(request);
    if (response.IsSuccessStatusCode)
    {
        var responseContent = await response.Content.ReadAsStringAsync();
        var data = JsonDocument.Parse(responseContent);
        return data;
    }

    throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}

如果将正确的证书发送到服务器,则会返回数据。 如果未发送证书或发送的证书不正确,则会返回 HTTP 403 状态代码。

在 PowerShell 中创建证书

创建证书是设置此流程最难的部分。 可以使用 New-SelfSignedCertificate PowerShell cmdlet 创建根证书。 创建证书时,请使用强密码。 如图所示添加 KeyUsageProperty 参数和 KeyUsage 参数非常重要。

创建根 CA

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath root_ca_dev_damienbod.crt

注意

-DnsName 参数值必须与应用的部署目标匹配。 例如,用于开发的“localhost”。

在受信任的根中安装

根证书需要在主机系统上受信任。 默认情况下,不会信任并非由证书颁发机构创建的根证书。 有关如何信任 Windows 上的根证书的信息,请参阅此问题

中间证书

现在可以从根证书创建中间证书。 这并不是所有用例所必需的,但有可能需要创建多个证书或需要激活或禁用证书组。 TextExtension 参数是设置证书基本约束中的路径长度所必需的。

然后可以将中间证书添加为 Windows 主机系统中受信任的中间证书。

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint of the root..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "intermediate_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "intermediate_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\intermediate_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath intermediate_dev_damienbod.crt

从中间证书创建子证书

可以从中间证书创建子证书。 这是最终实体,不需要创建更多的子证书。

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the Intermediate certificate..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

从根证书创建子证书

还可以直接从根证书创建子证书。

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the root cert..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

示例根 - 中间证书 - 证书

$mypwdroot = ConvertTo-SecureString -String "1234" -Force -AsPlainText
$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

Get-ChildItem -Path cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwdroot

Export-Certificate -Cert cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 -FilePath root_ca_dev_damienbod.crt

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\0C89639E4E2998A93E423F919B36D4009A0F9991 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 -FilePath child_a_dev_damienbod.crt

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\BA9BF91ED35538A01375EFC212A2F46104B33A44 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_b_from_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_b_from_a_dev_damienbod.com" 

Get-ChildItem -Path cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_b_from_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A -FilePath child_b_from_a_dev_damienbod.crt

使用根证书、中间证书或子证书时,可以根据需要使用指纹或公钥验证证书。

using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography.X509Certificates;

namespace AspNetCoreCertificateAuthApi
{
    public class MyCertificateValidationService 
    {
        public bool ValidateCertificate(X509Certificate2 clientCertificate)
        {
            return CheckIfThumbprintIsValid(clientCertificate);
        }

        private bool CheckIfThumbprintIsValid(X509Certificate2 clientCertificate)
        {
            var listOfValidThumbprints = new List<string>
            {
                "141594A0AE38CBBECED7AF680F7945CD51D8F28A",
                "0C89639E4E2998A93E423F919B36D4009A0F9991",
                "BA9BF91ED35538A01375EFC212A2F46104B33A44"
            };

            if (listOfValidThumbprints.Contains(clientCertificate.Thumbprint))
            {
                return true;
            }

            return false;
        }
    }
}

证书验证缓存

ASP.NET Core 5.0 及更高版本支持启用缓存验证结果的功能。 缓存极大地提高了证书身份验证的性能,因为验证是一种高成本操作。

默认情况下,证书身份验证禁用缓存。 若要启用缓存,请调用 Startup.ConfigureServices 中的 AddCertificateCache

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
            .AddCertificate()
            .AddCertificateCache(options =>
            {
                options.CacheSize = 1024;
                options.CacheEntryExpiration = TimeSpan.FromMinutes(2);
            });
}

默认的缓存实现将结果存储在内存中。 可以通过实现 ICertificateValidationCache 并注册“依赖项注入”来提供自己的缓存。 例如,services.AddSingleton<ICertificateValidationCache, YourCache>()

可选客户端证书

本部分提供必须使用证书来保护应用子集的应用的相关信息。 例如,应用中的 Razor 页或控制器可能要求提供客户端证书。 这会带来挑战,因为客户端证书:

  • 是 TLS 功能,而不是 HTTP 功能。
  • 是按连接进行协商的,并且通常是在连接开始时,在任何 HTTP 数据可用之前进行协商。

可以通过两种方法实现可选的客户端证书:

  1. 使用单独的主机名 (SNI) 和重定向。 虽然会增加配置工作,但仍建议使用这种方法,因为它适用于大多数环境和协议。
  2. HTTP 请求期间重新协商。 这种方法有多项限制,不建议使用。

分离主机 (SNI)

在连接开始时,仅服务器名称指示 (SNI)† 是已知的。 可以按主机名配置客户端证书,以便某台主机需要特定证书而其他主机不会需要。

ASP.NET Core 5 及更高版本针对“重定向以获取可选客户端证书”添加了更方便的的支持。 有关详细信息,请参阅可选证书的示例

  • 对于需要客户端证书但没有的 Web 应用的请求:
    • 使用客户端证书保护的子域重定向到同一页面。
    • 例如,重定向到 myClient.contoso.com/requestedPage。 由于对 myClient.contoso.com/requestedPage 的请求是与 contoso.com/requestedPage 不同的主机名,因此客户端建立了不同的连接,并提供了客户端证书。
    • 有关详细信息,请参阅 ASP.NET Core 简介

† 服务器名称指示 (SNI) 是一种 TLS 扩展,可将虚拟域作为 SSL 协商的一部分包括在内。 这实际上表示虚拟域名或主机名可用于标识网络终结点。

重新协商

TLS 重新协商是一个过程,通过该过程,客户端和服务器可以重新评估单个连接的加密要求,包括请求客户端证书(如果以前未提供)。 TLS 重新协商存在安全风险,不建议执行,因为:

  • 在 HTTP/1.1 中,服务器需要先缓冲或使用正在传输的 HTTP 数据,例如 POST 请求正文,以确保连接对于重新协商是清楚明了的。 否则,重新协商可能会停止响应或失败。
  • HTTP/2 和 HTTP/3 明确禁止重新协商。
  • 重新协商会带来安全风险。 TLS 1.3 删除了对整个连接的重新协商,并将其替换为一个新的扩展,用于在连接开始后仅请求客户端证书。 此机制通过相同的 API 公开,并且仍受之前的缓冲和 HTTP 协议版本约束的限制。

此功能的实现和配置因服务器和框架版本而异。

IIS

IIS 会代表你管理客户端证书协商。 应用程序的一个子部分可以启用 SslRequireCert 选项来协商这些请求的客户端证书。 有关详细信息,请参阅 IIS 文档中的配置

在重新协商之前,IIS 将自动缓冲任何请求正文数据,且不超过配置的大小限制。 超过限制的请求将被拒绝,并收到 413 响应。 此限额默认为 48KB,可通过设置 uploadReadAheadSize 进行配置。

HttpSys

HttpSys 有两个设置可控制客户端证书协商,这两个设置都应设置。 第一种是 http add sslcert clientcertnegotiation=enable/disable 下的 netsh.exe。 此标志指示是否应在连接开始时协商客户端证书,并且对于可选客户端证书,应将其设置为 disable。 有关详细信息,请参阅 netsh 文档

另一个设置是 ClientCertificateMethod。 当设置为 AllowRenegotation 时,可以在请求期间重新协商客户端证书。

注意 在尝试重新协商之前,应用程序应缓冲或使用任何请求正文数据,否则请求可能会失去响应。

存在一个已知问题:启用 AllowRenegotation 会导致在访问 ClientCertificate 属性时同步进行重新协商。 调用 GetClientCertificateAsync 方法以避免这种情况。 这在 .NET 6 中已得到解决。 有关详细信息,请参阅此 GitHub 问题。 注意 GetClientCertificateAsync 如果客户端拒绝提供一个空证书,则可以返回该证书。

Kestrel

Kestrel 使用 ClientCertificateMode 选项控制客户端证书协商。

对于 .NET 5 和更早版本,Kestrel 不支持在连接开始后进行重新协商来获取客户端证书。 此功能已添加到 .NET 6 中。

Microsoft.AspNetCore.Authentication.Certificate 包含类似于 ASP.NET Core 的证书身份验证的实现。 证书身份验证在 TLS 级别发生,远在到达 ASP.NET Core 之前。 更准确地说,这是一个身份验证处理程序,该程序验证证书,然后提供一个事件,可在其中将证书解析为 ClaimsPrincipal

配置服务器用于执行证书身份验证,无论使用的是 IIS、Kestrel、Azure Web 应用还是其他服务。

代理和负载均衡器方案

证书身份验证是一种有状态方案,主要用于代理或负载均衡器不处理客户端和服务器之间流量的情况。 如果使用了代理或负载平衡器,则仅当代理或负载均衡器符合以下情况时证书身份验证才起作用:

  • 会处理身份验证。
  • 将用户身份验证信息传递给应用(例如,在请求头中),应用根据身份验证信息执行操作。

在使用代理和负载平衡器的环境中,证书身份验证的另一种选择是结合使用 Active Directory 联合服务 (ADFS) 和 OpenID Connect (OIDC)。

入门

获取 HTTPS 证书,应用该证书,并配置服务器,将其配置为索要证书。

在 Web 应用中,添加对 Microsoft.AspNetCore.Authentication.Certificate 包的引用。 然后在 Startup.ConfigureServices 方法中,使用选项调用 services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(...);,以提供一个委托,使 OnCertificateValidated 能够对与请求一起发送的客户端证书执行任何补充验证。 将该信息转换为 ClaimsPrincipal 并在 context.Principal 属性上设置。

如果身份验证失败,此处理程序会返回一个 403 (Forbidden) 响应,而不是 401 (Unauthorized),你可能已经想到了。 原因是,身份验证应在初次 TLS 连接期间进行。 它到达处理程序的时间太晚。 无法将连接从匿名连接升级到具有证书的连接。

还要在 Startup.Configure 方法中添加 app.UseAuthentication();。 否则,HttpContext.User 不会设置为从证书创建的 ClaimsPrincipal。 例如:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(
        CertificateAuthenticationDefaults.AuthenticationScheme)
        .AddCertificate();

    // All other service configuration
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseAuthentication();

    // All other app configuration
}

前面的示例演示了添加证书身份验证的默认方法。 处理程序使用通用证书属性构造用户主体。

配置证书验证

CertificateAuthenticationOptions 处理程序具有一些内置验证,这些验证是需要对证书执行的最小程度的验证。 默认启用这些设置中的每一个。

AllowedCertificateTypes = Chained, SelfSigned, or All (Chained | SelfSigned)

默认值:30CertificateTypes.Chained

此检查验证是否只允许使用适当的证书类型。 如果应用使用自签名证书,则此选项需要设置为 CertificateTypes.AllCertificateTypes.SelfSigned

ValidateCertificateUse

默认值:30true

此检查验证客户端提供的证书是否具有客户端身份验证“增强型密钥使用”(EKU) 或完全没有 EKU。 根据规范,如果未指定 EKU,则所有 EKU 均视为有效。

ValidateValidityPeriod

默认值:30true

此检查验证证书是否在其有效期内。 对于每次请求,处理程序都会确保在出示时有效的证书在其当前会话期间没有过期。

RevocationFlag

默认值:30X509RevocationFlag.ExcludeRoot

一个标志,该标志指定将检查链中的哪些证书,确认其的吊销状态。

仅当证书链接到根证书时才对证书执行吊销检查。

RevocationMode

默认值:30X509RevocationMode.Online

一个标志,该标志指定如何执行吊销检查。

指定联机检查可能会导致在联系证书颁发机构时发生长时间延迟。

仅当证书链接到根证书时才对证书执行吊销检查。

我可以将我的应用配置为仅在某些路径上索要证书吗?

这不可能。 请记住,证书交换是在 HTTPS 会话开始时完成的,它是由服务器在该连接上收到第一个请求之前完成的,因此无法基于任何请求字段限定作用域。

处理程序事件

处理程序有两个事件:

  • OnAuthenticationFailed:在身份验证期间发生异常且你能够作出反应时调用。
  • OnCertificateValidated:在对证书进行了验证、通过验证并创建了默认主体后调用。 此事件使你能够执行自己的验证并增强或替换主体。 示例包括:
    • 确定你的服务是否知道该证书。

    • 构造你自己的主体。 请看下面 Startup.ConfigureServices 中的示例:

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

如果发现入站证书不符合额外的验证要求,请调用 context.Fail("failure reason") 并附带失败原因。

为了真正实现功能,很可能需要调用注册了“依赖关系注入”且连接到数据库或其他类型用户存储的服务。 通过使用传递到委托中的上下文访问服务。 请看下面 Startup.ConfigureServices 中的示例:

services.AddAuthentication(
    CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                var validationService =
                    context.HttpContext.RequestServices
                        .GetRequiredService<ICertificateValidationService>();

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

从概念上讲,证书验证与授权相关。 在授权策略中而不是在 OnCertificateValidated 中添加(例如)对颁发者或指纹的检查是完全可以接受的。

将服务器配置为索要证书

Kestrel

Program.cs 中,按如下所示配置 Kestrel:

public static void Main(string[] args)
{
    CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args)
{
    return Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
            webBuilder.ConfigureKestrel(o =>
            {
                o.ConfigureHttpsDefaults(o => 
                    o.ClientCertificateMode =  ClientCertificateMode.RequireCertificate);
            });
        });
}

注意

通过在调用 ConfigureHttpsDefaults 之前调用 Listen 创建的终结点将不会应用默认值。

IIS

在 IIS 管理器中完成以下步骤:

  1. 从“连接”选项卡中选择你的站点。
  2. 双击“功能视图”窗口中的”SSL 设置”选项。
  3. 选中“需要 SSL”复选框,并在“客户端证书”部分中选择“需要”单选按钮。

Client certificate settings in IIS

Azure 和自定义 Web 代理

有关如何配置证书转发中间件的信息,请参阅托管和部署文档

在 Azure Web 应用中使用证书身份验证

不需要针对 Azure 配置转发。 转发配置由证书转发中间件设置。

注意

此方案需要证书转发中间件。

有关详细信息,请参阅在 Azure 应用服务中的代码中使用 TLS/SSL 证书(Azure 文档)

在自定义 Web 代理中使用证书身份验证

AddCertificateForwarding 方法用于指定:

  • 客户端标头名称。
  • 如何加载证书(使用 HeaderConverter 属性)。

在自定义 Web 代理中,证书作为自定义请求头传递,例如 X-SSL-CERT。 要使用它,请在 Startup.ConfigureServices 中配置证书转发:

public void ConfigureServices(IServiceCollection services)
{
    services.AddCertificateForwarding(options =>
    {
        options.CertificateHeader = "X-SSL-CERT";
        options.HeaderConverter = (headerValue) =>
        {
            X509Certificate2 clientCertificate = null;

            if(!string.IsNullOrWhiteSpace(headerValue))
            {
                byte[] bytes = StringToByteArray(headerValue);
                clientCertificate = new X509Certificate2(bytes);
            }

            return clientCertificate;
        };
    });
}

private static byte[] StringToByteArray(string hex)
{
    int NumberChars = hex.Length;
    byte[] bytes = new byte[NumberChars / 2];

    for (int i = 0; i < NumberChars; i += 2)
    {
        bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
    }

    return bytes;
}

如果应用由 NGINX 通过配置 proxy_set_header ssl-client-cert $ssl_client_escaped_cert 反向代理,或使用 NGINX 入口部署在 Kubernetes 上,则客户端证书将以 URL 编码形式传递给应用。 要使用证书,请按如下方式对其进行解码:

System.Net 的命名空间添加到 Startup.cs 的顶部:

using System.Net;

Startup.ConfigureServices中:

services.AddCertificateForwarding(options =>
{
    options.CertificateHeader = "ssl-client-cert";
    options.HeaderConverter = (headerValue) =>
    {
        X509Certificate2 clientCertificate = null;

        if (!string.IsNullOrWhiteSpace(headerValue))
        {
            var bytes = UrlEncodedPemToByteArray(headerValue);
            clientCertificate = new X509Certificate2(bytes);
        }

        return clientCertificate;
    };
});

添加 UrlEncodedPemToByteArray 方法:

private static byte[] UrlEncodedPemToByteArray(string urlEncodedBase64Pem)
{
    var base64Pem = WebUtility.UrlDecode(urlEncodedBase64Pem);
    var base64Cert = base64Pem
        .Replace("-----BEGIN CERTIFICATE-----", string.Empty)
        .Replace("-----END CERTIFICATE-----", string.Empty)
        .Trim();

    return Convert.FromBase64String(base64Cert);
}

然后 Startup.Configure 方法会添加中间件。 UseCertificateForwarding 在调用 UseAuthenticationUseAuthorization 之前被调用:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseRouting();

    app.UseCertificateForwarding();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

可以使用单独的类来实现验证逻辑。 由于本例中使用了相同的自签名证书,因此请确保只有你的证书能使用。 验证客户端证书和服务器证书的指纹是否均匹配,否则任何证书都可以使用并足以进行身份验证。 这将在 AddCertificate 方法中使用。 如果使用的是中间证书或子证书,还可以在此处验证主题或颁发者。

using System.IO;
using System.Security.Cryptography.X509Certificates;

namespace AspNetCoreCertificateAuthApi
{
    public class MyCertificateValidationService
    {
        public bool ValidateCertificate(X509Certificate2 clientCertificate)
        {
            // Do not hardcode passwords in production code
            // Use thumbprint or key vault
            var cert = new X509Certificate2(
                Path.Combine("sts_dev_cert.pfx"), "1234");

            if (clientCertificate.Thumbprint == cert.Thumbprint)
            {
                return true;
            }

            return false;
        }
    }
}

使用证书和 IHttpClientFactory 实现 HttpClient

HttpClientHandler 可以直接添加到 HttpClient 类的构造函数中。 创建 HttpClient 的实例时应谨慎操作。 然后 HttpClient 将证书随每个请求一起发送。

private async Task<JsonDocument> GetApiDataUsingHttpClientHandler()
{
    var cert = new X509Certificate2(Path.Combine(_environment.ContentRootPath, "sts_dev_cert.pfx"), "1234");
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(cert);
    var client = new HttpClient(handler);

    var request = new HttpRequestMessage()
    {
        RequestUri = new Uri("https://localhost:44379/api/values"),
        Method = HttpMethod.Get,
    };
    var response = await client.SendAsync(request);
    if (response.IsSuccessStatusCode)
    {
        var responseContent = await response.Content.ReadAsStringAsync();
        var data = JsonDocument.Parse(responseContent);
        return data;
    }

    throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}

使用证书和来自 IHttpClientFactory 的命名的 HttpClient 实现 HttpClient

在下面的示例中,使用了处理程序中的 ClientCertificates 属性将客户端证书添加到 HttpClientHandler。 之后可以使用 ConfigurePrimaryHttpMessageHandler 方法在 HttpClient 的命名实例中使用此处理程序。 这在 Startup.ConfigureServices 中设置:

var clientCertificate = 
    new X509Certificate2(
      Path.Combine(_environment.ContentRootPath, "sts_dev_cert.pfx"), "1234");

services.AddHttpClient("namedClient", c =>
{
}).ConfigurePrimaryHttpMessageHandler(() =>
{
    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(clientCertificate);
    return handler;
});

然后可以使用 IHttpClientFactory 获取具有该处理程序和证书的命名实例。 使用具有 Startup 类中定义的客户端名称的 CreateClient 方法获取实例。 可根据需要使用客户端发送 HTTP 请求。

private readonly IHttpClientFactory _clientFactory;

public ApiService(IHttpClientFactory clientFactory)
{
    _clientFactory = clientFactory;
}

private async Task<JsonDocument> GetApiDataWithNamedClient()
{
    var client = _clientFactory.CreateClient("namedClient");

    var request = new HttpRequestMessage()
    {
        RequestUri = new Uri("https://localhost:44379/api/values"),
        Method = HttpMethod.Get,
    };
    var response = await client.SendAsync(request);
    if (response.IsSuccessStatusCode)
    {
        var responseContent = await response.Content.ReadAsStringAsync();
        var data = JsonDocument.Parse(responseContent);
        return data;
    }

    throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}

如果将正确的证书发送到服务器,则会返回数据。 如果未发送证书或发送的证书不正确,则会返回 HTTP 403 状态代码。

在 PowerShell 中创建证书

创建证书是设置此流程最难的部分。 可以使用 New-SelfSignedCertificate PowerShell cmdlet 创建根证书。 创建证书时,请使用强密码。 如图所示添加 KeyUsageProperty 参数和 KeyUsage 参数非常重要。

创建根 CA

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath root_ca_dev_damienbod.crt

注意

-DnsName 参数值必须与应用的部署目标匹配。 例如,用于开发的“localhost”。

在受信任的根中安装

根证书需要在主机系统上受信任。 默认情况下,不会信任并非由证书颁发机构创建的根证书。 有关如何信任 Windows 上的根证书的信息,请参阅此问题

中间证书

现在可以从根证书创建中间证书。 这并不是所有用例所必需的,但有可能需要创建多个证书或需要激活或禁用证书组。 TextExtension 参数是设置证书基本约束中的路径长度所必需的。

然后可以将中间证书添加为 Windows 主机系统中受信任的中间证书。

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint of the root..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "intermediate_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "intermediate_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\intermediate_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath intermediate_dev_damienbod.crt

从中间证书创建子证书

可以从中间证书创建子证书。 这是最终实体,不需要创建更多的子证书。

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the Intermediate certificate..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

从根证书创建子证书

还可以直接从根证书创建子证书。

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\"The thumbprint from the root cert..." )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com"

$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

Get-ChildItem -Path cert:\localMachine\my\"The thumbprint..." | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\"The thumbprint..." -FilePath child_a_dev_damienbod.crt

示例根 - 中间证书 - 证书

$mypwdroot = ConvertTo-SecureString -String "1234" -Force -AsPlainText
$mypwd = ConvertTo-SecureString -String "1234" -Force -AsPlainText

New-SelfSignedCertificate -DnsName "root_ca_dev_damienbod.com", "root_ca_dev_damienbod.com" -CertStoreLocation "cert:\LocalMachine\My" -NotAfter (Get-Date).AddYears(20) -FriendlyName "root_ca_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature

Get-ChildItem -Path cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 | Export-PfxCertificate -FilePath C:\git\root_ca_dev_damienbod.pfx -Password $mypwdroot

Export-Certificate -Cert cert:\localMachine\my\0C89639E4E2998A93E423F919B36D4009A0F9991 -FilePath root_ca_dev_damienbod.crt

$rootcert = ( Get-ChildItem -Path cert:\LocalMachine\My\0C89639E4E2998A93E423F919B36D4009A0F9991 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_a_dev_damienbod.com" -Signer $rootcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_a_dev_damienbod.com" -KeyUsageProperty All -KeyUsage CertSign, CRLSign, DigitalSignature -TextExtension @("2.5.29.19={text}CA=1&pathlength=1")

Get-ChildItem -Path cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\BA9BF91ED35538A01375EFC212A2F46104B33A44 -FilePath child_a_dev_damienbod.crt

$parentcert = ( Get-ChildItem -Path cert:\LocalMachine\My\BA9BF91ED35538A01375EFC212A2F46104B33A44 )

New-SelfSignedCertificate -certstorelocation cert:\localmachine\my -dnsname "child_b_from_a_dev_damienbod.com" -Signer $parentcert -NotAfter (Get-Date).AddYears(20) -FriendlyName "child_b_from_a_dev_damienbod.com" 

Get-ChildItem -Path cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A | Export-PfxCertificate -FilePath C:\git\AspNetCoreCertificateAuth\Certs\child_b_from_a_dev_damienbod.pfx -Password $mypwd

Export-Certificate -Cert cert:\localMachine\my\141594A0AE38CBBECED7AF680F7945CD51D8F28A -FilePath child_b_from_a_dev_damienbod.crt

使用根证书、中间证书或子证书时,可以根据需要使用指纹或公钥验证证书。

using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography.X509Certificates;

namespace AspNetCoreCertificateAuthApi
{
    public class MyCertificateValidationService 
    {
        public bool ValidateCertificate(X509Certificate2 clientCertificate)
        {
            return CheckIfThumbprintIsValid(clientCertificate);
        }

        private bool CheckIfThumbprintIsValid(X509Certificate2 clientCertificate)
        {
            var listOfValidThumbprints = new List<string>
            {
                "141594A0AE38CBBECED7AF680F7945CD51D8F28A",
                "0C89639E4E2998A93E423F919B36D4009A0F9991",
                "BA9BF91ED35538A01375EFC212A2F46104B33A44"
            };

            if (listOfValidThumbprints.Contains(clientCertificate.Thumbprint))
            {
                return true;
            }

            return false;
        }
    }
}

可选客户端证书

本部分提供必须使用证书来保护应用子集的应用的相关信息。 例如,应用中的 Razor 页或控制器可能要求提供客户端证书。 这会带来挑战,因为客户端证书:

  • 是 TLS 功能,而不是 HTTP 功能。
  • 是按连接进行协商的,并且通常是在连接开始时,在任何 HTTP 数据可用之前进行协商。

可以通过两种方法实现可选的客户端证书:

  1. 使用单独的主机名 (SNI) 和重定向。 虽然会增加配置工作,但仍建议使用这种方法,因为它适用于大多数环境和协议。
  2. HTTP 请求期间重新协商。 这种方法有多项限制,不建议使用。

分离主机 (SNI)

在连接开始时,仅服务器名称指示 (SNI)† 是已知的。 可以按主机名配置客户端证书,以便某台主机需要特定证书而其他主机不会需要。

ASP.NET Core 5 及更高版本针对“重定向以获取可选客户端证书”添加了更方便的的支持。 有关详细信息,请参阅可选证书的示例

  • 对于需要客户端证书但没有的 Web 应用的请求:
    • 使用客户端证书保护的子域重定向到同一页面。
    • 例如,重定向到 myClient.contoso.com/requestedPage。 由于对 myClient.contoso.com/requestedPage 的请求是与 contoso.com/requestedPage 不同的主机名,因此客户端建立了不同的连接,并提供了客户端证书。
    • 有关详细信息,请参阅 ASP.NET Core 简介

† 服务器名称指示 (SNI) 是一种 TLS 扩展,可将虚拟域作为 SSL 协商的一部分包括在内。 这实际上表示虚拟域名或主机名可用于标识网络终结点。

重新协商

TLS 重新协商是一个过程,通过该过程,客户端和服务器可以重新评估单个连接的加密要求,包括请求客户端证书(如果以前未提供)。 TLS 重新协商存在安全风险,不建议执行,因为:

  • 在 HTTP/1.1 中,服务器需要先缓冲或使用正在传输的 HTTP 数据,例如 POST 请求正文,以确保连接对于重新协商是清楚明了的。 否则,重新协商可能会停止响应或失败。
  • HTTP/2 和 HTTP/3 明确禁止重新协商。
  • 重新协商会带来安全风险。 TLS 1.3 删除了对整个连接的重新协商,并将其替换为一个新的扩展,用于在连接开始后仅请求客户端证书。 此机制通过相同的 API 公开,并且仍受之前的缓冲和 HTTP 协议版本约束的限制。

此功能的实现和配置因服务器和框架版本而异。

IIS

IIS 会代表你管理客户端证书协商。 应用程序的一个子部分可以启用 SslRequireCert 选项来协商这些请求的客户端证书。 有关详细信息,请参阅 IIS 文档中的配置

在重新协商之前,IIS 将自动缓冲任何请求正文数据,且不超过配置的大小限制。 超过限制的请求将被拒绝,并收到 413 响应。 此限额默认为 48KB,可通过设置 uploadReadAheadSize 进行配置。

HttpSys

HttpSys 有两个设置可控制客户端证书协商,这两个设置都应设置。 第一种是 http add sslcert clientcertnegotiation=enable/disable 下的 netsh.exe。 此标志指示是否应在连接开始时协商客户端证书,并且对于可选客户端证书,应将其设置为 disable。 有关详细信息,请参阅 netsh 文档

另一个设置是 ClientCertificateMethod。 当设置为 AllowRenegotation 时,可以在请求期间重新协商客户端证书。

注意 在尝试重新协商之前,应用程序应缓冲或使用任何请求正文数据,否则请求可能会失去响应。

存在一个已知问题:启用 AllowRenegotation 会导致在访问 ClientCertificate 属性时同步进行重新协商。 调用 GetClientCertificateAsync 方法以避免这种情况。 这在 .NET 6 中已得到解决。 有关详细信息,请参阅此 GitHub 问题。 注意 GetClientCertificateAsync 如果客户端拒绝提供一个空证书,则可以返回该证书。

Kestrel

Kestrel 使用 ClientCertificateMode 选项控制客户端证书协商。

对于 .NET 5 和更早版本,Kestrel 不支持在连接开始后进行重新协商来获取客户端证书。 此功能已添加到 .NET 6 中。

此 GitHub 讨论问题中提供关于可选客户端证书的问题、评论和其他反馈。