c# - asp - web api 2 individual authentication
Cómo usar un certificado de cliente para autenticar y autorizar en una API web (6)
Estoy tratando de usar un certificado de cliente para autenticar y autorizar dispositivos usando una API web y desarrollé una prueba de concepto simple para resolver problemas con la posible solución. Me encuentro con un problema en el que la aplicación web no recibe el certificado del cliente. Varias personas informaron sobre este problema, incluso en estas preguntas y respuestas , pero ninguna de ellas tiene una respuesta. Espero proporcionar más detalles para revivir este problema y espero obtener una respuesta para mi problema. Estoy abierto a otras soluciones. El requisito principal es que un proceso independiente escrito en C # pueda llamar a una API web y autenticarse mediante un certificado de cliente.
La API web en este POC es muy simple y solo devuelve un valor único. Utiliza un atributo para validar que se usa HTTPS y que hay un certificado de cliente.
public class SecureController : ApiController
{
[RequireHttps]
public string Get(int id)
{
return "value";
}
}
Aquí está el código para 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);
}
}
}
En este POC solo estoy verificando la disponibilidad del certificado del cliente. Una vez que esto funciona, puedo agregar comprobaciones de información en el certificado para validar con una lista de certificados.
Aquí está la configuración en IIS para SSL para esta aplicación web.
Aquí está el código para el cliente que envía la solicitud con un certificado de cliente. Esta es una aplicación de consola.
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;
}
Cuando ejecuto esta aplicación de prueba, obtengo un código de estado de 403 Prohibido con una frase de razón de "Certificado de Cliente Requerido" que indica que está entrando en mi RequireHttpsAttribute y no encuentra ningún certificado de cliente. Al ejecutar esto a través de un depurador, verifiqué que el certificado se está cargando y agregando a WebRequestHandler. El certificado se exporta a un archivo CER que se está cargando. El certificado completo con la clave privada se encuentra en los almacenes personales y de confianza de la máquina local para el servidor de aplicaciones web. Para esta prueba, el cliente y la aplicación web se ejecutan en la misma máquina.
Puedo llamar a este método de API web usando Fiddler, adjuntando el mismo certificado de cliente, y funciona bien. Al usar Fiddler, pasa las pruebas en RequireHttpsAttribute y devuelve un código de estado exitoso de 200 y devuelve el valor esperado.
¿Alguien se ha encontrado con el mismo problema donde HttpClient no envía un certificado de cliente en la solicitud y encuentra una solución?
Actualización 1:
También intenté obtener el certificado del almacén de certificados que incluye la clave privada. Así es como lo recuperé:
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;
}
Verifiqué que este certificado se estaba recuperando correctamente y se estaba agregando a la colección de certificados del cliente. Pero obtuve los mismos resultados donde el código del servidor no recupera ningún certificado de cliente.
Para completar, aquí está el código utilizado para recuperar el certificado de un archivo:
private static X509Certificate GetCert(string filename)
{
X509Certificate Cert = X509Certificate.CreateFromCertFile(filename);
return Cert;
}
Notará que cuando obtiene el certificado de un archivo, devuelve un objeto del tipo X509Certificate y cuando lo recupera del almacén de certificados es del tipo X509Certificate2. El método X509CertificateCollection.Add espera un tipo de X509Certificate.
Actualización 2: Todavía estoy tratando de resolver esto y he probado muchas opciones diferentes, pero fue en vano.
- Cambié la aplicación web para que se ejecute con un nombre de host en lugar de un host local.
- Configuré la aplicación web para que requiera SSL
- Verifiqué que el certificado se configuró para la autenticación del cliente y que está en la raíz de confianza
- Además de probar el certificado del cliente en Fiddler, también lo validé en Chrome.
En un momento durante la prueba de estas opciones, comenzó a funcionar. Luego comencé a retroceder los cambios para ver qué causaba que funcionara. Siguió funcionando. Luego intenté eliminar el certificado de la raíz de confianza para validar que se requería y dejó de funcionar y ahora no puedo volver a hacerlo, aunque volví a colocar el certificado en la raíz de confianza. Ahora Chrome ni siquiera me pedirá un certificado como el que usó también y falla en Chrome, pero aún funciona en Fiddler. Debe haber alguna configuración mágica que me falta.
También intenté habilitar "Negociar certificado de cliente" en el enlace, pero Chrome todavía no me pedirá un certificado de cliente. Aquí está la configuración usando "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
Aquí está el certificado del cliente que estoy usando:
Estoy desconcertado sobre cuál es el problema. Estoy agregando una recompensa por cualquiera que pueda ayudarme a resolver esto.
Asegúrese de que HttpClient tenga acceso al certificado completo del cliente (incluida la clave privada).
Está llamando a GetCert con un archivo "ClientCertificate.cer" que lleva a la suposición de que no hay una clave privada contenida, sino que debería ser un archivo pfx dentro de Windows. Puede ser aún mejor acceder al certificado desde el almacén de certificados de Windows y buscarlo con la huella digital.
Tenga cuidado al copiar la huella digital: hay algunos caracteres que no se pueden imprimir al visualizar en la administración de certificados (copie la cadena en notepad ++ y verifique la longitud de la cadena que se muestra).
De hecho, tuve un problema similar, en el que teníamos muchos certificados raíz de confianza. Nuestro servidor web recién instalado tenía más de cien años. Nuestra raíz comenzó con la letra Z, por lo que terminó al final de la lista.
El problema era que el IIS envió solo las primeras raíces confiables de veintitantos al cliente y truncó el resto , incluido el nuestro. Fue hace unos años, no recuerdo el nombre de la herramienta ... era parte de la suite de administración de IIS, pero Fiddler también debería hacerlo. Después de darnos cuenta del error, eliminamos muchas raíces confiables que no necesitamos. Esto se hizo prueba y error, así que ten cuidado con lo que eliminas.
Después de la limpieza, todo funcionó a las mil maravillas.
El rastreo me ayudó a encontrar cuál era el problema (gracias Fabian por esa sugerencia). Con más pruebas descubrí que podía hacer que el certificado del cliente funcionara en otro servidor (Windows Server 2012). Estaba probando esto en mi máquina de desarrollo (Windows 7) para poder depurar este proceso. Entonces, al comparar el rastreo con un servidor IIS que funcionó y uno que no funcionó, pude identificar las líneas relevantes en el registro de rastreo. Aquí hay una parte de un registro donde funcionó el certificado del cliente. Esta es la configuración justo antes del envío
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:
Así es como se veía el registro de rastreo en la máquina donde falló el certificado del cliente.
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.
Al centrarme en la línea que indicaba que el servidor especificaba 137 emisores, encontré estas preguntas y respuestas que parecían similares a mi problema . La solución para mí no fue la marcada como respuesta ya que mi certificado estaba en la raíz de confianza. La respuesta es la que se encuentra debajo, donde actualiza el registro. Acabo de agregar el valor a la clave de registro.
HKEY_LOCAL_MACHINE / SYSTEM / CurrentControlSet / Control / SecurityProviders / SCHANNEL
Nombre del valor: SendTrustedIssuerList Tipo de valor: REG_DWORD Datos del valor: 0 (falso)
Después de agregar este valor al registro, comenzó a funcionar en mi máquina con Windows 7. Esto parece ser un problema de Windows 7.
Me encontré con un problema similar recientemente y, siguiendo los consejos de Fabian, realmente me llevó a la solución. Resulta que con los certificados de cliente debes asegurarte de dos cosas:
-
La clave privada se está exportando como parte del certificado.
-
La identidad del grupo de aplicaciones que ejecuta la aplicación tiene acceso a dicha clave privada.
En nuestro caso tuve que:
- Importe el archivo pfx en el almacén del servidor local mientras marca la casilla de verificación de exportación para asegurarse de que se envió la clave privada.
- Con la consola MMC, otorgue a la cuenta de servicio el acceso utilizado a la clave privada para el certificado.
El problema raíz de confianza explicado en otras respuestas es válido, simplemente no fue el problema en nuestro caso.
Mirando el código fuente, también creo que debe haber algún problema con la clave privada.
Lo que está haciendo es verificar si el certificado que se pasa es del tipo X509Certificate2 y si tiene la clave privada.
Si no encuentra la clave privada, intenta encontrar el certificado en la tienda CurrentUser y luego en la tienda LocalMachine. Si encuentra el certificado, verifica si la clave privada está presente.
(vea el código fuente de la clase SecureChannnel, método GuaranteePrivateKey)
Por lo tanto, según el archivo que haya importado (.cer, sin clave privada o .pfx, con clave privada) y en qué tienda puede que no encuentre el correcto y Request.ClientCertificate no se completará.
Puede activar el seguimiento de red para intentar depurar esto. Te dará una salida como esta:
- Intentando encontrar un certificado coincidente en el almacén de certificados
- No se puede encontrar el certificado en la tienda LocalMachine o en la tienda CurrentUser.
Actualizar:
Ejemplo de Microsoft:
Original
Así es como conseguí que la certificación del cliente funcionara y verificara que una CA raíz específica la hubiera emitido, además de ser un certificado específico.
Primero edité
<src>/.vs/config/applicationhost.config
e hice este cambio:
<section name="access" overrideModeDefault="Allow" />
Esto me permite editar
<system.webServer>
en
web.config
y agregar las siguientes líneas que requerirán una certificación del cliente en IIS Express.
Nota: Edité esto para fines de desarrollo, no permita anulaciones en producción.
Para la producción, siga una guía como esta para configurar el IIS:
https://medium.com/@hafizmohammedg/configuring-client-certificates-on-iis-95aef4174ddb
web.config:
<security>
<access sslFlags="Ssl,SslNegotiateCert,SslRequireCert" />
</security>
Controlador API:
[RequireSpecificCert]
public class ValuesController : ApiController
{
// GET api/values
public IHttpActionResult Get()
{
return Ok("It works!");
}
}
Atributo:
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;
}
}
Luego puede llamar a la API con una certificación del cliente como esta, probada desde otro proyecto 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();
}
}
}