En este segundo post sobre APISIX vamos a securizar el acceso a los endpoints de un microservicio que se encuentra tras el api-gateway usando JWT
Este post es la continuación de apisix-1.html y da por supuesto que ya has instalado y desplegado ciertos artefactos en tu cluster. Si no es así te recomiendo que en primer lugar lo completes
El código de estos post los puedes encontrar en el repo https://github.com/jagedn/apisix-example
En el post anterior vimos cómo instalar un cluster en nuestro local con k3d
y
cómo desplegar en este cluster APISIX. Así mismo creamos un servicio whoami
y unas rutas para acceder a él a través de nuestro apigateway APISIX
En este post vamos a ver cómo podemos seguir con la migración a microservicios de nuestro monolito en concreto aquellos endpoints securizados con JWT
Es decir, probablemente en nuestro monolito tengamos implementado un sistema de autentificación de usuarios que genere token JWT y que tengamos multitud
de endpoints de nuestra API que lo validen mirando la cabecera authorization
El "problema" a resolver es conseguir que nuestro api gateway valide esos JWT antes de enrutar las peticiones al microservicio de tal forma que estos no tengan que preocuparse de esta validación.
Así mismo otro problema a resolver es cómo les hacemos llegar a estos usuarios qué usuario es el que está "detrás" de esa petición.
Así pues nuestra arquitectura actual es algo parecido a
Mientras que en una arquitectura microservicios con un ApiGw sería como:
Para este post he creado un servicio Micronaut de ejemplo siguiendo el tutorial https://guides.micronaut.io/latest/micronaut-security-jwt-gradle-java.html
Este servicio "validarará" cualquier login cuya password sea "password" y usará
el username como sub
en el JWT generado. Es decir, cualquier login con un
usuario cualquier y una password=password devolverá un JWT donde el sub
será el
usuario que indiquemos. De esta forma podremos simular diferentes logins y comprobar
que nuestra solución es capaz de identificarlos
el plugin de Apisix valida que el JWT incluya un campo key
por lo que
el tutorial de micronaut no es 100% valido.
El código de nuestro servicio de autentificación lo puedes encontrar en https://github.com/jagedn/apisix-example/tree/main/micronaut-security-jwt-gradle-java
Una vez compilado y generada la imagen la he subido a mi docker.io como jagedn/monolito
(ya, el nombre no es muy acertado)
Una vez publicada la imagen la desplegamos en nuestro cluster
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service-deployment
spec:
replicas: 1
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: jagedn/monolito
imagePullPolicy: Always
ports:
- containerPort: 8080
protocol: TCP
name: http
env:
- name: MICRONAUT_SECURITY_TOKEN_JWT_SIGNATURES_SECRET_GENERATOR_SECRET
value: MY_APPLICATION_JWT_SECRET_KEY_DUMMY (1)
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
ports:
- name: http
targetPort: http
port: 8080
selector:
app: user-service
1 | MY_APPLICATION_JWT_SECRET_KEY_DUMMY es la clave a usar para generar JWT firmados |
y creamos una ruta que redirija las peticiones de login a él
si nuestro monolito fuera el que está resolviendo las autentificaciones y generando los JWT, NO desplegaríamos un servicio nuevo sino que usaríamos la ruta que ya tengamos creada |
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: apisix-login-route
namespace: default
spec:
http:
- name: route-login
match:
paths:
- /login
backends:
- serviceName: user-service
servicePort: http
Ahora podemos probar a hacer un login con un usuario cualquiera:
http localhost:8881/login username=jorgepayaso password=password
{
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb3JnZXBheWFzbyIsIm5iZiI6MTY4MDg4MjMwMCwicm9sZXMiOltdLCJpc3MiOiJtaWNyb25hdXRndWlkZSIsImV4cCI6MTY4MDg4NTkwMCwiaWF0IjoxNjgwODgyMzAwLCJrZXkiOiJtYWluIn0.ALYLxJFNPYE-jmalF0cBGjTBse7DZFwzfd5DMEN1JLs",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.NjBlNTk3N2EtZjIyYi00MjFjLTk2MjktOGM5NzdjZTZkODE0.jysMcQJDEDHDTypmSOBnFA4YpkmyS-o3eqvmv_--c3U",
"token_type": "Bearer",
"username": "jorgepayaso"
}
si validas ese access_token
en jwt.io por ejemplo verás que el payload es:
{
"sub": "jorgepayaso",
"nbf": 1680882300,
"roles": [],
"iss": "micronautguide",
"exp": 1680885900,
"iat": 1680882300,
"key": "main"
}
Una vez que tenemos un servicio y una ruta para obtener tokens vamos a "securizar" nuestro API haciendo que el APISIX valide que todas las peticiones incluyen una cabecera Authorization con un JWT válido (y así no tener que implementarlo en TODOS los servicios):
Para poder comparar con el post anterior vamos a configurar una nueva ruta v2
que estará securizada, mientras que la del post anterior seguirá en "abierto"
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: apisix-whoami-route
namespace: default
spec:
http:
- name: route-1
match:
paths:
- /v2/*
backends:
- serviceName: whoami-service
servicePort: http
authentication:
enable: true
type: jwtAuth
Como puedes ver con este objeto kubernetes le estamos diciendo a Apisix que
las peticiones a /v2/*
se envíen a nuestro servicio whoami
(como las de v1,
simplemente es por no crear otros servicios) pero que primero valide que
están autenticadas usando jwtAuth
Tras aplicar este objeto veremos que toda peticion a v2 sin un JWT es rechazada por APISIX sin llegar a ejecutarse la llamada al servicio.
Ahora bien, APISIX puede comprobar que viene la cabecera e incluso parsear el
JWT pero no tiene forma de validar que ha sido nuestro servicio user-service
quien lo ha creado y que no es un intento de colarnos un JWT falso.
Para eso configuraremos el plugin JWTAuth indicando la clave a usar para verificarlo
En lugar de usar claves y passwords en claro como estoy haciendo en estos artículos, lo suyo es usar algún servicio de Secrets que ofrezca el cluster (y que soporte Apisix) |
apiVersion: apisix.apache.org/v2
kind: ApisixConsumer
metadata:
name: jwt-consumer
spec:
authParameter:
jwtAuth:
value:
key: main (1)
secret: MY_APPLICATION_JWT_SECRET_KEY_DUMMY (2)
1 | key=main está "a pelo" en customer service. No sé muy bien porqué APISIX lo necesita |
2 | MY_APPLICATION_JWT_SECRET_KEY_DUMMY es la clave que hemos usado en user-service para firmar los JWT |
Un ApisixConsumer representa a un cliente y APISIX maneja diferentes tipos de consumers. Por ejemplo basicAuth sirve para identificar a usuarios concretos mediante user+password, etc
Ahora ya podemos usar el JWT contra v2
$ TOKEN=$(http localhost:8881/login username=jorgepayaso password=password | jq -r .access_token)
$ http localhost:8881/v2/soy/yo Authorization:"Bearer $TOKEN"
HTTP/1.1 200 OK
GET /v2/soy/yo HTTP/1.1
Host: localhost:8881
Si has seguido hasta aquí la explicación (y si yo he conseguido explicarme), tenemos implementado un api gateway que protege las rutas que digamos mediante una validación JWT generado por nosotros.
Sin embargo uno de los problemas comunes en una arquitectura microservicios es que la petición, al ser enrutada al servicio en cuestión, necesita en la mayoría de los casos ser "personalizada" con algún tipo de identificación del usuario que la está realizando.
En un "monolito" lo normal es que deleguemos en el framework en el que está implementado la autorización y la autentificación de tal forma que el framework ante cada petición comprueba la firma del JWT y extrayendo el sub (por ejemplo) del token puede ir a la base de datos y obtener toda la info del usuario en cuestión.
En microservicios lo "normal" es adjuntar en una cabecera especial el ID del usuario y dejar a cada servicio que lidie con ello. En la mayoría de los casos este ID es suficiente para que el servicio pueda realizar su trabajo. En otros usará este ID para invocar al servicio de usuarios y que le devuelva más información como la fecha de creación, si está al orden de pago, …
En nuestro ejemplo lo que vamos a hacer es que APISIX, una vez validado el JWT,
parsee el payload y nos incluya el sub
en una cabecera X-USER-ID que enviará
junto con la petición al microservicio
Para ello simplemente "enriqueceremos" la ruta protegida v2
y añadiremos unas
líneas de código en el lenguaje Lua que es el que usa Apisix:
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: apisix-whoami-route
namespace: default
spec:
http:
- name: route-1
match:
paths:
- /v2/*
backends:
- serviceName: whoami-service
servicePort: http
authentication:
enable: true
type: jwtAuth
plugins:
- name: "serverless-post-function"
enable: true
config:
functions:
- |
-- probably this function can be placed in another file
function parseJWTPayload(conf, ctx)
-- Import neccessary libraries
local core = require("apisix.core")
local jwt = require("resty.jwt")
-- Parse jwt
local sub_str = string.sub
local jwt_token = core.request.header(ctx, "authorization")
local prefix = sub_str(jwt_token, 1, 7)
if prefix == 'Bearer ' or prefix == 'bearer ' then
jwt_token = sub_str(jwt_token, 8)
end
local jwt_obj = jwt:load_jwt(jwt_token)
-- Set x-user-id header
core.request.set_header(ctx, "X-USER-ID", jwt_obj.payload.sub)
end
-- this is the function to call
return function(conf, ctx)
return parseJWTPayload(conf, ctx)
end
Todavía tengo que investigar cómo/dónde ubicar la funcion parseJWTPayload para poderla reutilizar en otras rutas
Simplemente lo que hacemos es decirle a Apisix que una vez ejecutado el plugin
jwtAuth nos ejecute otro de sus plugins, serverless-post-function
, el cual
puede acceder a los datos de la petición y modificarlos.
En nuestro caso por ejemplo "enriquecemos" el request añadiendo una nueva cabecera que el microservicio puede usar:
core.request.set_header(ctx, "X-USER-ID", jwt_obj.payload.sub)
Una vez aplicado en el cluster los cambios podemos observar que nuestro whoami servicio recibe una cabecera X-USER-ID diferente segun el usuario con el que generemos el token:
$ TOKEN=$(http localhost:8881/login username=jorgepayaso password=password | jq -r .access_token)
$ http localhost:8881/v2/soy/yo Authorization:"Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Length: 729
Content-Type: text/plain; charset=utf-8
Date: Fri, 07 Apr 2023 16:21:22 GMT
Server: APISIX/3.2.0
Hostname: whoami-deployment-5d4fc76b57-2h8pl
IP: 127.0.0.1
IP: ::1
IP: 10.42.0.195
IP: fe80::d4f6:4dff:fe1f:75af
RemoteAddr: 10.42.0.191:59322
GET /v2/soy/yo HTTP/1.1
Host: localhost:8881
User-Agent: HTTPie/2.6.0
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb3JnZXBheWFzbyIsIm5iZiI6MTY4MDg4NDQ4MCwicm9sZXMiOltdLCJpc3MiOiJtaWNyb25hdXRndWlkZSIsImV4cCI6MTY4MDg4ODA4MCwiaWF0IjoxNjgwODg0NDgwLCJrZXkiOiJtYWluIn0.o-yU9BSYyw314oPN_KZFLxgmqXOT2IQ9smDlfmC28Ss
X-Forwarded-For: 10.42.0.1, 10.42.0.187
X-Forwarded-Host: localhost:8881
X-Forwarded-Port: 8881
X-Forwarded-Proto: http
X-Forwarded-Server: traefik-7cd4fcff68-9ksw7
X-Real-Ip: 10.42.0.187
X-User-Id: jorgepayaso
$ TOKEN=$(http localhost:8881/login username=pepitopalotes password=password | jq -r .access_token)
$ http localhost:8881/v2/soy/yo Authorization:"Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Length: 734
Content-Type: text/plain; charset=utf-8
Date: Fri, 07 Apr 2023 16:22:06 GMT
Server: APISIX/3.2.0
Hostname: whoami-deployment-5d4fc76b57-2h8pl
IP: 127.0.0.1
IP: ::1
IP: 10.42.0.195
IP: fe80::d4f6:4dff:fe1f:75af
RemoteAddr: 10.42.0.191:59322
GET /v2/soy/yo HTTP/1.1
Host: localhost:8881
User-Agent: HTTPie/2.6.0
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwZXBpdG9wYWxvdGVzIiwibmJmIjoxNjgwODg0NTI0LCJyb2xlcyI6W10sImlzcyI6Im1pY3JvbmF1dGd1aWRlIiwiZXhwIjoxNjgwODg4MTI0LCJpYXQiOjE2ODA4ODQ1MjQsImtleSI6Im1haW4ifQ.-uFlClK5kmjMYWK4F-jZFwRTwvM8vXQEuKl5OzkhRdY
X-Forwarded-For: 10.42.0.1, 10.42.0.187
X-Forwarded-Host: localhost:8881
X-Forwarded-Port: 8881
X-Forwarded-Proto: http
X-Forwarded-Server: traefik-7cd4fcff68-9ksw7
X-Real-Ip: 10.42.0.187
X-User-Id: pepitopalotes
2019 - 2024 | Mixed with Bootstrap | Baked with JBake v2.6.7