Experimentos en la Nube: Construyendo Autorización de API Flexible con Autorizadores Lambda

El código fuente para este artículo se puede encontrar aquí.

¡Bienvenido a otro experimento en la nube! La idea detrás de estos tutoriales prácticos es proporcionar experiencia práctica construyendo soluciones cloud-native de diferentes tamaños usando servicios de AWS y CDK. Nos enfocaremos en desarrollar experiencia en Infrastructure as Code, servicios de AWS y arquitectura en la nube mientras entendemos tanto el “cómo” como el “por qué” detrás de nuestras decisiones.

Asegurando el Acceso a una API

Esta vez, tienes la tarea de asegurar el acceso a una API de una manera flexible que pueda acomodar futuros cambios de requisitos. Tu empresa está ejecutando una API importante que actualmente es accesible para cualquiera que conozca su URL, y necesitas limitar quién puede acceder a estos datos. ¿El único problema? El producto es relativamente nuevo, y los mecanismos usados para verificar la identidad del usuario son propensos a cambiar. Necesitas limitar el acceso de una manera que sea fácil de cambiar cuando se requieran formas más elaboradas de autenticación.

La Configuración Actual

Primero, emulemos una API ya existente creando un despliegue simple de API Gateway respaldado por Lambda:

API Inicial Simple

Esta API, crítica para el éxito de la empresa, devuelve datos sobre el lote original de Pokémon—solo los de la primera generación (de alguna manera siempre logro colarlos en los tutoriales, debo dejar de hacer eso). Construiremos esta API usando CDK, con cada solicitud respaldada por una función Lambda. La API soporta solo dos URLs:

  • /poke_info: Devuelve datos para cada entidad individual en el sistema.
  • /poke_info/{dex_number}: Devuelve datos sobre una entidad específica basándose en su número de Pokédex (un dex_number: 1 devolvería datos para Bulbasaur). Si el dex_number no corresponde a ninguna entidad, devuelve una respuesta vacía.

Ahora construyamos la API. Primero, necesitamos la configuración regular del proyecto a la que nos hemos acostumbrado.

Crea una carpeta vacía (nombré la mía APIGatewayWithLambdaAuthorization) y ejecuta cdk init app --language typescript dentro de ella.

Este siguiente cambio es opcional, pero lo primero que hago después de crear un nuevo proyecto CDK es dirigirme a la carpeta bin y renombrar el archivo app a main.ts. Luego abro el archivo cdk.json y edito la configuración app:

{
  "app": "npx ts-node --prefer-ts-exts bin/main.ts",
  "watch": {
    ...
  }
}

Ahora tu proyecto reconocerá main.ts como el archivo de aplicación principal. No tienes que hacer esto—solo me gusta tener un archivo llamado main sirviendo como archivo principal de la app.

Creando las Funciones Lambda

Crea una carpeta llamada lambdas en el nivel raíz del proyecto (junto a bin y lib), y dentro de ella crea otra carpeta llamada api, con dos archivos: poke.py y poke_list.py.

Para lambdas/api/poke.py:

import logging
import json

logger = logging.getLogger()
logger.setLevel("INFO")

with open('pokedata.json', 'r') as file:
    dex_data = json.load(file)


def handler(event, context):
    dex_number = event.get('pathParameters', {}).get('dex_number', '')

    logger.info(f"Serving request for pk#{dex_number}")
    return {'statusCode': 200,
            'headers': {'content-type': 'application/json'},
            'body': json.dumps(dex_data.get(dex_number, {}))}

Para lambdas/api/poke_list.py:

import logging
import json

logger = logging.getLogger()
logger.setLevel("INFO")

with open('pokedata.json', 'r') as file:
    dex_data = json.load(file)


def handler(event, context):
    logger.info("Serving request for all pk")
    return {'statusCode': 200,
            'headers': {'content-type': 'application/json'},
            'body': json.dumps(dex_data)}

Ambas son funciones dummy que extraen datos de un archivo JSON local y los usan para servir solicitudes de API Gateway. En la vida real, tus funciones consultarían un almacén de datos para datos reales en vivo, pero lo mantengo simple para que podamos enfocarnos en la autenticación.

Construyendo el Stack

Ahora escribiremos código CDK para crear la solución descrita anteriormente:

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { aws_lambda as lambda } from "aws-cdk-lib";
import { aws_apigatewayv2 as gateway } from "aws-cdk-lib";
import { aws_apigatewayv2_integrations as api_integrations } from "aws-cdk-lib";


export class ApiGatewayWithLambdaAuthorizationStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const pokeDataAPI = new gateway.HttpApi(this, "pokeDataAPI");

    const singlePokeFunction = new lambda.Function(this, "spLambda", {
      runtime: lambda.Runtime.PYTHON_3_13,
      code: lambda.Code.fromAsset("lambdas/api"),
      handler: "poke.handler",
      description: `Provides a single pkm for the ${pokeDataAPI.httpApiName} API`,
    });

    const listPokeFunction = new lambda.Function(this, "lpLambda", {
      runtime: lambda.Runtime.PYTHON_3_13,
      code: lambda.Code.fromAsset("lambdas/api"),
      handler: "poke_list.handler",
      description: `Provides a list of pkm for the ${pokeDataAPI.httpApiName} API`,
    });

    pokeDataAPI.addRoutes({
      path: "/poke_info/{dex_number}",
      methods: [gateway.HttpMethod.GET],
      integration: new api_integrations.HttpLambdaIntegration(
        "spIntegration",
        singlePokeFunction
      ),
    });

    pokeDataAPI.addRoutes({
      path: "/poke_info",
      methods: [gateway.HttpMethod.GET],
      integration: new api_integrations.HttpLambdaIntegration(
        "lpIntegration",
        listPokeFunction
      ),
    });

    new cdk.CfnOutput(this, "APIEndpoint", { value: pokeDataAPI.apiEndpoint });
  }
}

Revisemos rápidamente lo que acabamos de escribir:

  • Construimos un API Gateway llamando al constructor para HttpApi. Estamos usando la colección V2 de constructs de API Gateway, que nos da acceso a HTTP APIs y otras características nuevas.
  • Creamos dos funciones Lambda con el código Python del paso anterior. Estas funciones no tienen dependencias externas, así que podemos usar el runtime Python 13 Lambda por defecto sin instrucciones de bundling adicionales.
  • Creamos dos rutas en nuestra API: poke_info/{dex_number} y poke_info, asociadas con singlePokeFunction y listPokeFunction respectivamente. Usamos HttpLambdaIntegration para conectarlas y responder solo a solicitudes GET.
  • Finalmente, agregamos una salida CloudFormation para recibir la URL de la API en nuestra terminal una vez que se complete el despliegue. Esto no es requerido, pero es útil cuando se prueban soluciones.

Después de ejecutar cdk deploy, obtendrás una salida CF como esta:

Outputs:
ApiGatewayWithLambdaAuthorizationStack.APIEndpoint = https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com

La tuya será diferente, así que reemplázala con la URL de tu stack cuando ejecutes los siguientes comandos. También, recuerda agregar el archivo JSON con los datos de Pokémon a tu proyecto—puedes encontrarlo junto a los archivos de handler Python en el repositorio del proyecto.

Probando Nuestra API

¡Ahora podemos probar que todo funciona! Usaremos CURL para hacer solicitudes a nuestro endpoint.

Una solicitud GET al recurso poke_info:

$ curl https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info
{
  "1": {"name": "Bulbasaur", "number": "1", "types": "Grass/Poison"},
  "2": {"name": "Ivysaur", "number": "2", "types": "Grass/Poison"},
  "3": {"name": "Venusaur", "number": "3", "types": "Grass/Poison"},
  "4": {"name": "Charmander", "number": "4", "types": "Fire"},
  "5": {"name": "Charmeleon", "number": "5", "types": "Fire"},
  "6": {"name": "Charizard", "number": "6", "types": "Fire/Flying"},
  "7": {"name": "Squirtle", "number": "7", "types": "Water"},
  "8": {"name": "Wartortle", "number": "8", "types": "Water"},
  "9": {"name": "Blastoise", "number": "9", "types": "Water"},
  "10": {"name": "Caterpie", "number": "10", "types": "Bug"},
  "11": {"name": "Metapod", "number": "11", "types": "Bug"},
...
}

Una solicitud GET a la novena entidad del recurso poke_info/{dex_number}:

$ curl https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info/9
{
 "name": "Blastoise",
 "number": "9",
 "types": "Water"
}

¡Perfecto! Todo funciona como se esperaba. Ahora enfoquémonos en asegurar que no todos en internet puedan acceder a esta API súper importante.

Diseñando una Solución de Autorización Flexible

Necesitamos un mecanismo de autorización flexible que podamos personalizar para soportar cambios de requisitos de los que aún sabemos poco. Afortunadamente, hay una manera simple de lograr esto con API Gateways: Autorizadores Lambda.

La idea es usar una función Lambda para procesar solicitudes cuando llegan al gateway, delegando el proceso de autorización a la lógica en su handler. Esto te permite hacer virtualmente cualquier cosa cuando autorizas solicitudes, ofreciendo una solución robusta y flexible a nuestro problema.

El diseño revisado de nuestro stack:

Diseño Revisado

El plan es crear un secreto con nuestro despliegue de stack, luego verificar cada solicitud API comparando el header authorization con el valor del secreto. Si coinciden, autorizamos la solicitud. Si no, denegamos el acceso. Aislamos la lógica de autorización dentro de nuestra función, así los cambios en requisitos tendrán impacto limitado y resultarán solo en cambios para nuestra Lambda.

En la carpeta lambdas, crea otra carpeta llamada authorizer con un archivo llamado authorizer.py:

import os
import boto3

secret = os.environ['SECRET_NAME']
secrets_client = boto3.client("secretsmanager")
secret_api_key = secrets_client.get_secret_value(
    SecretId=secret).get('SecretString')


def handler(event, context):
    response = {"isAuthorized": False}

    try:
        if (event["headers"]["authorization"] == secret_api_key):
            response = {
                "isAuthorized": True,
            }
            return response
        else:
            return response
    except BaseException:
        return response

Esta función:

  • Recupera el valor del secreto que usaremos para autorización y crea un cliente de Secrets Manager para obtenerlo.
  • Establece la respuesta por defecto para denegar cada solicitud.
  • Si el header authorization coincide con la clave secreta, autoriza el acceso a la API. De lo contrario, deniega el acceso.
  • Si ocurre una excepción, devuelve la respuesta por defecto y deniega el acceso.

Estamos usando el Simple Response Type ya que solo necesitamos validar un header simple, pero puedes usar un esquema de respuesta diferente para escenarios de autenticación complejos.

Ahora podemos editar nuestra definición de stack. Primero, agrega dos nuevas importaciones:

import { aws_secretsmanager as secrets } from "aws-cdk-lib";
import { aws_apigatewayv2_authorizers as api_auth } from "aws-cdk-lib";

Luego agrega los siguientes bloques de código:

// Aquí definimos las dos funciones lambda que sirven las solicitudes del gateway

const authorizerSecret = new secrets.Secret(this, "authorizerSecret", {
  description: `Used as secret for authorizing requests hitting: ${pokeDataAPI.httpApiName}`,
});

const authorizerFunction = new lambda.Function(
  this,
  "authorizerFunction",
  {
    runtime: lambda.Runtime.PYTHON_3_13,
    code: lambda.Code.fromAsset("lambdas/authorizer"),
    handler: "authorizer.handler",
    environment: {
      SECRET_NAME: authorizerSecret.secretName,
    },
    description: `Implements Lambda authorization for: ${pokeDataAPI.httpApiName}`,
  }
);

const httpAuthorizer = new api_auth.HttpLambdaAuthorizer(
  "httpAuthorizer",
  authorizerFunction,
  {
    responseTypes: [api_auth.HttpLambdaResponseType.SIMPLE],
  }
);

pokeDataAPI.addRoutes({
  path: "/poke_info/{dex_number}",
  methods: [gateway.HttpMethod.GET],
  integration: new api_integrations.HttpLambdaIntegration(
    "spIntegration",
    singlePokeFunction
  ),
  authorizer: httpAuthorizer, // <- ESTO ES NUEVO
});

pokeDataAPI.addRoutes({
  path: "/poke_info",
  methods: [gateway.HttpMethod.GET],
  integration: new api_integrations.HttpLambdaIntegration(
    "lpIntegration",
    listPokeFunction
  ),
  authorizer: httpAuthorizer, // <- ESTO ES NUEVO
});

authorizerSecret.grantRead(authorizerFunction);

// Aquí definimos el CfnOutput

Este código:

  • Crea un secreto en Secrets Manager—usaremos esto como nuestro token de autenticación.
  • Crea una función Lambda con nuestro código de autorización, luego crea un HttpLambdaAuthorizer vinculado a nuestra Lambda de autorización usando el tipo de respuesta simple.
  • Pasa el autorizador a la propiedad authorizer de cada ruta. No necesitas ejecutar el autorizador en cada acción, pero decidimos asegurar ambas rutas.
  • Otorga acceso de lectura a la Lambda autorizadora al secreto que acabamos de crear.

¡Listo para probar la versión final! Ejecuta cdk deploy y estaremos listos.

Probando Nuestra Solución Completa

Sin cambios, ejecutar los comandos anteriores nos da:

$ curl https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info
{"message":"Unauthorized"}

$ curl https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info/9
{"message":"Unauthorized"}

¡Perfecto! Nuestra solicitud carece del token de autenticación, así que recibe una respuesta Unauthorized. Recuperemos nuestro token y agreguémoslo al header. Puedes encontrarlo en el Secrets Manager de tu cuenta—el mío se veía así:

Token de Secret Manager

Ahora podemos agregar el header a nuestras llamadas curl usando -H y probar si funcionan:

$ curl -H "Authorization: r<p,spx/o1[\",8@Y{gQ2G[.]GuU@n7LW" https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info/9
{
  "name": "Blastoise",
  "number": "9",
  "types": "Water"}


$ curl -H "Authorization: r<p,spx/o1[\",8@Y{gQ2G[.]GuU@n7LW" https://jgr2iirikk.execute-api.eu-west-1.amazonaws.com/poke_info
{
  "1": {"name": "Bulbasaur", "number": "1", "types": "Grass/Poison"},
  "2": {"name": "Ivysaur", "number": "2", "types": "Grass/Poison"},
  "3": {"name": "Venusaur", "number": "3", "types": "Grass/Poison"},
  "4": {"name": "Charmander", "number": "4", "types": "Fire"},
  "5": {"name": "Charmeleon", "number": "5", "types": "Fire"},
  "6": {"name": "Charizard", "number": "6", "types": "Fire/Flying"},
  "7": {"name": "Squirtle", "number": "7", "types": "Water"},
  "8": {"name": "Wartortle", "number": "8", "types": "Water"},
  "9": {"name": "Blastoise", "number": "9", "types": "Water"},
  "10": {"name": "Caterpie", "number": "10", "types": "Bug"},
  "11": {"name": "Metapod", "number": "11", "types": "Bug"},
...
}

¡Funcionan!

Recuerda que tu secreto puede contener caracteres que necesites escapar. El mío tenía una “ que necesité escapar. Puedes usar una herramienta más sofisticada como Postman o Insomnia para probar tu API—de esa manera no necesitarás escapar caracteres especiales:

Token de Secret Manager

Con esto, podemos concluir que logramos asegurar nuestra API, y ni siquiera fue tan difícil. La función autorizadora es bastante simple por el momento, pero estoy seguro de que ya puedes empezar a pensar en algunos flujos de autorización más elaborados.

¡IMPORTANTE! Siempre recuerda eliminar tu stack ejecutando cdk destroy o eliminándolo manualmente en la consola.

Mejoras y Experimentos

  • Inspecciona tu API Gateway y encuentra dónde se configura la duración del caché de respuesta de la Lambda de autorización (las respuestas de Lambda de autorización se cachean para rendimiento). ¿Cuáles son las ventajas y desventajas de establecer esto a un valor bajo?
  • Intenta crear una versión de nuestro autorizador que use api_auth.HttpLambdaResponseType.IAM en lugar de api_auth.HttpLambdaResponseType.SIMPLE.
  • Usar un solo token inmutable para autorización no es ideal. ¿Cómo actualizarías la lógica del autorizador para validar tokens temporales producidos por otros servicios AWS?
  • Modifica el código del autorizador para también validar la IP de la entidad que envía la solicitud. Prueba dos enfoques:
    • Validar tanto IP como token de autorización
    • Si la IP coincide con un valor específico, ignorar el requisito del token (la IP tiene acceso privilegiado)
  • Modifica la función Lambda para validar el user agent de la solicitud y solo permitir solicitudes de navegadores web modernos.
  • Edita el stack para recibir (como props) una lista de IPs permitidas y solo permitir solicitudes de esas IPs.

¡Estos experimentos te mantendrán ocupado por un fin de semana o más! La ventaja de tener un mecanismo de autorización tan flexible y personalizable es enorme—te permite soportar casi cualquier requisito de autorización que puedas imaginar.

Este laboratorio difirió de los anteriores en que se enfocó en mejorar una solución existente en lugar de construir una desde cero. Demostró que mejoras significativas del sistema a menudo pueden lograrse sin cambios mayores—simplemente usando las herramientas correctas efectivamente. Hoy, aprendiste una técnica que puede ayudarte a hacer justamente eso.

¡Espero que esto te sea útil!

Juan Luis Orozco Villalobos

¡Hola! Soy Juan, un ingeniero de software y consultor que vive en Budapest. Me especializo en computación en la nube e IA/ML, y me encanta ayudar a otros a aprender sobre tecnología e ingeniería