Autentificación y autorización de usuarios con sesión compartida en una arquitectura de microservicios

Carlos Compains
9 min readNov 8, 2018

--

Una de las cuestiones que siempre hay que resolver en cualquier proyecto web es la gestión de la autentificación de los usuarios. En el caso de estar usando una solución basada en microservicios el problema se vuelve mas complejo dada la naturaleza distribuida de las tareas.

Sin cubrir todas las opciones ni entrar en demasiado detalle, se puede resolver este problema de las siguientes maneras:

  • JWT. El servicio encargado de la autentificación valida las credenciales generando un token auto-firmado que contiene la información que consideramos relevante del usuario. Cada servicio recibe ese token, valida la firma con un secreto compartido y evaluando el payload del token decide como proceder.
  • SSO (single sign on). Se utiliza un servicio externo para autenticar al usuario.
  • Sesión compartida. La sesión que normalmente se almacena de forma local, se guarda en un almacenamiento accesible por todos los microservicios, cada uno de los cuales leerá esa sesión ver si el usuario esta autentificado y en su caso decidir si dispone de los permisos necesarios.

En este caso me voy a centrar en una aplicación basada en Symfony, que utiliza Redis para almacenar la sesión de manera que resulta accesible para cada uno de los microservicios. Voy a aplicar el resultado de varias pruebas que he realizado en otros proyectos, con lo que puede haber algún error o problema de seguridad. ¡Los comentarios son bienvenidos!

Planteamiento

La aplicación estará compuesta por dos microservicios basados en Symfony 4. Todos ellos tendrán acceso a una instancia de Redis, la cual contendrá la sesión del usuario.

Para gestionar el enrutado de las peticiones utilizaré Varnish estableciendo reglas a nivel url, y en funcion de ellas direccionando la petición uno u otro backend. En este caso Varnish sólo actuará como un balanceador de capa 7, desactivando las funcionalidades de proxy cache.

Todo el “proyecto” se gestionará con Docker, mas concretamente con Docker-compose, para crear tanto los contenedores como la red por la que se comunican los contenedores.

Creando el entorno

Necesitaremos ejecutar varios contenedores Docker que tengan visibilidad entre sí. Ademas necesitamos que los dos contenedores con los microservicios contengan el código de nuestras aplicaciones. Dado que la intención de este proyecto es la de probar código más que ejecutarlo en producción utilizaré un volumen en cada uno de ellos, para facilitar poder actualizar el código.

El contenedor que va a ejecutar Varnish estará basada en la imagen million12/varnish.

La configuración de docker-compose.yaml es la siguiente:

version: '3'
services
:
mysql:
image: mysql:5.7
ports:
- 33306:3306
command: --sql_mode=""
expose:
- 3306
environment:
MYSQL_ROOT_PASSWORD: password
volumes:
- ./mysql_data:/var/lib/mysql
restart: always
redis:
image: redis
ports:
- 36379:6379
expose:
- 6379
volumes:
- ./redis_data:/data
restart: always
varnish:
image: million12/varnish
ports:
- 90:80
volumes:
- ../varnish:/etc/varnish
environment:
VCL_CONFIG: /etc/varnish/site.vcl
VARNISHD_PARAMS: -p default_ttl=3600 -p default_grace=3600
command: /start.sh
restart: always
app1:
build:
dockerfile: Dockerfile
context: .
expose:
- 80
ports:
- 8081:80
volumes:
- ../app1:/var/www
- ../apache:/etc/apache2/sites-available
app2:
build:
dockerfile: Dockerfile
context: .
expose:
- 80
volumes:
- ../app2:/var/www
- ../apache:/etc/apache2/sites-available

El fichero Dockerfile que se usa para construir la imagen agrega algunas dependencias a la imagen oficial de php y cambia el document root de apache para que apunte a la carpeta public de cada proyecto.

FROM php:7.2.11-apache

ENV APACHE_DOCUMENT_ROOT /var/www/public

RUN docker-php-ext-install pdo pdo_mysql

RUN pecl install -o -f redis \
&& rm -rf /tmp/pear \
&& docker-php-ext-enable redis

WORKDIR /var/www

La configuración de Varnish será la siguiente:

vcl 4.0;backend app1 {
.host = "app1";
.port = "80";
.max_connections = 300;

.first_byte_timeout = 300s;
.connect_timeout = 5s;
.between_bytes_timeout = 2s;
}

backend app2 {
.host = "app2";
.port = "80";
.max_connections = 300;
.first_byte_timeout = 300s;
.connect_timeout = 5s;
.between_bytes_timeout = 2s;
}
sub vcl_recv {
set req.backend_hint = app1;
if(req.url ~ "^\/app2-test-user"){
set req.backend_hint = app2;
}

return (pass);
}

Esta configuración se limita a declarar los dos backends usando los nombres de servicio definidos en docker-compose.yaml y a distribuir el tráfico de una manera muy simple.

Por defecto todo el tráfico va a App1, que es el microservicio que gestiona la autentificación de usuarios. Si la ruta solicitada coincide con /app2-test-user se envía a App2, que es el microservicio que entregará contenido según los privilegios del usuario.

Creando las aplicaciones

Ahora necesitamos crear dos aplicaciones con Symfony , para ello seguimos las instrucciones de la documentación para crear una aplicación creando las dos.

App1 será la encargada de validar al usuario y establecer la sesión. Ademas tendrá acceso a mysql donde estarán almacenados los usuarios.

App2 consultará la sesión para reconstruir el contexto de seguridad y entregar o no contenido según las credenciales presentes en la misma.

Configurando la sesión

Necesitamos decirle a Symfony que almacene los datos relativos a la sesión en Redis, usando para ello la extensión Redis disponible en PECL. De instalarla se encarga Docker durante la construcción de la imagen según el fichero Dockerfile definido anteriormente.

Para comenzar necesitamos configurar un cliente Redis en el fichero services.yml de ambas aplicaciones:

Redis:
class: Redis
calls:
- method: connect
arguments:
- 'redis'
- 6379
Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler:
arguments:
- '@Redis'

Aquí estamos declarando un servicio de nombre Redis que instancia una clase del tipo Redis y llama al método connect pasándole los parámetros necesarios, en este caso el host de Redis, que se corresponde con el nombre del servicio que hemos establecido en el fichero docker-compose.yml y el puerto, que en este caso es el puerto por defecto.

En este caso, al utilizar docker-compose todos los puertos que se nombran bajo el apartado expose en docker-compose.yml son accesibles al resto de contenedores en la misma red.

Si el contenedor estuviese en otro servidor o en otra red, tendríamos que exponer el puerto usando ports y en tal caso sería necesario asegurar de alguna manera esa conexión, ya sea mediante el propio mecanismo de Redis o bien restringiendo las ip’s que pueden conectarse.

En el segundo bloque creamos un servicio RedisSessionHandler. Esta clase espera recibir en el constructor un cliente Redis para crear el servicio que se encargará de leer los datos para meterlos en la sesión de la aplicación

Por último tenemos que decirle a Symfony que utilize este servicio que acabamos de crear. Para ello cambiamos esto en config/packages/framework.yaml

session:
handler_id: ~

por

session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler

Desde este momento, cada vez que Symfony vaya a realizar alguna operación sobre la sesión, leerá o escribirá en nuestra instancia de Redis la cual es accesible por ambas aplicaciones.

Añadiendo seguridad

Llegados a este punto necesitamos configurar los aspectos relativos a la seguridad de Symfony. Es decir tenemos que instalar el bundle correspondiente y sus dependencias.

En este ejemplo concreto App1, tiene que tener acceso a MySql, ya que es ahí donde estarán los usuarios almacenados. Por ello esta aplicación necesitará los siguientes bundles:

  • symfony/security-bundle
  • symfony/orm-pack para tener disponible Doctrine.

Para ahorrarnos algo de trabajo tambien se puede instalar symfony/maker-bundle. Todos estos pasos están explicados en la documentación oficial de Symfony.

En el caso de App2, solo necesitamos symfony/security-bundle, ya que vamos a dar por válida la información presente en la sesión y no se va a comprobar nada.

Además de instalar en bundle, necesitamos crear una clase User en el mismo namespace que en App1. Esto es necesario ya que cuando Php lea la sesión llamará al método unserialize el cual va a entregarnos una instancia de la clase almacenada en la sesión con serialize.

Es decir que en App2, necesitamos una clase user en App\Entity, a pesar de que en App2 User no va a ser una entidad persitida.

Por último, en App2 necesitamos crear una clase que implemente UserProviderInterface, de manera que la aplicación sepa cómo obtener las credenciales de los usuarios. En este caso esta clase será muy simple, ya que se va a limitar a entregar la clase User tal cual la recibe desde la session.

<?php
namespace
App\Security;


use App\Entity\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class SharedSessionUserProvider implements UserProviderInterface {
public function loadUserByUsername( $username ) {
//En los microservicios que no gestionan usuarios la única fuente de información es la sesión compartida.
return null;
}

public function refreshUser( UserInterface $user ) {
// Por defecto los microservicios no tienen información del usuario que deban actualizar, de eso se encarga el
// microservicio de gestión de usuarios
return $user;
}

public function supportsClass( $class ) {
return User::class == $class;
}

}

Finalmente en config/packages/security.yaml tenemos que actualizar la configuración para decirle a Symfony que clase va a contener a los usuario y cual es el proveedor:

security:
encoders:
App\Entity\User:
algorithm: argon2i

providers:
app_user_provider:
id: App\Security\SharedSessionUserProvider

Para crear el formulario de login en App1, podemos seguir la documentación oficial. En mi caso he tenido que editar el twig creado para añadir una barra baja al principio de los nombres de los inputs para las credenciales para que funcione correctamente.

Creando el contenido

En App1 aprovechando el controlador que nos crea el comando que genera el formulario de login, crearemos una segunda ruta para validar que el usuario se loguea correctamente en App1. Esto se puede obviar si tenemos el profiler, pero así tampoco dependemos exclusivamente de los bundles.

/**
*
@Route("/user-profile", name="user_profile")
*/
public function profile(){
$user = $this->getUser();

return new Response('<body><h1>Hola '.$user->getUsername().' estas en App1</h1></body>');
}

Como se puede ver es una función muy simple que lo único que hace es recuperar al usuario desde la sesión y mostrar el username.

En App2 crearemos un controlador que nos mostrará el nombre del usuario si esta logueado o dará un error si no es el caso.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AuthenticationException;


class SecuredController extends AbstractController {
/**
*
@Route("/app2-test-user", name="test_user")
*/
public function testSecurityAction( Request $request ) {
$user = $this->getUser();

return new Response( '<body><h1>Hola ' . $user->getUsername() . ' estas en App2</h1></body>' );
}
}

Ademas necesitaremos crear las rutas /logout y /login_check según la documentación de Symfony.

En resumen hemos creado las siguientes rutas:

  • /login muestra el formulario para ingresar en el sistema. Está en App1.
  • /user-profile Está en App1 y muestra el username almacenado en la sesión.
  • /app2-test-user Está en App2 y muestra el username almacenado en las sesión.
  • /logout para cerrar la sesión.

Añadiendo las reglas de seguridad

En App2 actualizamos el fichero config/packages/security.yml con la siguiente directiva

access_control:
- { path: ^/app2-test-user, roles: ROLE_ADMIN }

De manera que le decimos a symfony que cualquiera que visite esa url ha de tener como mínimo rol de administrador (ROLE_ADMIN).

Creando el usuario

Para crear al usuario, he seguido un proceso demasiado manual, que posiblemente se podría resolver con una fixture. En vez de eso siguiendo la idea de no instalar bundles si no es estrictamente necesario lo he insertado directamente en la base de datos con la siguiente sentencia:

INSERT INTO `user` (`id`, `email`, `password`)
VALUES
(1, 'admin@test.com','$argon2i$v=19$m=1024,t=2,p=2$TWJpUkV4VEJaOUtxU1BQbg$dGjMHDzlybtrTmbk5zQJfeGvDZLVtejf+TpB+2YHVCQ');
UPDATE user set roles='["ROLE_ADMIN"]' where id = 1;

El password desencriptado es 1234.

Probando la solución

Llegados a este punto, lanzamos los contenedores con docker-compose:

docker-compose -f docker/docker-compose.yaml up -d

En mi caso la configuración de docker esta en la carpeta docker, por lo que he de especificar la ruta al fichero en cada comando.

Después de esto, accedemos a http://127.0.0.1:90/login y deberíamos ver el formulario de login.

Después de loguearnos con admin@test.com y 1234, podemos navegar hasta http://127.0.0.1:90/user-profile y deberíamos de ver algo como esto:

/user-profile con usuario logueado

Ahora si vamos a http://127.0.0.1:90/app2-test-user deberíamos ver

/app2-test-user en app2 con usuario logueado

Finalmente podemos ir a http://127.0.0.1:90/logout para cerrar la sesión y volver a visitar las urls anteriores para confirmar que no se muestra contenido.

Intento de acceso de usuario sin loguear

Conclusiones

Este método de gestión de la autentificación, lógicamente tiene sus problemas.

Por ejemplo, si nuestra aplicación utiliza más de un lenguaje (Java, Python…), leer la sesión puede ser un poco mas complicado.

El hecho de que los datos almacenados en Redis sean compartidos entre distintos microservicios, se puede ver como una forma de acoplamiento entre los mismos. Si bien hasta cierto punto es cierto, creo que es asumible a cambio de obtener una solución relativamente simple.

Por otro lado, si se quiere bloquear a un usuario, la hacerlo e invalidar la sesión, automáticamente dejara de tener acceso en todos los microservicios sin tener que esperar a que la sesión caduque, como por ejemplo puede suceder con JWT.

He dejado en github todo el código que he utilizado para escribir este post.

--

--

No responses yet