Primeros pasos con Apisix en Kubernetes: JWT Auth

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

INFO

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

INFO

El código de estos post los puedes encontrar en el repo https://github.com/jagedn/apisix-example

Recapitulando

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.

Arquitectura

Así pues nuestra arquitectura actual es algo parecido a

Diagram

Mientras que en una arquitectura microservicios con un ApiGw sería como:

Diagram

Security Service

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

WARNING

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"
}

Securizando el API

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

Identificar el usuario

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
INFO

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:

username=jorgepayaso
$ 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
username=pepitopalotes
$ 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

Este texto ha sido escrito por un humano

This post was written by a human

2019 - 2024 | Mixed with Bootstrap | Baked with JBake v2.6.7