Implementación de custom Lambda Authorizer para Amazon API Gateway

kcatucuamba

Kevin Catucuamba

Posted on November 5, 2023

Implementación de custom Lambda Authorizer para Amazon API Gateway

Al desarrollar servicios mediante Amazon API Gateway, es fundamental considerar cómo garantizar la seguridad de nuestros recursos y prevenir el abuso por parte de ciertos usuarios. En el contexto de Amazon API Gateway, existen varias formas de autorizar las solicitudes.

En este breve tutorial, se presentará un ejemplo práctico de cómo asegurar nuestra API Gateway mediante el uso de tokens. Utilizaremos una función Lambda como autorizador, y los tokens serán generados por el servicio de Amazon Cognito, que cuenta con un conjunto de usuarios.

Objetivos del ejemplo:

  • Implementar los recursos esenciales en AWS utilizando AWS SAM (Serverless Application Model), y validar la correcta creación de estos recursos en la AWS Management Console.
  • Configurar, a través de una plantilla con especificaciones OpenAPI, todas las medidas necesarias para asegurar una API, garantizando su protección integral.
  • Demostrar el proceso de autenticación de un usuario a través de Amazon Cognito y cómo enviar el token correspondiente para habilitar la invocación de una API en AWS de manera segura.
  • Realizar pruebas de la funcionalidad de la función Lambda autorizadora implementada, asegurando su correcto funcionamiento.

En la consola de AWS se visualizarán los recursos que se deben crear, por lo que es importante tener un entendimiento básico de las funciones Lambda y las APIs en AWS.

Lambda Authorizer

Una Lambda Authorizer es un componente del servicio Amazon API Gateway que gestiona el acceso a las APIs y los recursos de backend. Opera antes de que las solicitudes de una API en Amazon API Gateway se procesen y determina si un usuario o una solicitud de aplicación tiene el permiso necesario para acceder a los recursos protegidos.

En este caso, se presentará un ejemplo de una Lambda Authorizer basada en tokens:

Image1

1.- Un cliente efectúa una solicitud HTTP hacia un servicio a través de Amazon API Gateway.
2.- Amazon API Gateway transmite la información requerida, incluyendo el token, a la función lambda encargada de la autorización. En esta función, se lleva a cabo la validación del token o acciones necesarias para determinar su validez.

Ejemplo de los datos de entrada:

{
    "type": "TOKEN",
    "methodArn": "arn:aws:execute-api:us-east-1:818802983213:4dxds2wsdf/v1/GET/labs/products",
    "authorizationToken": "Bearer eyJraWQi...."
}
Enter fullscreen mode Exit fullscreen mode

3.- Dentro de la lambda se implementa la lógica necesaria para validar el token y retornar un documento de politica que contiene una lista de declaraciones de políticas, indicando si pemite o deniega el acceso.

Ejemplo de politica generada:

{
    "principalId": "user/unauthorized",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Deny",
                "Resource": [
                    "arn:aws:execute-api:us-east-1:00XXXXXXXX00:XXXXXXXXX/v1/GET/labs/products"
                ]
            }
        ]
    },
    "context": {
        "key": "value",
        "number": 1,
        "bool": true
    }
}
Enter fullscreen mode Exit fullscreen mode

4.- Devuelve la política generada a Amazon API Gateway para su evaluación.
5.- En caso de que la política generada permita el acceso al recurso configurado, el acceso al recurso desplegado, que en este caso es una función lambda, es autorizado.

Pool de Usuarios

Un grupo de usuarios (Pool Users) de Amazon Cognito es un directorio de usuarios para la autenticación y autorización de aplicaciones web y móviles. Un pool de usuarios en Amazon Cognito actúa como un repositorio centralizado de usuarios y proporciona funciones de autenticación, autorización y administración de usuarios para sus aplicaciones.

Mayor información en: Grupos de usuarios de Amazon Cognito

Plantilla de SAM para crear un pool de usuarios.

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  SAM template for the Cognito resources.

Parameters:
  EnvironmentId:
    Type: String
    Description: Environment Name

  CompanyPrefix:
    Type: String
    Description: "A name for environment id"

Resources:
  SystemsUsersPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName : !Join ['-',[!Ref "EnvironmentId"  ,"system-users" ]]
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
      UserPoolTags:
        ORGANIZATION: !Ref CompanyPrefix
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
      AutoVerifiedAttributes:
        - email
      AliasAttributes:
        - email
      Schema:
        - AttributeDataType: String
          Name: email
          Required: true
          Mutable: true

  ClientAppAuth:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref SystemsUsersPool
      GenerateSecret: true
      ReadAttributes:
        - address
        - nickname
        - birthdate
        - phone_number
        - email
        - phone_number_verified
        - email_verified
        - picture
        - family_name
        - preferred_username
        - gender
        - profile
        - given_name
        - zoneinfo
        - locale
        - updated_at
        - middle_name
        - website
        - name
Enter fullscreen mode Exit fullscreen mode

SystemsUsersPool: Este recurso representa un grupo de usuarios en Cognito. Este recurso se utiliza para crear un grupo de usuarios con configuraciones específicas, como políticas de contraseña y atributos permitidos.

ClientAppAuth: Es un recurso que representa una aplicación cliente que se autentica con el grupo de usuarios creado anteriormente. Este recurso define las propiedades de la aplicación cliente, como los atributos que se pueden leer y si se debe generar un secreto para la autenticación.

En el servicio de Amazon Cognito se puede observar los grupos de usuarios creados:

Image description

Por defecto, el grupo de usuarios recién creado no incluirá a ningún usuario. Para agregar uno, se debe crear un nombre de usuario, proporcionar una dirección de correo electrónico y establecer una contraseña que cumpla con la política especificada.

Una vez que se ha creado el usuario, este se encontrará en un estado en el que debe cambiar su contraseña. Para confirmar la creación del usuario, se puede utilizar el siguiente comando:

aws cognito-idp admin-set-user-password --user-pool-id us-east-1_7W6X0n6v0 --username userdemo --password kevin??890KZNA --permanent
Enter fullscreen mode Exit fullscreen mode

Para conocer más comandos disponibles visitar: cognito-idp

En este escenario, ya existen usuarios registrados y preparados para autenticarse y generar tokens.

Image description

Nota: Para ejecutar este comando, es necesario configurar las credenciales en el archivo "credentials" de AWS.

Configuración en especifiación

Es importante tener en cuenta que es necesario configurar la propiedad "securitySchemes" a nivel de la especificación dentro de los componentes.

Image description

Tener en cuenta que se configuran dos partes: "api_key" y "authorizer". El último es el encargado de validar el token dentro de la función lambda autorizadora..

  securitySchemes:
    api_key:
      type: apiKey
      in: header
      name: x-api-key
      description: 'API key or aborts with Unauthorized'
    authorizer:
      type: apiKey
      in: header
      name: Authorization
      description: 'Token JWT para el consumo del API.'
      x-amazon-apigateway-authtype: custom
      x-amazon-apigateway-authorizer:
        type: token
        identitySource: v1
        authorizerUri:
          Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${LambdaAuthorizer}:production/invocations
        authorizerResultTtlInSeconds: 3000
        identityValidationExpression: ^Bearer [-0-9a-zA-z\.]*$
Enter fullscreen mode Exit fullscreen mode

A nivel de cada endpoint, es esencial garantizar que se utilicen esos esquemas de seguridad.

Image description

Una vez que la API se haya desplegado con estas configuraciones, se podrá verificar los cambios en la consola de AWS:

Image description

Ejemplo de código

Autenticación de usuario:

Un ejemplo para autenticar a un usuario de nuestro pool es el siguiente código en Node.js:

Command file

import {EnvUtil} from "../utils/env.util";
import {CognitoIdentityProviderClient, InitiateAuthCommand} from "@aws-sdk/client-cognito-identity-provider";
import {AuthLoginResponse, AuthUserCredentials} from "../models/auth-login.model";
import {loggerUtil as log} from "../utils/logger.util";
import crypto from "crypto";
import {HttpStatusCode} from "axios";
export const LogInCommandExecutor = async (authUserCredentials: AuthUserCredentials): Promise<AuthLoginResponse> => {

    const [region, clientId, secretClient] = EnvUtil.getObjectEnvVarOrThrow(
        ['AUTH_AWS_REGION', 'AUTH_CLIENT_ID', 'AUTH_SECRET_CLIENT']);

    const {username, password} = authUserCredentials;

    const client = new CognitoIdentityProviderClient({region});

    const hash = crypto.createHmac('sha256', secretClient).update(`${username}${clientId}`).digest('base64');

    const command = new InitiateAuthCommand({
        AuthFlow: "USER_PASSWORD_AUTH",
        ClientId: clientId,
        AuthParameters: {
            USERNAME: username,
            PASSWORD: password,
            SECRET_HASH: hash
        }
    });

    try {
        const response = await client.send(command);
        if (!response.AuthenticationResult && response.ChallengeName === "NEW_PASSWORD_REQUIRED") {
            return {
                statusMessage: "It is necessary to change the password",
                credentials: null
            }
        }
        return {
            statusMessage: "Authentication successful",
            credentials: {
                idToken: response.AuthenticationResult.IdToken,
                accessToken: response.AuthenticationResult.AccessToken,
                refreshToken: response.AuthenticationResult.RefreshToken
            }
        }


    } catch (error) {
        const errorResponse = {
            status: 500,
            body: "Hay muchos errores",
            headers: {
                "Content-Type": "application/json",
            }
        }
        throw new Error(HttpStatusCode.InternalServerError.toString());
    } finally {
        client.destroy();
    }


}
Enter fullscreen mode Exit fullscreen mode

En términos generales, este código realiza la autenticación de un usuario en Cognito utilizando su dirección de correo y contraseña. Utiliza el SDK de Cognito para Node.js y recupera todas las variables de entorno necesarias para llevar a cabo la autenticación:

  • región: La región en la que se ha desplegado el grupo de usuarios.
  • clientId: Un identificador único para el cliente de integración.
  • secretClient: Es una cadena constante que nuestra aplicación debe incluir en todas las solicitudes de API al cliente de la aplicación..

Puedes obtener esta información en la pestaña "App Integration" de Amazon Cognito:

Image description

Para obtener más información de las diferentes acciones que se puede realizar, vistiar: API Reference Cognito - Actions

Verificación de token:

Dentro de la función lambda autorizadora, es esencial verificar la validez del token recibido. Si el token es válido, la función debe devolver una política que indique si se permite o deniega el acceso al recurso. La lógica puede ser la siguiente (Node.js):

import {APIGatewayAuthorizerResult, APIGatewayTokenAuthorizerEvent} from "aws-lambda";
import {CognitoJwtVerifier} from "aws-jwt-verify";

const AWS_ACCOUNT = process.env.AUTH_AWS_ACCOUNT_ID;
const AWS_REGION = process.env.AUTH_AWS_REGION;

export class KcUtil {

    static ALLOW_TEXT = "Allow";
    static DENY_TEXT = "Deny";
    static PRINCIPAL_ID = "user|kcatucuamba";

    static async validateToken(token: string): Promise<boolean> {

        const userPoolId = process.env.AUTH_USER_POOL_ID;
        const clientId = process.env.AUTH_CLIENT_ID;

        const verifier = CognitoJwtVerifier.create({
            userPoolId,
            tokenUse: "access",
            clientId
        });

        token = token.replace("Bearer ", "");

        try {
            const response = await verifier.verify(token as any);

            return true;
        }catch (e) {
            return false;
        }

    }

    static async generatePolicy(event: APIGatewayTokenAuthorizerEvent): Promise<APIGatewayAuthorizerResult> {

        const isTokenFailed = !(await KcUtil.validateToken(event.authorizationToken));

        if (isTokenFailed) {
            return await KcUtil.createPolicy("user/unauthorized", KcUtil.DENY_TEXT, event.methodArn);
        }

        const resource = "arn:aws:execute-api:" + AWS_REGION + ":" + AWS_ACCOUNT + ":*/*/*/*";
        return await KcUtil.createPolicy(KcUtil.PRINCIPAL_ID, KcUtil.ALLOW_TEXT, resource);
    }


    static async createPolicy(
        principalId: string,
        effect: string,
        resource: string): Promise<APIGatewayAuthorizerResult> {
        return {
            principalId: principalId,
            policyDocument: {
                Version: '2012-10-17',
                Statement: [
                    {
                        Action: 'execute-api:Invoke',
                        Effect: effect,
                        Resource: [resource]
                    }
                ]

            },
            context: {
                key: 'value',
                number: 1,
                bool: true
            }
        };

    }
}
Enter fullscreen mode Exit fullscreen mode

Se adjunta el archivo package.json que especifica las dependencias utilizadas tanto para la lambda autorizadora como para la generación del token:

  "devDependencies": {
    "@types/aws-lambda": "^8.10.114",
    "@types/axios": "^0.14.0",
    "@types/jsonwebtoken": "^9.0.2",
    "@types/jwk-to-pem": "^2.0.1",
    "@types/node": "^18.15.11",
    "aws-lambda": "^1.0.7",
    "aws-sdk": "^2.1409.0",
    "ts-node": "^10.9.1",
    "typescript": "^5.0.3"
  },
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.363.0",
    "aws-jwt-verify": "^4.0.0",
    "axios": "^1.4.0",
    "crypto": "^1.0.1",
    "jsonwebtoken": "^9.0.0",
    "jwk-to-pem": "^2.0.5",
    "winston": "^3.8.2"
  }
Enter fullscreen mode Exit fullscreen mode

Verificando funcionalidad

Token:

Generamos el token (accessToken) utilizando otra API de Amazon y funciones lambda en este caso:

Image description

1.- Enviando token inválido

En primer lugar, llevamos a cabo una prueba sin enviar un token válido. Dependiendo de la configuración de la API, esta responderá con un mensaje de error en este caso:

Image description

Verificamos la traza generada:

Image description

Es claro que la validación se lleva a cabo exclusivamente en la función lambda autorizadora, y no se intenta acceder al endpoint en sí, ya que el token ha fallado. Como resultado, se ha generado una política denegada para el recurso que se intentaba acceder a través de API Gateway.

  1. Enviando token válido

Si enviamos el token previamente generado (accessToken), la API debería realizar una llamada a la función lambda autorizadora. Si la validación es exitosa, la API ejecutará y permitirá el acceso a los recursos configurados, que en este caso también son funciones lambda.

Verificamos la traza:

Image description

La sección anaranjada indica que el token ha sido validado con éxito en la función lambda autorizadora, lo que permite el acceso al recurso requerido, en este caso, una lambda que realiza una consulta de productos.

Si se envía nuevamente una solicitud con el mismo token, ocurre algo interesante:

Image description

Se observa que no se lleva a cabo la validación en la función lambda autorizadora. Esto se debe a la configuración de una caché de una hora. Es decir, como el token previamente se había verificado y resultó válido, se almacena en la caché durante ese período y no se vuelve a verificar en las siguientes solicitudes. Esta estrategia es beneficiosa en términos de tiempo de respuesta, ya que no sería eficiente realizar la validación en cada petición.

Conclusiones

  • La función lambda autorizadora es independiente del lenguaje o la tecnología utilizada, lo que demuestra la flexibilidad y facilidad para implementar estos esquemas de seguridad con las herramientas permitidas por AWS.
  • Es esencial tener en cuenta el tiempo de caché de la función lambda autorizadora, el cual debe adaptarse a la duración del token. En este ejemplo, se ha configurado una duración de una hora debido a la vigencia del token.
  • La configuración de seguridad implementada aquí es una de las más simples disponibles. Se recomienda consultar la documentación oficial para implementar técnicas de seguridad más avanzadas.

Referencias

💖 💪 🙅 🚩
kcatucuamba
Kevin Catucuamba

Posted on November 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related