Utilizando el DNIe para autenticarnos en una aplicación Spring Boot detrás de un proxy inverso – Autenticación X.509

Son muchos los ejemplos que se pueden encontrar de cómo implementar la autenticación mediante certificados – X.509 – en una aplicación con Spring Boot, pero pocos hablan de como integrarla cuando nuestra aplicación no se expone por SSL directamente, si no que lo hace a través de un proxy inverso como Nginx o Apache.

Este es el escenario más típico hoy en día con arquitecturas cloud orientadas a los microservicios, en los que las aplicaciones se exponen a través de un proxy por HTTPS, pero la comunicación interna se realiza por HTTP, salvo casos en los que se implemente un service mesh como Istio, pero eso escapa al alcance de este post.

En este post vamos a ver como configurar la autenticación X.509 con un caso práctico, utilizando el DNIe como certificado de cliente, Nginx como proxy inverso, y una aplicación Spring Boot que escucha tráfico HTTP.

El diagrama descrito sería el siguiente:

Flujo de comunicación

Nota: necesitaremos un lector de tarjetas electrónicas para utilizar el certificado digital del DNIe

Configuración del Nginx

El Nginx será la pieza encargada de pedir el certificado al cliente y validarlo. Para que pueda validar el certificado correctamente, necesitaremos indicarle el fichero que contiene los certificados raíz e intermediario que expidieron el certificado del cliente.

Para obtener los certificados raíz intermediarios para el caso del DNIe, hay varias opciones:

  • Podemos ir a la web oficial donde encontraremos los links de descarga
  • Podemos conectar el DNIe al ordenador, y visualizar el certificado (después de haber instalados los plugins necesarios para el lector). Al visualizar el certificado, nos mostrará tanto el certificado raíz, como el intermediario/subordinado.

El segundo método es más fiable, ya que por ejemplo, para el DNIe existen varias autoridades de certificación (CA) intermedias, y de esta forma nos aseguramos de tener la que expidió el nuestro.

Al conectar el DNIe nos pedirá la contraseña para poder utilizarlo

Petición de contraseña DNIe en MacOS – Firefox

Una vez introducida, en firefox, podremos visualizar los certificados en Preferencias->Privacidad y Seguridad->Ver certificados

Certificados incluidos en el DNIe

Si hacemos click en el que pone AUTENTICACIÓN, podremos ver la cadena de confianza completa del certificado.

Cadena de confianza del certificado

En nuestro caso, nos interesa exportar los certificados de las CAs: AC RAIZ DNIE y AC DNIE 001. Para esto seleccionamos cada uno y le damos a exportar, de tal forma que lo guardaremos en formato PEM (BASE64) en dos archivos separados.

Lo siguiente que haremos será copiar ambos certificados en un solo fichero, ya que Nginx por debajo utiliza openssl para la verificación, y solo se le indica un fichero, de ahí la importancia de que estén ambos certificados en el mismo fichero.

Antes de configurar el Nginx, podemos hacer una comprobación con openssl para verificar nuestro certificado cliente, para esto necesitaremos exportar también nuestro certificado personal en formato PEM como en el paso anterior.

openssl verify -CAfile CA.pem javi.pem
CA.pem: OK
javi.pem: OK

Si obtenemos un error en el paso anterior relacionado con que no puede verificar el issuer, será porque en el fichero CA.pem no tenemos los certificados raíz e intermedio. El fichero CA.pem debería tener esta pinta:

Fichero CA.pem con certificados raíz e intermedio

Ahora vamos con la propia configuración del Nginx, que es muy sencilla, deberemos poner las siguientes directivas en el fichero nginx.conf en el bloque server o http:

ssl_verify_client optional; #también se puede poner a 'on', pero en este ejemplo queremos dejar la App accesible también por usuario y contraseña.

ssl_client_certificate /CA.pem;
ssl_verify_depth 2;

Por último, tendremos que reenviar el certificado cliente verificado a nuestra aplicación Spring Boot, para ello, en el bloque location de la aplicación, pondremos la siguiente directiva que enviará el certificado escapado en una cabecera HTTP:

location /controlpanel{
    proxy_pass http://192.168.20.27:18000/controlpanel;
    proxy_set_header X509-Cert $ssl_client_escaped_cert;
}

Configuración de la aplicación Spring Boot

En la aplicación Spring Boot queremos que coexistan varios métodos de autenticación, Oauth 2, usuario y contraseña, X.509…, por esta razón implementaremos esta autenticación a nivel de filtro HTTP en la cadena de SecurityFilterChain de Spring.

La lógica del filtro será la siguiente:

  • Si la request contiene la cabecera X509-Cert y la petición no está autenticada, se ejecuta.
  • Si la request es contra una URL de recursos Statelessness como por ejemplo una API REST, se autenticará el usuario pero no se persistirá la sesión/autenticación en el contexto de seguridad de Spring – SecurityContextHolder -, por lo tanto se eliminará todo rastro de autenticación al concluir la request.
  • Si la request es contra una URL de recursos que necesitan sesión, se autenticará al usuario y no se eliminará la sesión del contexto de seguridad al concluir la request.

Por otra parte, nos hará falta un servicio que extraiga la información del usuario del certificado, y si procede como en el caso de este ejemplo, crear el usuario en base de datos si es la primera vez que accede a nuestra aplicación.

Servicio para la extracción de datos del certificado

Lo primero que necesitaremos en el servicio, es un método que descodifique el certificado y lo convierta a un objeto X509Certificate. Como sabemos, el certificado viene con escapado URL y en base 64, además deberemos quitarle el header y el trailing de -BEGIN CERTIFICATE- y -END CERTIFICATE-, y los \n.

private X509Certificate parseCertificate(String pem) throws CertificateException, UnsupportedEncodingException {
  final String cert = URLDecoder.decode(pem, StandardCharsets.UTF_8.name());

  final byte[] decoded = Base64.getDecoder()
			.decode(cert.replaceAll(BEGIN_CERTIFICATE,"")
                        .replaceAll(END_CERTIFICATE, "")
                        .replaceAll("\n", ""));

  return (X509Certificate) CertificateFactory.getInstance("X.509")
				.generateCertificate(new ByteArrayInputStream(decoded));
}

Lo siguiente será un método que extraiga información útil, como el nombre de pila y el ID único (nº de DNI) del certificado, para después crear y autenticar al usuario.

Como sabemos, la información de identificación viene en el SubjectDN del certificado, del cual podemos extraer información específica utilizando expresiones regulares. En este ejemplo, vamos a extraer el CN, que contiene el nombre de pilar de la persona, y el SerialNumber, que contiene el número de DNI, todo esto incluido dentro del SubjectDN. Por tanto, haremos uso de las siguientes expresiones regulares:

Pattern subjectDnPattern = Pattern.compile("CN=\"(.*?)(?:\"|$)", Pattern.CASE_INSENSITIVE);
Pattern subjectSerialIDPattern = Pattern.compile("SERIALNUMBER=(.*?)(?:,|$)", Pattern.CASE_INSENSITIVE);

Como referencia, en un Subject de un DNIe, viene la siguiente información:

CN = «GOMEZ CORNEJO GIL, FRANCISCO JAVIER (AUTENTICACIÓN)»
Given Name = FRANCISCO JAVIER
Surname = GOMEZ CORNEJO
SERIALNUMBER = 0000001A
C = ES

En el siguiente método, extraemos a un mapa ambos parámetros comentados, y hasheamos el nº de DNI si así lo tenemos configurado, por motivos de la LOPD u otras restricciones.

private Map<String, String> extractInfoFromSN(String subjectDN) throws Exception {
    final Map<String, String> info = new HashMap<>();
    Matcher matcher = subjectDnPattern.matcher(subjectDN);
    if (matcher.find()) {
	info.put(CN, matcher.group(1));
    }

    matcher = subjectSerialIDPattern.matcher(subjectDN);

    if (matcher.find()) {
	final String id = shoudlHashIdAttribute 
                     ? PasswordEncoder.getInstance().encodeSHA256(matcher.group(1))
		     : matcher.group(1);
	info.put(SERIALNUMBER, id);

    }
    return info;
}

Por último, nos queda ver si el usuario existe en nuestra DB, crearlo si no existe, y devolver dicho usuario para cuando invoque el servicio nuestro filtro HTTP.

Los siguientes métodos, devuelven el nombre de usuario a partir de un certificado, creándolo primero si no existe, y encapsulado en un Optional para los casos en los que no se pueda extraer.

public Optional<String> extractUserNameFromCert(X509Certificate cert) throws Exception {
	final Map<String, String> info =extractInfoFromSN(cert.getSubjectDN().getName());
	if (info.isEmpty())
		return Optional.empty();

	final User user = userService.getUser(info.get(SERIALNUMBER));
	if (user == null)
		return Optional.of(createUserFromCertInfo(info).getUserId());
	else
		return Optional.of(user.getUserId());

}
public User createUserFromCertInfo(Map<String, String> info) {
	final User user = new User();
	user.setUserId(info.get(SERIALNUMBER));
	user.setFullName(info.get(CN));
	user.setEmail(info.get(SERIALNUMBER) + "@e-cert.com");
	user.setActive(true);
	user.setPassword(defaultPassword + UUID.randomUUID().toString().substring(1, 5));
	user.setRole(roleRepository.findById(Role.Type.ROLE_USER.name()).orElse(null));
	userService.createUser(user);
	return user;
}

Para simplificar las llamadas desde el filtro, añadiremos un método que encapsule toda la lógica descrita del servicio:

public Optional<String> extractUserNameFromCert(String pem)throws Exception {
	return extractUserNameFromCert(parseCertificate(pem));
}

Filtro HTTP

A continuación implementaremos la lógica del filtro HTTP descrita anteriormente.

En el doFilter, contemplaremos los casos enunciados:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException {
	final HttpServletRequest req = (HttpServletRequest) request;
	if (requiresAuthentication(req, true)) {
		log.debug("Detected header {} in API request, loading temp autenthication", CERT_HEADER);
		try {
			prepareX509Authentication(req);
			chain.doFilter(request, response);

		} catch (final Exception e) {
            		log.error("Error on X509 REST authentication", e);
		} finally {
			log.debug("Clearing authentication contexts");
			InterceptorCommon.clearContexts();
		}
	} else if (requiresAuthentication(req, false)) {
		log.debug("Detected header {} in Non API request, loading autenthication & session", CERT_HEADER);
		try {
			final Authentication auth = prepareX509Authentication(req);
			if (auth != null)
				successHandler.onAuthenticationSuccess(req,         (HttpServletResponse) response, auth);
			chain.doFilter(request, response);
		} catch (final Exception e) {
			log.error("Error on X509 authentication", e);
		}
	} else {
		chain.doFilter(request, response);
	}

}

//Método que evalúa si se debe ejecutar la lógica de cada condición/caso
private boolean requiresAuthentication(HttpServletRequest req, boolean isAPI) {
	if (!isAPI) {
                //Si no es una API REST
		return !req.getServletPath().startsWith(LOGOUT_PREFIX) 
                       &&  req.getHeader(CERT_HEADER) != null 
                       && SecurityContextHolder.getContext().getAuthentication() == null;
	} else {
                //Si es una API REST
		return req.getServletPath().startsWith(API_PREFIX) 
                       && req.getHeader(CERT_HEADER) != null;
	}
}

//Método que devuelve el objeto autenticación con el usuario actual, haciendo uso del
//servicio creado anteriormente y del UserDetailsService para cargar los detalles del 
//usuario extraido del certificado
private Authentication prepareX509Authentication(HttpServletRequest req)
	throws CertificateException, UnsupportedEncodingException, Exception {
		x509Service.extractUserNameFromCert(req.getHeader(CERT_HEADER)).ifPresent(id -> 
        {
		final UserDetails details = detailsService.loadUserByUsername(id);
		if (details != null) {
			final Authentication auth = new UsernamePasswordAuthenticationToken(details, details.getPassword(),details.getAuthorities());
                       //Setea el contexto de seguridad de Spring
			InterceptorCommon.setContexts(auth);
			log.debug("Loaded authentication for user {}", auth.getName());
		}
	});
	return SecurityContextHolder.getContext().getAuthentication();

}

A pesar de que el código tiene comentarios, cabe destacar el caso en el que se trata de una URL que no es una API REST: una vez autenticado el usuario, se llama al Bean AuthenticationSuccessHandler, simulando una autenticación al más puro estilo AuthenticationProvider, para mimetizar el proceso de autenticación con un Bean de este tipo.

Añadir el filtro a la cadena de Spring

El último paso de este tutorial, será añadir el filtro creado a la SecurityFilterChain de Spring, justo antes del AnonymousFilter, para evitar que Spring devuelva un 403 al usuario, al estar todas las URLs securizadas.

En la clase de configuración de seguridad, añadiremos la siguiente línea:

@Override
	protected void configure(HttpSecurity http) throws Exception {
     http.authorizeRequests()
	 .anyRequest().authenticated()
	 .and()
	 .addFilterBefore(new X509CertFilter(), AnonymousAuthenticationFilter.class);


}

Solo nos quedará acceder a http://localhost/controlpanel y acceder a nuestra App, veremos que se crea el usuario y nos da acceso sin pedirnos ninguna contraseña 🙂

Usuario creado a partir de un DNIe

Un comentario

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s