PAYATHON 2026

.NET 6 Web API 如何获取并验证客户端证书

数据小陈

结论

Request.HttpContext.Connection.ClientCertificate 为 null,通常不是读取代码出了问题,而是 TLS 连接中没有客户端证书。

要获取证书,以下条件缺一不可:

  • Webhook 使用 HTTPS。
  • ASP.NET Core/Kestrel 已配置为请求或强制要求客户端证书。
  • 调用方在 TLS 握手时发送客户端证书。
  • 如果请求经过 IIS、Nginx、负载均衡器或云网关,需要正确转发代理接收到的证书。
  • Postman 必须配置包含私钥的客户端证书。仅发送一份”已知有效”的 JSON 数据并不会产生客户端证书。

X_Client-Cert 不是浏览器、Postman 或 ASP.NET Core 自动生成的标准请求头,所以正常情况下读取结果也是 null。

使用以下方案前,还要确认 Starling 的文档明确说明:调用 Webhook 时,它会将所提供的证书作为 TLS 客户端证书发送。如果文档所说的是”请求体签名”或其他公钥验签机制,就应该验证签名,而不是读取 ClientCertificate。

在 Kestrel 中要求并验证客户端证书

可以在 Program.cs 中配置 Kestrel,让它在 TLS 握手阶段验证 Starling 提供的证书。

下面的示例固定了证书的 SHA-256 指纹,只有与本地 .crt 完全相同的证书才能通过验证:

using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Server.Kestrel.Https;

var builder = WebApplication.CreateBuilder(args);

var certificatePath = builder.Configuration["Starling:ClientCertificatePath"]
    ?? throw new InvalidOperationException(
        "Missing Starling:ClientCertificatePath configuration.");

using var pinnedCertificate = new X509Certificate2(certificatePath);
var pinnedCertificateHash = SHA256.HashData(pinnedCertificate.RawData);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(httpsOptions =>
    {
        httpsOptions.ClientCertificateMode =
            ClientCertificateMode.RequireCertificate;

        httpsOptions.ClientCertificateValidation =
            (certificate, chain, sslPolicyErrors) =>
            {
                if (certificate is null)
                {
                    return false;
                }

                var receivedCertificateHash =
                    SHA256.HashData(certificate.RawData);

                return CryptographicOperations.FixedTimeEquals(
                    receivedCertificateHash,
                    pinnedCertificateHash);
            };
    });
});

builder.Services.AddControllers();

var app = builder.Build();

app.UseHttpsRedirection();
app.MapControllers();

app.Run();

配置文件可以这样写:

{
  "Starling": {
    "ClientCertificatePath": "D:\\Certificate\\Starling\\starling-sandbox-api-certificate.crt"
  }
}

在生产环境中,最好使用部署环境提供的安全配置或证书存储,不要把本机绝对路径硬编码到程序里。

如果 .crt 使用 PEM 格式,而普通构造函数无法加载,可以改用:

using var pinnedCertificate =
    X509Certificate2.CreateFromPemFile(certificatePath);

配置 RequireCertificate 后,未发送证书或证书验证失败的连接会在 TLS 阶段被拒绝,请求通常不会进入 Controller。

在 Controller 中读取证书

完成 TLS 配置后,可以这样读取客户端证书:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/webhook")]
public class WebhookController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Receive()
    {
        var clientCertificate =
            await HttpContext.Connection.GetClientCertificateAsync();

        if (clientCertificate is null)
        {
            return Forbid();
        }

        // TLS 阶段已经完成证书验证。
        // 在这里继续验证并处理 Webhook 数据。
        return Ok(new { status = "ok" });
    }
}

GetClientCertificateAsync() 是更通用的读取方式,但它无法凭空取得证书。服务器必须在 TLS 握手时请求证书,客户端也必须真正发送证书。

如果 Kestrel 已经验证过证书,通常不必在每个 Controller 中重复比较。

如果要固定公钥而不是整个证书

固定整个证书后,Starling 一旦更换证书,即使新证书使用相同的公钥,验证也会失败。如果对方明确要求 public key pinning,可以比较 Subject Public Key Info:

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

static byte[] GetPublicKeyPin(X509Certificate2 certificate)
{
    byte[] subjectPublicKeyInfo =
        certificate.PublicKey.ExportSubjectPublicKeyInfo();

    return SHA256.HashData(subjectPublicKeyInfo);
}

启动时计算本地证书的公钥摘要:

using var pinnedCertificate = new X509Certificate2(certificatePath);
var pinnedPublicKeyHash = GetPublicKeyPin(pinnedCertificate);

然后验证收到的证书:

httpsOptions.ClientCertificateValidation =
    (certificate, chain, sslPolicyErrors) =>
    {
        if (certificate is null)
        {
            return false;
        }

        var receivedPublicKeyHash = GetPublicKeyPin(certificate);

        return CryptographicOperations.FixedTimeEquals(
            receivedPublicKeyHash,
            pinnedPublicKeyHash);
    };

固定”完整证书”和固定”公钥”是两种不同的验证方式,具体采用哪一种,必须以 Starling 当前的文档为准。

为什么使用 Postman 时始终为 null

Postman 默认不会发送客户端证书。需要在它的客户端证书设置中,针对准确的域名和端口配置:

  • 客户端证书,例如 .pfx 或 .crt;
  • 与证书匹配的私钥;
  • 私钥密码,如果存在;
  • 与请求 URL 一致的 host 和 port。

只有 Starling 发布的 .crt 公钥证书还不够。Postman 需要对应的私钥才能证明身份,而 Starling 不会把自己的私钥提供给你,所以不能通过 Postman 冒充 Starling。

调试时可以采用以下方式:

  • 让 Starling Sandbox 发出真实的 Webhook;
  • 自己生成一套测试客户端证书和私钥,并在开发环境中固定这份测试证书;
  • 使用 curl 携带自己生成的测试证书:
curl --cert test-client.crt \
     --key test-client.key \
     --request POST \
     --header "Content-Type: application/json" \
     --data '{"test":true}' \
     https://localhost:5001/api/webhook

使用 IIS、Nginx 或负载均衡器时

如果 HTTPS 在反向代理处终止,Kestrel 看到的是代理新建的连接,而不是 Starling 原始的 TLS 连接。此时,HttpContext.Connection.ClientCertificate 仍可能为 null。

代理层需要完成这些工作:

  1. 要求客户端提供证书;
  2. 验证证书;
  3. 将验证后的证书安全地转发给 ASP.NET Core;
  4. 删除外部请求中可能伪造的同名 Header;
  5. 在 ASP.NET Core 中启用证书转发中间件。

ASP.NET Core 的证书转发可以这样配置:

using Microsoft.AspNetCore.HttpOverrides;
using System.Security.Cryptography.X509Certificates;

builder.Services.AddCertificateForwarding(options =>
{
    options.CertificateHeader = "X-Client-Cert";

    options.HeaderConverter = headerValue =>
    {
        byte[] certificateBytes = Convert.FromBase64String(headerValue);
        return new X509Certificate2(certificateBytes);
    };
});

在读取证书之前启用:

app.UseCertificateForwarding();

Header 名称和编码格式必须与代理实际发送的内容一致,不能自行认定它就是 X_Client-Cert。只有受信任代理写入的证书 Header 才能用于认证,否则客户端可以直接伪造这个 Header。

还需要检查的事项

  • 请求 URL 必须使用 https://,不能使用 http://。
  • Visual Studio 使用 IIS Express 时,TLS 可能由 IIS Express 处理,因此 Kestrel 配置未必生效。调试时要确认当前启动的是项目本身还是 IIS Express。
  • Starling Sandbox 和生产环境可能使用不同的证书,需要分别配置。
  • 证书可能会轮换,部署时要预留更新固定证书或公钥的机制。
  • 不要用 Ok(new { status = "error" }) 表示认证失败。认证失败应该返回 401 Unauthorized、403 Forbidden,或者直接在 TLS 握手阶段拒绝连接。
  • 验证来源后,仍要处理 Webhook 重放、重复投递、请求完整性和幂等问题。客户端证书只能证明 TLS 对端持有对应私钥,不能替代其他业务层安全检查。

备注:内容仅供参考。