User authentication and authorization with shared session in a micro-services architecture
One of the usual questions that must be solved on any web project is the user auth management. When using a micoservices based solution this task becomes more complex due the distributed workload.
Not trying to cover all the options and neither going too deep, this kind of requirements can be solved in these ways:
- JWT. The service that takes care of authentication checks the user credentials and generates a self-signed token that contains the relevant user information. Each service receives this token within each request, it checks the signature against a shared secret and depending on token payload decides what to do.
- SSO (Single sign on). An external service is used to authenticate the user.
- Shared session. The session data that usually is stored locally is stored in some safe shared storage that can be read from all the microservices.
In this case I’ll be focused on a Symfony based application that uses Redis as shared session storage. I’ll be using some tests results and code from some projects, so there can be bugs or some safety issues. Comments are welcome!
Planning
This application will consist in two Symfony 4 based microservices. All of them will have access granted to a Redis instance that will store the user session.
To route request I’ll be using Varnish setting rules at url level, and depending on that forwarding the request to the correct backend. In this case Varnish will act as a load balancer, not using it’s proxy cache capabilities.
The whole “project” will run on Docker, using docker-compose to create containers and network.
Building the environment
OI need two containers in the same network in order to them be visible to each other. As this is a testing/dev project I’ll be using volumes to contain code instead of putting it directly into the Docker image.
Another container will run Varnish using million12/varnish.
The docker-compose.yaml file is like this:
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
The Dockerfile file that will be used to build the image will add some dependencies to the offilcia php image and also change de document root.
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
The Varnish config will be:
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);
}
This config just declares the two backends using the service names defined in the docker-composer.yaml file, and sets a single routing rule.
By default all the traffic is forwarded to App1, that is the microservice which manages the user auth. If requested url is /app2-test-user then it’s forwarded to App2, that will build an answer depending on user privileges.
Building the apps
Now I need tell Symfony to store user session data in Redis. I’ll use the PECL Redis extension. The install process will be done by Docker during the image build process.
First of all a new Redis client will be configured in the services.yml file:
Redis:
class: Redis
calls:
- method: connect
arguments:
- 'redis'
- 6379
Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler:
arguments:
- '@Redis'
A new Symfony service called Redis that instances a Redis class is created. Just after creation the “connect” method is called with needed parameters to connect to “redis” host, that is the service name defined in docker-compose.yaml.
All the container ports are available for all the containers defined in the docker-compose.yaml file, as the are in the same Docker network.
If the container were in a different network or host machine, it would be needed to expose the port using a “ports” block, and it would be recommended to put some security by using a Redis secret or using some IP whitelist to prevent unwanted connections.
The sencond block in services.yaml a RedisSessionHandler is created. This class expects receive a Redis client as a constructor argument and will be in charge of reading data and make them accesible to the application session.
Lastly I tell symfony to use this recently created service. For that I change this
session:
handler_id: ~
with this
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
in the config/packages/framework.yaml file.
Since this moment each time that Symfony performs any operation against the user session the data will be read/written in the Redis instance.
Adding security
At this point I have to focus on the Symfony security configuration.
In this example App1 must have access to MySql, because users are stored there. I need to install this two bundles:
- symfony/security-bundle
- symfony/orm-pack para tener disponible Doctrine.
To save sometime I can also install symfony/maker-bundle. All the steps are explained in the Symfony official docs.
App2 only needs symfony/security-bundle as it is going to assume that the session data is correct and is not going to check anything. I also need to create a User class in the same namespace as App1. This is needed to allow Php unserialize the session data to the same class as in App2.
Finally, in App2 I need a class that implements UserProviderInterface to tell Symfony how to get user credentials from session data. This one will be very simple as it gives de data as it received from the 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;
}
}
Finally in config/packages/security.yaml I configure the user Class an user provider:
security:
encoders:
App\Entity\User:
algorithm: argon2i
providers:
app_user_provider:
id: App\Security\SharedSessionUserProvider
To create the login form in App1 I follow the official docs. In this case I jad to edit the created twig to prepend a lodash in the credentials form inputs name.
Making content
In App using the same controller that is created for the login form I have created another route to check if the user is properly logged in.
/**
* @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>');
}
As can be seen is a simple method that retrieves the user from the session and shows the username.
In App2 I create a controller that shows the user name if there is a logged in user or shows an error otherwise.
<?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>' );
}
}
Also a /logout and /login_check routes are needed according to Symfony docs.
In summary this routes where created:
- /login shows login form. Belongs to App1.
- /user-profile Belongs to App1 and shows the username stored in session.
- /app2-test-user Belongs to App2 and shows the username stored in session.
- /logout logs out the user.
Adding security rules
In App2 the config/packages/security.yml needs to be updated with:
access_control:
- { path: ^/app2-test-user, roles: ROLE_ADMIN }
So I have instructed Symfony to require at least ROLE_ADMIN to any that access to /app2-test-user url.
Creating the user
To do so i have follow a full-manual process, doing it directly in database:
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;
the plain text password is 1234.
Testing the solution
Time to launch the containers
docker-compose -f docker/docker-compose.yaml up -d
In my case the docker config is not in the root so I need to specify the docker-compose.yaml route in the command.
Once Docker has finished I go to http://127.0.0.1:90/login and I see the login form.
After login in (admin@test.com, 1234) I can browse http://127.0.0.1:90/user-profile and I see this:
Now I go to http://127.0.0.1:90/app2-test-user and see this:
And finally I logout (http://127.0.0.1:90/logout) and when I visit again the previous urls I get
Conclusions
Of course there are some tradeoffs on using this method.
For instance if our application uses more than one language, the we need to solve somehow the user data serialization/deserialization process.
Using Redis a shared storage can be seen as some kind of coupling between microservices, but I think that is reasonable as it is a quite simple solution.
If blocking a user is needed, the session can be deleted in Redis making that user blocked inmediately. For instance using JWT the user will have access until the JWT expires.
Al the code is available in github.
This post is the english version of this one