Enriqueciendo tokens con Lambda y API Gateway

Enriqueciendo tokens con Lambda y API Gateway

Uno de los patrones de seguridad machine to machine con los que más nos encontramos a medida que nos adentramos en el mundo financiero legacy es el que consiste en un microservicio dedicado a la autorización y autenticación, mientras que los demás microservicios solo se limitan a consultar este servicio cada vez que necesiten validar algún proceso.

Encontramos este mismo esquema en un cliente que sufría problemas de rendimiento en su backend, y si bien este esquema es seguro, sencillo y con un modelo de responsabilidad muy claro (toda la seguridad depende del microservicio dedicado), también tiene desventajas, siendo una de estas desventajas la razón de haber cambiado completamente el esquema a uno modernizado con la responsabilidad un poco más compartida.

El problema de siempre "demasiada carga"

Como suele pasar, la arquitectura original funcionó muy bien al inicio e hizo posible la implementación de un sistema de autenticación y autorización mediante una combinación id-tokens y access-tokens, los cuales eran forjados y validados por el microservicio de seguridad.

El problema empezó a tener forma mientras se combinaban los siguientes hechos:

  1. Los N microservicios que forman parte del backend hacían validaciones de seguridad cada vez que recibían tokens de cualquier origen, para saber si eran válidos y el alcance de los mismos.
  2. Si bien el servicio de seguridad era escalable horizontalmente, la capacidad de carga del mismo no aumentaba en forma lineal, esto es, existía algún punto en la interna que agregaba un pequeño delay a medida que aumentaba la carga, por ejemplo, la base de datos y sus querys propietarios.
  3. Naturalmente, en caso de fallar una validación de seguridad, por ejemplo en el caso de mucho tráfico, este proceso se repetía N veces hasta finalmente fallar, manteniendo el tráfico alto por más tiempo.
  4. Las validaciones de seguridad no tienen una jerarquía de prioridades.

Como podemos ver, la combinación de un par de estos hechos ya era suficiente para causar una caída o al menos un aumento considerable de la latencia de los procesos que dependen de autenticación, así que al juntarse todas el sistema no tenía chances.

Compartiendo responsabilidades

Mirando los hechos listados en la sección anterior y un poco del código de los microservicios, era claro que muchas de las peticiones de autenticación y autorización no eran necesarias por lo cual se podían reducir directamente en código, pero decidimos ir un paso más adelante y proponer que los microservicios no hagan consultas al microservicio de seguridad sobre la validez y el alcance de los tokens, sino que los validen ellos mismos.

La propuesta anterior implica dos problemas a resolver:

  1. Cómo confiar en los tokens
  2. Cómo definir y usar el alcance de los tokens

1. Cómo confiar en los tokens

Mediante un esquema de Clave Privada y Clave Pública, es posible firmar los tokens y validarlos sin necesidad de que haya un contacto directo entre el emisor y el receptor. En nuestro caso utlizamos JSON Web Tokens. Que son firmados por el emisor, en nuestro caso el microservicio de seguridad o Cognito, y son validados por todos los microservicios que los reciben mediante la clave pública.

Al ser un proceso individual de cada microservicio, la validación fue implementada en código para cada uno de ellos, aunque no requirió mucho esfuerzo ya que el código de validación es el mismo para todos los microservicios y es sencillo ya que solo decodifica el token con la clave pública.

2. Cómo definir y usar el alcance de los tokens

El microservicio de seguridad del cliente operaba con tokens opacos, los cuales solo eran interpretables por el mismo, por lo cual, la única forma de obtener información sobre el token era consultando al propio microservicio de seguridad, entonces un ejemplo de interacción dentro del backend era el siguiente:

  1. Una app móvil se autentica en un portal.
  2. El portal, cuyo backend es el microservicio de seguridad, le retorna un token opaco.
  3. La app móvil consulta una API pública, cuyo backend es un conjunto de microservicios, enviando el token recibido.
  4. Cada uno de los microservicios recibe el token y consulta al microservicio de seguridad si el token es válido, si corresponde al usuario y si puede o no realizar ciertas operaciones dentro del backend.

Al cambiar los tokens opacos por tokens JWT, se hizo posible agregar información de autorización en el mismo token, logrando el siguiente flujo:

  1. Una app móvil se autentica en el portal.
  2. El portal (cuyo backend es ahora Cognito u otro servicio de autenticación con soporte de tokens JWT) le retorna un token JWT firmado, que incluye información extra como el número de cuenta y sus productos.
  3. La app móvil consulta una API pública, cuyo backend es un conjunto de microservicios, enviando el token recibido.
  4. Cada uno de los microservicios recibe el token, revisa su validez mediante la clave pública y decide si dar o no el servicio de acuerdo a los productos y otros datos listados en su token.

Con este flujo se reduce considerablemente las llamadas al servicio de autenticación y es compatible con una implementación parcial, esto es, reemplazar solo el microservicio de autenticación con uno que soporte JWTs, sin necesidad de usar un servicio externo como Cognito.

En nuestro caso y tendiendo a que el cliente tenía el 90% de su stack tecnológico sobre servicios AWS, se decidió utilizar Cognito como reemplazo del microservicio de seguridad y triggers Lambda para agregar información a los tokens.

Lo que se implementó

La solución final implementada se puede ver en el siguiente gráfico:

Aquí se ve que la solución final es una combinación de las soluciones para confianza y alcance de tokens, los puntos interesantes son:

  1. Se utilizó el AWS API Gateway como validador de conexiones a las APIs públicas utilizando su feature de JWT Authorizer, aquí se hace un primer filtrado en base a los scopes del token.
  2. Antes de crearse el token en Cognito, se dispara el pre-token generation trigger, el cual llama a una función Lambda que en base a los datos del usuario, consulta una base de datos de seguridad y envía como respuesta los scopes del usuario, este proceso requiere que Cognito sea configurado con advanced security features para poder modificar los scopes de los access-tokens.
  3. Si el scope es el correcto, el API Gateway lo conecta con el backend, donde su token es evaluado para determinar si es válido (mediante la clave pública del JWT).
  4. Luego realiza las operaciones solicitadas por la petición, aunque teniendo en cuenta los valores de los campos "products" y "backends".

Con este esquema se logró eliminar completamente el cuello de botella generado en los momentos de tráfico muy alto, ya que los procesos de autenticación y autorización ya no están centralizados en un microservicio, sino que se dividen en Cognito, funciones Lambda y el propio backend.

Conclusión

Llevar a cabo este proyecto nos dejó estos aprendizajes:

  1. La documentación de AWS si bien es muy completa, a veces simplemente no es de ayuda sin antes pasarla por un tamiz de investigación y experiencias de otros colegas que ya implementaron soluciones parecidas.
  2. Implementar este cambio sólo fue posible gracias a la total confianza del cliente, quien compartió todo el código fuente y mucha información interna sobre el funcionamiento de cada uno de los microservicios y su interacción dentro del marco de seguridad.
  3. Además del código fuente, fue necesario montar un laboratorio complejo con una gran cantidad de microservicios interconectados y de rápida evolución para las pruebas de cada cambio, tanto de infraestructura como de código.

Como siempre les agradecemos la lectura y esperamos que tanto la solución que implementamos como el proceso de diseño le sea útil.