How to use a client certificate to authenticate and authorize in a Web API
Tracing helped me find what the problem was (Thank you Fabian for that suggestion). I found with further testing that I could get the client certificate to work on another server (Windows Server 2012). I was testing this on my development machine (Window 7) so I could debug this process. So by comparing the trace to an IIS Server that worked and one that did not I was able to pinpoint the relevant lines in the trace log. Here is a portion of a log where the client certificate worked. This is the setup right before the send
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:
Here is what the trace log looked like on the machine where the client certificate failed.
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.
Focusing on the line that indicated the server specified 137 issuers I found this Q&A that seemed similar to my issue. The solution for me was not the one marked as an answer since my certificate was in the trusted root. The answer is the one under it where you update the registry. I just added the value to the registry key.
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL
Value name: SendTrustedIssuerList Value type: REG_DWORD Value data: 0 (False)
After adding this value to the registry it started to work on my Windows 7 machine. This appears to be a Windows 7 issue.
Make sure HttpClient has access to the full client certificate (including the private key).
You are calling GetCert with a file "ClientCertificate.cer" which leads to the assumption that there is no private key contained - should rather be a pfx file within windows. It may be even better to access the certificate from the windows cert store and search it using the fingerprint.
Be careful when copying the fingerprint: There are some non-printable characters when viewing in cert management (copy the string over to notepad++ and check the length of the displayed string).
I actually had a similar issue, where we had to many trusted root certificates. Our fresh installed webserver had over a hunded. Our root started with the letter Z so it ended up at the end of the list.
The problem was that the IIS sent only the first twenty-something trusted roots to the client and truncated the rest, including ours. It was a few years ago, can't remember the name of the tool... it was part of the IIS admin suite, but Fiddler should do as well. After realizing the error, we removed a lot trusted roots that we don't need. This was done trial and error, so be careful what you delete.
After the cleanup everything worked like a charm.
Update:
Example from Microsoft:
https://docs.microsoft.com/en-us/azure/app-service/app-service-web-configure-tls-mutual-auth#special-considerations-for-certificate-validation
Original
This is how I got client certification working and checking that a specific Root CA had issued it as well as it being a specific certificate.
First I edited <src>\.vs\config\applicationhost.config
and made this change: <section name="access" overrideModeDefault="Allow" />
This allows me to edit <system.webServer>
in web.config
and add the following lines which will require a client certification in IIS Express. Note: I edited this for development purposes, do not allow overrides in production.
For production follow a guide like this to set up the IIS:
https://medium.com/@hafizmohammedg/configuring-client-certificates-on-iis-95aef4174ddb
web.config:
<security>
<access sslFlags="Ssl,SslNegotiateCert,SslRequireCert" />
</security>
API Controller:
[RequireSpecificCert]
public class ValuesController : ApiController
{
// GET api/values
public IHttpActionResult Get()
{
return Ok("It works!");
}
}
Attribute:
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;
}
}
Can then call the API with client certification like this, tested from another web project.
[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();
}
}
}