如何使用客户端证书在Web API中进行身份验证和授权

69
我正在尝试使用客户端证书对设备进行身份验证和授权,使用Web API开发了一个简单的概念验证来解决潜在解决方案的问题。 我遇到的问题是Web应用程序未接收到客户端证书。 许多人报告了此问题,包括在此Q&A中, 但他们没有答案。 我希望提供更多详细信息以解决此问题,并希望得到解决方案。 我可以接受其他解决方案。 主要要求是编写的C#独立进程可以调用Web API并使用客户端证书进行身份验证。
此POC中的Web API非常简单,只返回单个值。 它使用属性验证使用HTTPS并存在客户端证书。
public class SecureController : ApiController
{
    [RequireHttps]
    public string Get(int id)
    {
        return "value";
    }

}

这是RequireHttpsAttribute的代码:
public class RequireHttpsAttribute : AuthorizationFilterAttribute 
{ 
    public override void OnAuthorization(HttpActionContext actionContext) 
    { 
        if (actionContext.Request.RequestUri.Scheme != Uri.UriSchemeHttps) 
        { 
            actionContext.Response = new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden) 
            { 
                ReasonPhrase = "HTTPS Required" 
            }; 
        } 
        else 
        {
            var cert = actionContext.Request.GetClientCertificate();
            if (cert == null)
            {
                actionContext.Response = new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden)
                {
                    ReasonPhrase = "Client Certificate Required"
                }; 

            }
            base.OnAuthorization(actionContext); 
        } 
    } 
}

在这个 POC 中,我只是检查客户端证书的可用性。一旦这个工作正常,我可以添加对证书中信息的检查,以验证是否在证书列表中。
以下是此 Web 应用程序 SSL 的 IIS 设置。

enter image description here

这是一个发送带有客户端证书请求的客户端代码,是一个控制台应用程序。
    private static async Task SendRequestUsingHttpClient()
    {
        WebRequestHandler handler = new WebRequestHandler();
        X509Certificate certificate = GetCert("ClientCertificate.cer");
        handler.ClientCertificates.Add(certificate);
        handler.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(ValidateServerCertificate);
        handler.ClientCertificateOptions = ClientCertificateOption.Manual;
        using (var client = new HttpClient(handler))
        {
            client.BaseAddress = new Uri("https://localhost:44398/");
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            HttpResponseMessage response = await client.GetAsync("api/Secure/1");
            if (response.IsSuccessStatusCode)
            {
                string content = await response.Content.ReadAsStringAsync();
                Console.WriteLine("Received response: {0}",content);
            }
            else
            {
                Console.WriteLine("Error, received status code {0}: {1}", response.StatusCode, response.ReasonPhrase);
            }
        }
    }

    public static bool ValidateServerCertificate(
      object sender,
      X509Certificate certificate,
      X509Chain chain,
      SslPolicyErrors sslPolicyErrors)
    {
        Console.WriteLine("Validating certificate {0}", certificate.Issuer);
        if (sslPolicyErrors == SslPolicyErrors.None)
            return true;

        Console.WriteLine("Certificate error: {0}", sslPolicyErrors);

        // Do not allow this client to communicate with unauthenticated servers.
        return false;
    }

当我运行这个测试应用程序时,我会得到一个状态码为403 Forbidden的返回结果,并附带着“需要客户端证书”的原因短语,表明它正在进入我的RequireHttpsAttribute,但没有找到任何客户端证书。通过调试器运行后,我已经验证了证书被加载并添加到了WebRequestHandler中。该证书被导出到了CER文件中并被加载。完整的包含私钥的证书位于本地机器的个人和受信任的根存储区中,用于Web应用程序服务器。在这个测试中,客户端和Web应用程序在同一台机器上运行。
我可以使用Fiddler调用此Web API方法,附加相同的客户端证书,它可以正常工作。当使用Fiddler时,它通过了RequireHttpsAttribute中的测试,并返回了预期的值和状态码200。
是否有人遇到过HttpClient不发送客户端证书的请求而找到解决办法?
更新1:
我还尝试从包含私钥的证书存储中获取证书。以下是我检索它的方式:
    private static X509Certificate2 GetCert2(string hostname)
    {
        X509Store myX509Store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
        myX509Store.Open(OpenFlags.ReadWrite);
        X509Certificate2 myCertificate = myX509Store.Certificates.OfType<X509Certificate2>().FirstOrDefault(cert => cert.GetNameInfo(X509NameType.SimpleName, false) == hostname);
        return myCertificate;
    }

我已验证证书正确获取并已添加到客户端证书集合中。但是,服务器代码未检索任何客户端证书,结果相同。
以下是用于从文件中检索证书的完整代码:
    private static X509Certificate GetCert(string filename)
    {
        X509Certificate Cert = X509Certificate.CreateFromCertFile(filename);
        return Cert;

    }

你会注意到,当你从文件获取证书时,它返回的是X509Certificate类型的对象,而当你从证书存储中检索它时,它是X509Certificate2类型的。X509CertificateCollection.Add方法需要一个X509Certificate类型。
更新2: 我仍在尝试弄清楚这个问题,并尝试了许多不同的选项,但都没有成功。
- 我将Web应用程序更改为在主机名上运行,而不是本地主机。 - 我设置了Web应用程序需要SSL。 - 我验证了证书是否设置为客户端身份验证,并且它在受信任的根目录中。 - 除了在Fiddler中测试客户端证书外,我还在Chrome中验证了它。
在尝试这些选项的某个时候,它开始工作了。然后我开始撤销更改,看看是什么导致它工作的。它继续工作。然后我尝试将证书从受信任的根目录中删除,以验证是否需要此操作,结果它停止工作了,即使我将证书放回受信任的根目录中,现在Chrome甚至不会提示我选择证书,就像它曾经做过的那样,它在Chrome中失败,但在Fiddler中仍然有效。我肯定是遗漏了一些神奇的配置。

我也尝试在绑定中启用“Negotiate Client Certificate”,但 Chrome 仍不会提示我提供客户端证书。以下是使用“netsh http show sslcert”显示的设置:

 IP:port                 : 0.0.0.0:44398
 Certificate Hash        : 429e090db21e14344aa5d75d25074712f120f65f
 Application ID          : {4dc3e181-e14b-4a21-b022-59fc669b0914}
 Certificate Store Name  : MY
 Verify Client Certificate Revocation    : Disabled
 Verify Revocation Using Cached Client Certificate Only    : Disabled
 Usage Check    : Enabled
 Revocation Freshness Time : 0
 URL Retrieval Timeout   : 0
 Ctl Identifier          : (null)
 Ctl Store Name          : (null)
 DS Mapper Usage    : Disabled
 Negotiate Client Certificate    : Enabled

这是我正在使用的客户端证书:

enter image description here

enter image description here

enter image description here

我对问题感到困惑。我正在为任何能帮助我解决这个问题的人提供赏金。


在哪里运行这个命令?netsh http show sslcert 我也遇到了同样的问题。但我使用的是Windows 10。 - kudlatiger
6个回答

22

追踪帮助我找到了问题所在(感谢Fabian的建议)。通过进一步测试,我发现可以在另一台服务器上(Windows Server 2012)使客户端证书工作。我是在我的开发机器上进行测试(Windows 7),以便调试此过程。因此,通过比较有效和无效的IIS服务器的跟踪,我能够确定跟踪日志中相关的行。下面是客户端证书正常工作时的日志部分。这是发送前的设置。

System.Net Information: 0 : [17444] InitializeSecurityContext(In-Buffers count=2, Out-Buffer length=0, returned code=CredentialsNeeded).
System.Net Information: 0 : [17444] SecureChannel#54718731 - We have user-provided certificates. The server has not specified any issuers, so try all the certificates.
System.Net Information: 0 : [17444] SecureChannel#54718731 - Selected certificate:

以下是在客户端证书失败的计算机上跟踪日志的样子。

System.Net Information: 0 : [19616] InitializeSecurityContext(In-Buffers count=2, Out-Buffer length=0, returned code=CredentialsNeeded).
System.Net Information: 0 : [19616] SecureChannel#54718731 - We have user-provided certificates. The server has specified 137 issuer(s). Looking for certificates that match any of the issuers.
System.Net Information: 0 : [19616] SecureChannel#54718731 - Left with 0 client certificates to choose from.
System.Net Information: 0 : [19616] Using the cached credential handle.

我注意到指定服务器137个发行商的那一行,找到了这个类似于我的问题的问答。对我而言,解决方案并不是标记为答案的那一个,因为我的证书在受信任的根目录中。答案在下面,即下面的这个回答,您需要更新注册表。我只需将值添加到注册表键。

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL

Value name: SendTrustedIssuerList Value type: REG_DWORD Value data: 0 (False)

在将此值添加到注册表后,它开始在我的 Windows 7 计算机上正常工作。看起来这是一个 Windows 7 的问题。


如何启用跟踪?您能分享一下步骤吗? - kudlatiger

14

更新:

来自Microsoft的示例:

https://learn.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth#special-considerations-for-certificate-validation

原始内容:

以下是我让客户端证书正常工作并检查特定根CA是否已颁发它以及它是否为特定证书的方法。

首先,我编辑了<src>\.vs\config\applicationhost.config并进行了如下更改:<section name="access" overrideModeDefault="Allow" />

这使我可以在web.config中编辑<system.webServer>并添加以下行,这将要求IIS Express使用客户端证书。 注意: 我出于开发目的而编辑了此内容,请勿在生产中允许覆盖。

对于生产环境,请按照类似这样的指南设置IIS:

https://medium.com/@hafizmohammedg/configuring-client-certificates-on-iis-95aef4174ddb

web.config:

<security>
  <access sslFlags="Ssl,SslNegotiateCert,SslRequireCert" />
</security>

API控制器:

[RequireSpecificCert]
public class ValuesController : ApiController
{
    // GET api/values
    public IHttpActionResult Get()
    {
        return Ok("It works!");
    }
}

属性:

public class RequireSpecificCertAttribute : AuthorizationFilterAttribute
{
    public override void OnAuthorization(HttpActionContext actionContext)
    {
        if (actionContext.Request.RequestUri.Scheme != Uri.UriSchemeHttps)
        {
            actionContext.Response = new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden)
            {
                ReasonPhrase = "HTTPS Required"
            };
        }
        else
        {
            X509Certificate2 cert = actionContext.Request.GetClientCertificate();
            if (cert == null)
            {
                actionContext.Response = new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden)
                {
                    ReasonPhrase = "Client Certificate Required"
                };

            }
            else
            {
                X509Chain chain = new X509Chain();

                //Needed because the error "The revocation function was unable to check revocation for the certificate" happened to me otherwise
                chain.ChainPolicy = new X509ChainPolicy()
                {
                    RevocationMode = X509RevocationMode.NoCheck,
                };
                try
                {
                    var chainBuilt = chain.Build(cert);
                    Debug.WriteLine(string.Format("Chain building status: {0}", chainBuilt));

                    var validCert = CheckCertificate(chain, cert);

                    if (chainBuilt == false || validCert == false)
                    {
                        actionContext.Response = new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden)
                        {
                            ReasonPhrase = "Client Certificate not valid"
                        };
                        foreach (X509ChainStatus chainStatus in chain.ChainStatus)
                        {
                            Debug.WriteLine(string.Format("Chain error: {0} {1}", chainStatus.Status, chainStatus.StatusInformation));
                        }
                    }
                }
                catch (Exception ex)
                {
                    Debug.WriteLine(ex.ToString());
                }
            }

            base.OnAuthorization(actionContext);
        }
    }

    private bool CheckCertificate(X509Chain chain, X509Certificate2 cert)
    {
        var rootThumbprint = WebConfigurationManager.AppSettings["rootThumbprint"].ToUpper().Replace(" ", string.Empty);

        var clientThumbprint = WebConfigurationManager.AppSettings["clientThumbprint"].ToUpper().Replace(" ", string.Empty);

        //Check that the certificate have been issued by a specific Root Certificate
        var validRoot = chain.ChainElements.Cast<X509ChainElement>().Any(x => x.Certificate.Thumbprint.Equals(rootThumbprint, StringComparison.InvariantCultureIgnoreCase));

        //Check that the certificate thumbprint matches our expected thumbprint
        var validCert = cert.Thumbprint.Equals(clientThumbprint, StringComparison.InvariantCultureIgnoreCase);

        return validRoot && validCert;
    }
}

然后可以像这样使用客户端证书调用API,我已经从另一个Web项目中测试过了。

[RoutePrefix("api/certificatetest")]
public class CertificateTestController : ApiController
{

    public IHttpActionResult Get()
    {
        var handler = new WebRequestHandler();
        handler.ClientCertificateOptions = ClientCertificateOption.Manual;
        handler.ClientCertificates.Add(GetClientCert());
        handler.UseProxy = false;
        var client = new HttpClient(handler);
        var result = client.GetAsync("https://localhost:44331/api/values").GetAwaiter().GetResult();
        var resultString = result.Content.ReadAsStringAsync().GetAwaiter().GetResult();
        return Ok(resultString);
    }

    private static X509Certificate GetClientCert()
    {
        X509Store store = null;
        try
        {
            store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
            store.Open(OpenFlags.OpenExistingOnly | OpenFlags.ReadOnly);

            var certificateSerialNumber= "‎81 c6 62 0a 73 c7 b1 aa 41 06 a3 ce 62 83 ae 25".ToUpper().Replace(" ", string.Empty);

            //Does not work for some reason, could be culture related
            //var certs = store.Certificates.Find(X509FindType.FindBySerialNumber, certificateSerialNumber, true);

            //if (certs.Count == 1)
            //{
            //    var cert = certs[0];
            //    return cert;
            //}

            var cert = store.Certificates.Cast<X509Certificate>().FirstOrDefault(x => x.GetSerialNumberString().Equals(certificateSerialNumber, StringComparison.InvariantCultureIgnoreCase));

            return cert;
        }
        finally
        {
            store?.Close();
        }
    }
}

6
请确保HttpClient可以访问完整的客户端证书(包括私钥)。
您正在使用文件“ClientCertificate.cer”调用GetCert,这导致假设不包含私钥 - 在Windows中应该是pfx文件。最好从Windows cert存储库中访问证书并使用指纹进行搜索。
复制指纹时要小心:在证书管理中查看时会有一些不可打印的字符(将字符串复制到notepad ++中并检查显示的字符串的长度)。

谢谢您的建议。我已经尝试从证书存储中检索并获得了相同的结果。请参见我原问题的更新。 - Kevin Junghans
你能检查一下代码是否可以访问证书的私钥(可在prop HasPrivateKey中找到)吗?我们在其他软件上看到过一些问题,其中运行时无法访问私钥,因此无法使用证书。 只是出于兴趣:当使用iexplore访问URI时,是否会收到证书选择提示? - Daniel Nachtrub
是的,HasPrivateKey属性已设置为true。我不确定您所说的通过iexplore访问URI的意思。托管Web API的Web应用程序具有与客户端应用程序使用的证书不同的证书。 - Kevin Junghans
1
好的,私钥没问题。HttpClient只有在被请求时才会发送证书。当通过iexplore访问网站时,如果服务器设置正确,您将会得到一个弹出窗口,可以选择客户端证书。这只是一个快速测试。如果iexplore没有要求您提供证书,则可能是iis设置存在问题,HttpClient将不会发送其客户端证书。 - Daniel Nachtrub
使用 Fiddler 进行测试不会达到相同的验证效果吗? - Kevin Junghans
显示剩余3条评论

4
我曾遇到类似问题,原因是我们信任的根证书太多了。我们新安装的 Web 服务器上就有一百多个。由于我们的根证书以字母 Z 开头,所以它出现在了列表的末尾。
问题在于 IIS 仅向客户端发送前二十几个可信根证书并截断其余部分,包括我们的证书。这是几年前的事情了,我记不得工具的名字……它是 IIS 管理套件的一部分,但 Fiddler 也可以。意识到错误后,我们删除了许多不需要的可信根证书。这是通过试错完成的,因此请小心删除内容。
清理后,一切都运行得很顺畅。

2
我最近遇到了类似的问题,遵循Fabian的建议实际上帮我找到了解决方法。原来在使用客户端证书时需要确保两件事情:
  1. 私钥实际上作为证书的一部分被导出。
  2. 运行应用程序的应用程序池标识具有访问该私钥的权限。
在我们的情况下,我必须:
  1. 勾选导出复选框,将pfx文件导入本地服务器存储区以确保发送私钥。
  2. 使用MMC控制台,为证书的私钥授予服务帐户访问权限。
其他答案中解释的受信任的根问题是有效的,但不是我们的问题所在。

2
看源代码,我也认为私钥可能存在问题。
它实际上是在检查传递的证书是否为X509Certificate2类型以及是否具有私钥。
如果找不到私钥,则尝试在CurrentUser存储区和LocalMachine存储区中查找证书。如果找到证书,则检查私钥是否存在。
(请参见SecureChannnel类中EnsurePrivateKey方法的源代码
因此,取决于导入哪个文件(.cer-没有私钥或.pfx-带有私钥)以及在哪个存储区中,它可能无法找到正确的证书,从而未填充Request.ClientCertificate。
您可以激活网络跟踪来尝试调试此问题。它会给你这样的输出:
尝试在证书存储中查找匹配的证书,无法在LocalMachine存储或CurrentUser存储中找到该证书。

正如我在问题描述中提到的,我从LocalMachine和一个具有相同结果的.cer文件中检索了证书。您可以从说明中看到LocalMachine中的那个具有私钥,并且证书也复制到了Trusted Root中。 - Kevin Junghans
好的。抱歉。那可能不是问题所在。您是否激活了跟踪? - Fabian

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接