}

Autenticando el API de idealista/hipotecas con Spring OAuth2 y Zuul

En idealista/hipotecas hemos publicado un API para poder hacer simulaciones hipotecarias. De momento sólo está abierta a algunos de nuestros clientes (que nos están haciendo de beta testers), y su cometido es exponer la información y cálculos tanto de nuestro simulador como de nuestras tablas de amortización.

Ya que el API iba a ser utilizada por terceros, uno de los requisitos del desarrollo ha sido implementar una capa de autenticación; para lo que hemos decidido usar OAuth2 como protocolo de autorización junto con el estándar JWT para la generación y representación de los tokens. Esta combinación nos ofrece algunas ventajas:

  • No nos hace falta persistir el token para su posterior validación o comprobación de expiración.
  • Nos permite generar tokens firmados y con toda la información necesaria para la validación y expiración de los mismos.
  • No es necesario que el token sea validado por el servidor de autenticación, si no que es el propio API Gateway el que puede validar el token.

Nuestro primer paso fue crear un servidor de autenticación utilizando la implementación de oauth2 de Spring.  El siguiente paso sería configurar el API para recibir y validar las peticiones, pero queríamos aprovechar nuestra arquitectura actual y utilizar el Zuul como API Gateway añadiendole un módulo de autenticación y que fuera éste el encargado de validar y autorizar las peticiones al API. Este módulo  debía de ser capaz no solo de validar y autorizar las peticiones del simulador, sino cualquier otra petición que requiera autenticación. Esto nos permitiría en un futuro añadir más APIs sin necesidad de tener que preocuparnos por tener que autenticarlas individualmente. La securización de URLs además debía ser dinámica. Es decir: debíamos ser capaces de añadir, modificar y eliminar de una forma sencilla las URLs que queríamos securizar.

Para conseguir estos objetivos, hemos tenido que:

  • Implementar un mecanismo de securización de URLs: una forma sencilla de añadir, modificar y eliminar las URLs del API.
  • Asegurarnos de que el mecanismo de autenticación es transparente a todas las URLs que actualmente están pasando a través de él. Es decir: todo tiene que seguir funcionando igual.
  • Además hemos querido implementar un mecanismo para monitorizar y crear métricas de  todas las peticiones al API del simulador (o futuras APIs).

Implementación del módulo de autenticación en el API Gateway.

El primer paso ha sido implementar el mecanismo para la securización de URLs. En la mayor parte de la documentación que habíamos consultado al respecto, era cada servicio individual el que se encargaba de autenticar las peticiones a sus propios endpoints. Pero nosotros queríamos que fuera el propio Gateway el que tuviese esta responsabilidad de forma centralizada. De esta forma, podremos añadir otras APIs en el futuro sin tener que configurar una capa de autenticación para cada una de ellas.

Todo esto se gestiona a través de un fichero de configuración yml en nuestro config-server. Tiene una forma parecida a esto:

 security:
  authentication:
    routes:
      - path: /hipotecas/api/simulator/**/simulation
        scope: read
        method: post
      - path: /hipotecas/api/simulator/**/amortization
        scope: read
        method: post

Donde definimos los siguientes campos por ruta:

  • path: Url que queremos securizar
  • scope: De lectura (read) o de escritura (write). Este parámetro también es útil para limitar o dar acceso a algunos clientes a ciertos endpoints.
  • method: Método http de la petición. (get, post, put, patch, delete, options, head).

Tener las URLs por configuración nos permite la modifcación, eliminación y adición de más recursos sin necesidad de hacer un nuevo despliegue del Gateway (el Zuul en nuestro caso).

Estas URLs son validadas y añadidas por configuración en el arranque del Zuul, que las carga a través de esta clase:

@ConfigurationProperties(prefix = "security.authentication")
public class ConfigurationSecurityRoutes {

    private AuthenticationRouteMapper authenticationRouteMapper;

    private List<Routes> routes = new ArrayList<>();

    private static List<AuthenticationRoute> AUTHENTICATION_ROUTES;

    public List<Routes> getRoutes() {
        return this.routes;
    }

    public void setRoutes(List<Routes> routes) {
        this.routes = routes;
    }
}

Una vez tenemos los endpoints a securizar definidos en el Gateway, el segundo paso será configurar un servidor de recursos. Se define servidor de recursos, según el RFC,  como el servidor que aloja los recursos protegidos y es capaz de aceptar y responder a todas las peticiones utilizando tokens de acceso. Para crearlo añadimos a nuestro Gateway una clase de configuración con la siguiente estructura :

@Configuration
@EnableResourceServer
public class ResourcesServerConfiguration extends ResourceServerConfigurerAdapter {

    @Value("${security.token.accessTokenValidity}")
    private int accessTokenValidity;

    @Value("${security.token.refreshTokenValidity}")
    private int refreshTokenValidity;

    @Value("${security.token.jwt.signing.key}")
    private String signingKey;

    private ConfigurationSecurityRoutes configurationSecurityRoutes;

    @Inject
    public ResourcesServerConfiguration(ConfigurationSecurityRoutes configurationSecurityRoutes) {
        this.configurationSecurityRoutes = configurationSecurityRoutes;
    }

    public ResourcesServerConfiguration() {
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer config) {
        config.tokenServices(tokenServices());
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey(signingKey);
        return jwtAccessTokenConverter;
    }

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices services = new DefaultTokenServices();
        services.setSupportRefreshToken(Boolean.TRUE);
        services.setTokenStore(tokenStore());
        services.setTokenEnhancer(accessTokenConverter());
        services.setAccessTokenValiditySeconds(accessTokenValidity);
        services.setRefreshTokenValiditySeconds(refreshTokenValidity);
        return services;
    }


    @Override
    public void configure(HttpSecurity http) throws Exception {

        for (AuthenticationRoute authenticationRoute: configurationSecurityRoutes.obtainAuthenticationRoutes()) {
            http.authorizeRequests()
                    .antMatchers(authenticationRoute.method, authenticationRoute.path)
                    .access(generateOAuthSecurityExpression(authenticationRoute));
        }
    }

}

A continuación, creamos un  ResourceServerConfigurerAdapter y añadimos la anotación @EnableresourceServerConfiguration; la cual añade automáticamente un filtro de tipo OAuth2AuthenticationProcessingFilter -que nos proporciona Spring OAuth-, al conjunto de de filtros de Spring Security. Con esto conseguimos habilitar el filtro de Spring Security que autentica las peticiones OAuth2.

Al tener nuestro servidor de recursos en el API Gateway (y por tanto separado del OAuth2 server) debemos replicar la configuración  de validación del token. Los elementos configurados son los siguientes:

  1. tokenServices: Definimos y configuramos un ResourceServerTokenServices.
  2. tokenStore: Implementación de TokenStore, en nuestro caso será JwtTokenStore al cual le pasamos un JwtAccessTokenConverter con la configuración de la firma digital para la validación del token.
  3. Securización de recursos a través de HttpSecurity:
  @Override
    public void configure(HttpSecurity http) throws Exception {

        for (AuthenticationRoute authenticationRoute: configurationSecurityRoutes.obtainAuthenticationRoutes()) {
            http.authorizeRequests()
                    .antMatchers(authenticationRoute.method, authenticationRoute.path)
                    .access(generateOAuthSecurityExpression(authenticationRoute));
        }
    }

Este método será el encargado de securizar y configurar el acceso a nuestros recursos. Lo que hace es iterar las urls (recursos) e ir añadiendolas a la configuración de Spring Security a través de HttpSecurity, mediante el método de la petición, el path y un scope.

El scope se indica principalmente para establecer unos límites o privilegios a los clientes que tendrán acceso a los recursos. Por ejemplo se puede establecer unos scopes de escritura o de lectura de tal forma que un cliente sólo tenga acceso a peticiones con scope de escritura y otro de lectura y escritura. Para establecer el acceso se utilizan expresiones compuestas por OAuthSecurityExpressionMethod que tienen la siguiente forma:

#oauth2.hasScope('read') and #oauth2.isClient()

Con esto finalizamos la implementación y configuración de nuestro módulo de autenticación dentro de nuestro API Gateway.

Implementación del módulo de identificación de peticiones en el API Gateway

Otro de los requisitos que teníamos era poder identificar qué cliente nos hacía las peticiones; con el fin de poder monitorizar, crear métricas o limitar el acceso a los mismos por número de peticiones. Para esto pensamos en dos posibles soluciones:

  • Establecer en la definición de nuestra api campos de identificación del cliente.
  • Interceptar la petición y utilizar los datos del token para obtener el clientId.

La facilidad que nos aporta Zuul para crear filtros para las requests nos hizo decantarnos por la segunda opción. De tal forma que creamos un filtro que filtrara las peticiones a las URLs securizadas (nuestros recursos) y obtuviera el clientId de la autenticación.

public class ClientRequestInfoFilter extends ZuulFilter {

    private static final String ANONYMOUS = "ANONYMOUS";
    public static final String CLIENTID = "CLIENTID";

    private ConfigurationSecurityRoutes configurationSecurityRoutes;
    private UrlPathHelper urlPathHelper;
    private PathMatcher pathMatcher;


    public ClientRequestInfoFilter(ConfigurationSecurityRoutes configurationSecurityRoutes) {
        this.configurationSecurityRoutes = configurationSecurityRoutes;
        this.urlPathHelper = new UrlPathHelper();
        this.pathMatcher = new AntPathMatcher();
    }

    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return SEND_FORWARD_FILTER_ORDER-1;
    }

    @Override
    public boolean shouldFilter() {
        RequestContext requestContext = obtainRequest();
        String requestPath = urlPathHelper.getPathWithinApplication(requestContext.getRequest());

        return matchWithAuthenticationRoutes(requestPath);
    }


    @Override
    public Object run() {
        RequestContext requestContext = obtainRequest();
        requestContext.addZuulRequestHeader(CLIENTID, obtainAuthenticationClientId());

        return null;
    }

    private String obtainAuthenticationClientId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!(authentication instanceof AnonymousAuthenticationToken)) return authentication.getName();

        return ANONYMOUS;
    }

    private RequestContext obtainRequest() {
        return RequestContext.getCurrentContext();
    }

    private boolean matchWithAuthenticationRoutes(String requestPath) {
        for (AuthenticationRoute route: configurationSecurityRoutes.obtainAuthenticationRoutes()) {
            if (pathMatcher.match(route.path, requestPath)) return Boolean.TRUE;
        }

        return Boolean.FALSE;
    }

Crear un filtro en Zuul es bastante sencillo. Simplemente creamos una clase que herede de ZuulFilter y le asignamos un tipo (pre, post, route, error) y un orden. A continuación implementamos el método shoudFilter; en el que definimos las condiciones de aplicación del filtro:

 @Override
    public boolean shouldFilter() {
        RequestContext requestContext = obtainRequest();
        String requestPath = urlPathHelper.getPathWithinApplication(requestContext.getRequest());

        return matchWithAuthenticationRoutes(requestPath);
    }

    private boolean matchWithAuthenticationRoutes(String requestPath) {
        for (AuthenticationRoute route: configurationSecurityRoutes.obtainAuthenticationRoutes()) {
            if (pathMatcher.match(route.path, requestPath)) return Boolean.TRUE;
        }

        return Boolean.FALSE;
    }

Como hemos dicho anteriormente, solo nos interesa identificar las llamadas a los recursos securizados. Por tanto deberemos filtrar por esas URLs. En cada petición comprobamos si la URL se corresponde con la de algún recurso securizado. Si es así pasamos a aplicar el filtro. En caso contrario la ejecución de la request continuaría su curso sin pasar por la capa de autenticación.

En el caso de que aplique el filtro, la ejecución de éste consistirá en obtener el contexto de autenticación y, a partir de éste el atributo name -que coincide con el clientId enviado en el token-.

 @Override
    public Object run() {
        RequestContext requestContext = obtainRequest();
        requestContext.addZuulRequestHeader(CLIENTID, obtainAuthenticationClientId());

        return null;
    }

    private String obtainAuthenticationClientId() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!(authentication instanceof AnonymousAuthenticationToken)) return authentication.getName();

        return ANONYMOUS;
    }

Una vez obtenido el clientId lo añadimos a la cabecera para después exponer estos datos en el sistema de monitorización (en nuestro caso usamos Prometheus y Grafana, pero una vez identificada la petición puede ser cualquier otro) y generar todas las métricas y gráficas necesarias.

Posibles cambios, mejoras y siguientes pasos

Algo que puede mejorar la arquitectura actual sería incluir el servidor de OAuth dentro del API Gateway (en nuestro caso Zuul), algunas de las ventajas que podríamos obtener serían:

  • Dejar de duplicar la configuración para la validación del token en el servidor de recursos.
  • Eliminar un serivcio y por tanto liberar recursos y reducir tareas de mantenimiento.
  • Tener el módulo de autenticación completo dentro del zuul.

4 comments

  1. Muy buen artículo, Sí señor. Da gusto ver artículos de calidad, con código y reutilizables.

    ¡Muchas gracias por el articulo!

    1. ¡Muchas gracias Nacho!

      Un Saludo!!!

  2. Basándome en este artículo he creado este otro incluyendo el código fuente del ejemplo completo pero en vez de utilizando Zuul utilizando Spring Cloud Gateway que parece es su sustituto, en el momento que sepa que WebFlux está soportado por Spring Security hago que el gateway implemente la autorización de forma similar al artículo.

    https://picodotdev.github.io/blog-bitix/2019/02/servidor-oauth-gateway-y-servicio-utilizando-tokens-jwt-con-spring/

    Respecto a las posibles mejoras finales, integrar el servidor Oauth y el Gateway aunque es bueno para simplificar la infraestructura puede presentar otros problemas en la integración si cada uno de ellos utiliza diferentes versiones de librerías.

    Buen artículo. Un saludo.

    1. ¡Muchas gracias pd!, y gracias también por la mención en tu artículo (muy currado).

      Un Saludo!

Deja una respuesta