Article

AWS Serverless & Programmation Événementielle : Créez un Service de Transfert d’Argent Mobile (Partie 1) 💰

Dans cet article (pratique), je vais vous montrer comment mettre en place une solution de transfert d'argent mobile (imaginaire) beaucoup utilisé en Afrique en utilisant du serverless sur AWS. L'objectif principal étant de vous familiariser avec les concepts serverless tout en construisant une application pratique.

Introduction :

A la fin de cette série d’articles, vous aurez appris à utiliser l’infra-as-code avec Terraform pour configurer et déployer :

  • un Api Gateway sécurisé avec une Lambda Authorizer qui utilise un service d’authentication externe comme Auth0
  • des fonctions Lambda pour gérer les 3 trois types de transactions suivantes :
    • Vérification de solde
    • Dépôt
    • Retrait
  • une base de donnée DynamoDB
  • une file SQS pour enregistrer les transactions et les traiter plus tard de manière asynchrone,
  • La journalisation (logging) avec CloudWatch pour surveiller et déboguer les fonctions Lambda
    .
  • La notification des utilisateurs :
    • Par SMS avec un service externe comme Nimba SMS
    • Par email en utilisant Amazon Simple Email Service (SES).

Architecture :

Infrastructure Infrastructure Serverless Pour l’Application Fintech Imaginaire

Prérequis :

  • Terraform version 1.10.0 ou supérieure
  • Nodejs version 20 ou supérieure
  • Yarn / Npm
  • Compte AWS :
    • avec un utilisateur ayant le droit d’administrateur. voir créer un compte AWS
    • Note : Une carte de crédit est nécessaire pour la vérification d’identité. La plupart des ressources utilisées dans ce tutoriel sont serverless et ne génèrent des coûts que si elles sont utilisées avec un certain volume.

  • Avoir un compte Auth0 (j’utilise un compte gratuit) voir créer un compte Auth0.

  • Optionnel :
    • Avoir un compte NimbaSMS avec un pack sms active (ou un autre fournisseur de sms quel que soit le pays). Créer un compte NimbaSMS.

    • Avoir un nom de domaine acheté sur AWS ou un chez un autre fournisseur. Mais l’intégration est plus simple si le domaine est AWS.

      Par défaut, l’API Gateway génère un domaine pour vos APIs. Si vous disposez d’un nom de domaine, vous pouvez le mapper à votre API Gateway au lieu d’utiliser le domaine par défaut.

    • Outils recommandés pour le développement :

      Pour faciliter le développement local et le cycle de déploiement, les outils suivants sont recommandés :

      • Devbox : pour configurer des environnements de développement de manière isolés de ceux de l’hôte.
      • Taskfile : pour automatiser les tâches de déploiement et de développement(équivalent à Makefile).
      • AWS CLI : utiliser pour créer le backend S3 de terraform.
      • Direnv : pour gérer les variables d’environnement dans le répertoire de dev par exemple.
      • Aws SAM: pour générer un projet typescript prêt à l’emploi.

Contexte :

Aujourd’hui, on peut dire que la transition des datacenters traditionnels vers le cloud est en plein essor. La plupart des entreprises ont choisi de suivre cette tendance en l’adoptant pour bénéficier des avantages qu’il apporte. Pour en savoir plus sur les avantages du cloud, consulter cet article de GCP.

Amazon a été le premier à se lancer dans le cloud computing avec son service EC2 (Elastic Compute Cloud) en 2006, suivi de S3 (Simple Storage Service) en 2007. Aujourd’hui, AWS (Amazon Web Services) est devenu une entreprise à part entière, offrant une pléthore de services.

Avec un modèle d’affaires basé sur la facturation à l’heure pour l’utilisation des ressources, des services comme EC2 se sont rapidement imposés comme des solutions incontournables. Désormais, au lieu de gérer des serveurs physiques, on peut louer des serveurs virtuels sur AWS pour une durée déterminée.

Cependant, après plusieurs années d’utilisation de ce modèle, les entreprises ont commencé à se poser une question essentielle : pourquoi payer pour des ressources qui ne sont pas utilisées en permanence ? Par exemple, lors des périodes de faible activité comme les nuits, les weekends, etc.

En 2014, AWS a lancé un nouveau modèle Serverless de Facturation et service (Function as a Service) qui permet de ne payer que ce qu’on utilise avec AWS Lambda. Les lambdas sont des fonctions qui peuvent être déclenchées par des événements comme la réception d’un fichier sur S3, un changement de status sur un serveur, ou encore par un appel HTTP etc

En tant que développeur, aucun service de compute à gérer ou à maintenir, vous pouvez vous concentrer sur l’écriture de code.

Il faut noter qu’AWS est le leader du marché du cloud devant des concurents comme Microsoft Azure et Google Cloud Platform.

Présentation des outils utilisés :

Auth0 :

Auth0 est une plateforme d’authentification et d’autorisation qui permet aux développeurs de sécuriser leurs applications web, mobiles et API.
Il permet par exemple dans notre cas, de sécuriser l’API qu’on s’apprête à déployer.

Nimba SMS :

Nimba SMS est une plateforme de notification par SMS à moindre coût.
Excellente documentation avec une integration très simple, c’est du niveau de Twilio mais avec un coût bien inférieur.

Services AWS :

Ci-dessous la liste des services AWS utilisés dans ce projet avec leurs rôles et coûts respectifs :

NomRôleCoût sur Canada Central 
API GatewayCréation d’API$3.50/333 M requêtes / mois Documentation 
LambdaFunction contenant la logique Métier$0.20/1 M requêtes / mois Documentation 
CloudWatchMonitoring des logs et des erreurs0,55 USD par Go / mois Documentation 
Route 53DNS0,50 $ / zone hébergée / mois Documentation 
DynamoDBBase de données NoSqlen fonction du mode de stockage et du nombre de requêtes Documentation 
SqsFile d’attente (Queue)0,40 $ file standard, de 1 million à 100 milliards de demandes/mois Documentation 
KMSChiffrement des données0,03 $ pour 10 000 demandes Documentation 
SSMGestion des parametres et secretsgrattuit pour le standard et 0,05 USD par paramètre avancé par mois (au prorata horaire si le paramètre est stocké moins d’un mois) Documentation 
SESEnvoie et reception d’Email0,10 $/1 000 e-mails Documentation 
IAMGestion des identés utilisateurs et rolesGratuit Documentation 

Structure du projet :

Voici à quoi pourrait ressembler la structure finale du projet :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
├── infra-as-code
│   ├── environments
│   │   └── dev
│   └── modules
│       └── common
├── lambda-src
│   ├── authorizer
│   │   ├── src
│   │   ├── tests
│   │   │   └── unit
│   ├── get-account-info
│   │   ├── src
│   │   ├── tests
│   │   │   └── unit
│   └── init-user-account
│       ├── src
│       ├── tests
│       │   └── unit

Pour reprendre la structure ci-haut, exécuter les commandes suivantes :

1
2
3
4
5
6
7
8
9
10
11
12
    # Dossier des environnements de votre infrastructure. Si vous avez plusieurs environnements
    # (dev, test, prod, etc.), vous pouvez les créer manuellement ou utiliser Terragrunt pour
    # générer les environnements dynamiquement.
    mkdir -p infra-as-code/environments/dev/

    # Dossier utilisé pour stocker le template de la spécification OpenAPI de l'API Gateway.
    # Ce template servira de base pour définir les endpoints et les configurations de l'API.
    mkdir -p infra-as-code/modules/common/templates/
    
    # Dossier utilisé pour stocker les projets Node.js des fonctions Lambda.
    # Chaque sous-dossier correspondra au code source d'une fonction Lambda spécifique.
    mkdir -p lambda-src/

Installation des outils nécessaires :

Dans cet article, j’utilise devbox pour installer les différents outils dans un environnement isolé, il est basé sur nix. Vous pouvez utiliser votre gestionnaire de paquet préféré pour installer les outils nécessaires ou télécharger les binaires ou les scripts d’installation directement sur le site officiel de chaque outil.

Note : L’un des avantages majeurs d’un outil comme Devbox est sa capacité à créer des environnements isolés et reproductibles. Cela est particulièrement utile pour les projets open source ou les projets qui doivent être partagés avec d’autres développeurs. En quelque sorte, Devbox est à la gestion des paquets ce que Docker est à la gestion des conteneurs.

1
2
3
4
5
6
7
8
9
10
11
# Cette commande permet d'initialiser devbox dans le projet en créant un fichier de configuration devbox.json
devbox init 

# Ajout des différents paquets nécessaires
devbox add git
devbox add terraform@1.10.5
devbox add devenv@1.4 
devbox add nodejs_22
devbox add aws-sam-cli@1.132.0 
devbox add go-task@3.41.0
devbox add yarn@1.22.22

L’installation des paquets peut prendre un certain temps si c’est la première fois que vous utilisez devbox, nix ou un gestionnaire de paquet similaire. Une fois l’installation terminée, vous pouvez lancer la commande suivante pour activer l’environnement virtuel.

1
2
3
4
5
6
7
devbox shell

Info: Ensuring packages are installed.
✓ Computed the Devbox environment.
Starting a devbox shell...
(devbox) 
# taper exit puis entrée pour sortir de l'environnement virtuel

Création de l’infrastructure :

Pour faire du serverless sur AWS, il existe plusieurs outils et frameworks sur le marché dont CloudFormation, AWS CDK, AWS SAM maintenue par AWS et d’autres comme Serverless Framework, Terraform/OpenTofu etc.

Nous allons utiliser terraform, l’outil que nous avons installé précédemment pour provisionner notre infrastructure.

Terraform 1.10.5 est la version utilisé dans cet article, il supporte l’utilisation de S3 comme backend pour stocker l’état de l’infrastructure avec le lock dynamique native de S3 sans utiliser de base de données comme dynamodb. D’habitude, on utilise opentofu mais la dernière version d’opentofu ne supporte pas encore l’utilisation de S3 comme backend avec le lock dynamique native. Suivre l’issue sur le repo d’opentofu pour le support du lock_file native de S3 : https://github.com/opentofu/opentofu/issues/599

Nous allons utiliser un bucket s3 comme backend pour stocker l’état de l’infrastructure, il nous faut donc un avant la création de celle-ci.

Pour communiquer avec l’API d’AWS via la CLI ou Terraform, nous avons besoin de configurer les credentials.
J’utilise l’outil direnv pour gérer les variables d’environnement, Voir comment configurer les credentials AWS avec votre OS

1
2
3
4
export AWS_ACCESS_KEY_ID=votre-access-key-id
export AWS_SECRET_ACCESS_KEY=votre-secret-access-key
export AWS_DEFAULT_REGION=votre-region
export AWS_ACCOUNT_ID=votre-account-id

Puis une fois dans le dossier infra-as-code/environments/dev : direnv allow pour activer les variables d’environnement.

Note : Le fichier .envrc doit être ignoré par git, donc il faut ajouter une ligne dans le fichier .gitignore : *.envrc **/.envrc

  • Maintenant que les crédentials sont configurés, nous pouvons créer le bucket s3 qui vas servir de backend pour terraform:
  • Note : Terraform offre Terraform Cloud et Terraform Enterprise pour gérer les états des infrastructures.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
      BUCKET_NAME="aws-serverless-fintech-solution-statefile-bucket-xsxd3"
        
      REGION="ca-central-1" # remplacer par la région la plus proche de vous
        
      # Creation du bucket s3
      aws s3api create-bucket \
        --bucket $BUCKET_NAME \
        --region $REGION \
        --create-bucket-configuration LocationConstraint=$REGION
        
      # Activer le versioning sur le bucket s3
      aws s3api put-bucket-versioning \
        --bucket $BUCKET_NAME \
        --versioning-configuration Status=Enabled
          
      #List buckets pour verifier que le bucket a bien été créé
      aws s3api list-buckets
    
  • Avec la Console Web d’AWS :

    Sur le champ de recherche, tapez s3 et cliquez sur dessus, puis sur le button jaune create bucket, remplissez le champ bucket name avec le nom du bucket que vous avez choisi, sur la section Bucket Versioning cliquez sur Enable puis sur create bucket.

    Activer le versioning sur le bucket s3 est une bonne pratique pour éviter la perte de données ou en cas corruption du statefile de terraform on peut revenir à une version antérieure.

Création des différents composants de l’infrastructure et leurs intégrations:

  • Création d’une clé de cryptage (AWS KMS) :

    Nous allons créer une clé de cryptage unique pour l’ensemble des ressources de notre infrastructure (AWS KMS).

    infra-as-code/modules/common/00-kms.tf :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    
      
    #Récupère l'ID du compte AWS
    data "aws_caller_identity" "current" {}
      
    data "aws_iam_policy_document" "kms_policy_document" {
      statement {
        sid    = uuidv5("dns", "kms_policy_document_root")
        effect = "Allow"
        actions = [
          "kms:*"
        ]
        resources = [
          "*"
        ]
        principals {
          type = "AWS"
          identifiers = [
            "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root",
          ]
        }
      }
    }
      
    resource "aws_kms_key" "this" {
      description = "Cle d'encryption pour les données"
      policy      = data.aws_iam_policy_document.kms_policy_document.json
    }
      
    resource "aws_kms_alias" "this" {
      name          = "alias/fintech-solution"
      target_key_id = aws_kms_key.this.key_id
    }
    
  • Api Gateway :

    Aws support 4 types d’Api Gateway :

    • REST API
    • HTTP API
    • WebSocket API
    • Rest API Private

    Rest API est celui qu’on vas utiliser pour ce projet, vous pouvez lire cette documentation pour mieux faire le choix entre les 4 types.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    
    openapi: 3.0.3
      
    info:
      title: API Argent Mobile
      description: Cette API est juste un prototype pour la gestion des comptes Argent Mobile et utilisé comme exemple pour la formation DevSecOps
      version: 1.0.0
    servers:
      - url: "https://api.doudhal-devops.com/v1"
      
    components:
      # Utilisation de l'authentification JWT pour sécuriser les endpoints
      securitySchemes:
        JwtAuthorizer:
          type: apiKey
          name: Authorization
          in: header
          x-amazon-apigateway-authtype: "custom"
          x-amazon-apigateway-authorizer:
            type: "token"
              
            # L'ARN de la fonction Lambda qui va vérifier le token JWT (lambda-authorizer)
            authorizerUri: "arn:aws:apigateway:${aws_region}:lambda:path/2015-03-31/functions/${jwt_authorizer_lambda_arn}/invocations"
            authorizerResultTtlInSeconds: 250
            identitySource: "method.request.header.Authorization"
            identityValidationExpression: "^Bearer\\s[a-zA-Z0-9._-]+$"
      
    paths:
      /get-account-info/msisdn/{msisdn}:
        get:
          operationId: getAccountInfo
          description: Obtenir les informations sur la solde d'argent mobile d'un numéro de téléphone
            
          # Pour protéger cette route, on ajoute la sécurité JwtAuthorizer défini plus haut
          security:
            - JwtAuthorizer: []
      
          # Cette prend juste en path paramètre le numéro de téléphone du client
          parameters:
            - name: msisdn
              in: path
              required: true
              description: Le numéro de telephone du client
              schema:
                type: string
            
          # Cette section reserve pour l'intégration avec le backend (ici une lambda get-account-info) 
          # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration.html
          x-amazon-apigateway-integration:
            credentials: "${api_gateway_role_arn}"
            uri: "arn:aws:apigateway:${aws_region}:lambda:path/2015-03-31/functions/${get_account_info_lambda_arn}/invocations"
            passthroughBehavior: "when_no_match"
            httpMethod: "POST"
              
            # Vue le get-account-info est sensible, le client veut toute de suite voir son solde, on met cette intégration synchrnone
            type: "aws_proxy"
              
          responses:
            "200":
              description: OK
              content: {}
                
          # Pour les autres codes d'erreur, on renvoie une réponse par défaut
      
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    
    locals {
      api_gw_name = "fintech-solution-api"
      get_acount_info_lambda_name = "get-account-info"
    }
      
    data "aws_iam_policy_document" "api_gw_assume_role" {
      #Section de définition du rôle assumption pour le service API Gateway
      statement {
        effect = "Allow"
      
        principals {
          type        = "Service"
          identifiers = ["apigateway.amazonaws.com"]
        }
      
        actions = ["sts:AssumeRole"]
      }
    }
      
    data "aws_iam_policy_document" "api_gw_policy_document" {
      #Section de définition des permissions pour le service API Gateway, pour toutes intégrations que l'on va créer
      statement {
        effect = "Allow"
      
        actions = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:DescribeLogGroups",
          "logs:DescribeLogStreams",
          "logs:PutLogEvents",
          "logs:GetLogEvents",
          "logs:FilterLogEvents",
        ]
      
        resources = ["*"]
      }
      
      statement {
        effect = "Allow"
      
        actions = [
          "lambda:InvokeFunction"
        ]
      
        resources = [
          "arn:aws:lambda:${var.aws_region}:${var.aws_account_id}:function:${local.get_acount_info_lambda_name}"
        ]
      }
    }
      
    data "template_file" "endpoints" {
      # Conversion du fichier endpoints.oas.yaml en template terraform
      template = file("${path.module}/templates/endpoints.oas.yaml")
      
      vars = {
        api_gateway_role_arn        = aws_iam_role.gateway_rest_api_role.arn
        jwt_authorizer_lambda_arn   = aws_lambda_function.lambda_authorizer.arn
        get_account_info_lambda_arn = "arn:aws:lambda:${var.aws_region}:${var.aws_account_id}:function:${local.get_acount_info_lambda_name}"
        aws_region                  = var.aws_region
      }
    }
      
    # Création du role aws pour l'api gateway
    resource "aws_iam_role" "gateway_rest_api_role" {
      name               = "${local.api_gw_name}-role"
      assume_role_policy = data.aws_iam_policy_document.api_gw_assume_role.json
    }
      
    #Création de la policy pour l'api gateway
    resource "aws_iam_policy" "api_gw_policy" {
      name   = "${local.api_gw_name}-policy"
      policy = data.aws_iam_policy_document.api_gw_policy_document.json
    }
      
    # Attachement de la policy au role de l'api gateway
    resource "aws_iam_policy_attachment" "api_gw_policy_attachment" {
      name = "${local.api_gw_name}-policy-attachment"
      roles = [
        aws_iam_role.gateway_rest_api_role.name
      ]
      policy_arn = aws_iam_policy.api_gw_policy.arn
    }
      
    # Création des logs d'exécution de l'api gateway dans cloudwatch
    resource "aws_cloudwatch_log_group" "this" {
      name              = "API-Gateway-Execution-Logs_${aws_api_gateway_rest_api.this.id}/dev"
      retention_in_days = 1
    }
      
    # Création de l'api gateway account l'autoriser à pusher les logs dans cloudwatch
    resource "aws_api_gateway_account" "this" {
      cloudwatch_role_arn = aws_iam_role.gateway_rest_api_role.arn
    }
      
    # Création de l'api gateway avec le l'OpenApi Spec
    resource "aws_api_gateway_rest_api" "this" {
      body = data.template_file.endpoints.rendered
      
      name = local.api_gw_name
      
      endpoint_configuration {
        # On utilise un endpoint regional pour éviter les coûts supplémentaires, mais vous voulez aller large et utiliser un endpoint edge
        types = ["REGIONAL"]
      }
    }
      
    # Activation des métriques et le log level à INFO, si vous voulez moins de logs, vous pouvez mettre ERROR ou OFF
    resource "aws_api_gateway_method_settings" "this" {
      rest_api_id = aws_api_gateway_rest_api.this.id
      stage_name  = aws_api_gateway_stage.this.stage_name
      method_path = "*/*"
      
      settings {
        metrics_enabled = true
        logging_level   = "INFO"
      }
    }
      
    # Déploiement de l'api gateway, on s'assure qu'a chaque fois l'OAS change, le déploiement se fait
    resource "aws_api_gateway_deployment" "this" {
      rest_api_id = aws_api_gateway_rest_api.this.id
      
      triggers = {
        redeployment = sha1(jsonencode(aws_api_gateway_rest_api.this.body))
      }
      
      lifecycle {
        create_before_destroy = true
      }
    }
      
    # Création du stage de l'api gateway, ici j'utilise dev mais vous pouvez utiliser prod, staging, etc.
    resource "aws_api_gateway_stage" "this" {
      deployment_id = aws_api_gateway_deployment.this.id
      rest_api_id   = aws_api_gateway_rest_api.this.id
      stage_name    = "dev"
      
      # On active les logs au format json, il y a d'autres formats disponibles
      # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html#set-up-access-logging-permissions
      access_log_settings {
        destination_arn = aws_cloudwatch_log_group.this.arn
        format          = jsonencode({ "requestId":"$context.requestId", "extendedRequestId":"$context.extendedRequestId","ip": "$context.identity.sourceIp", "caller":"$context.identity.caller", "user":"$context.identity.user", "requestTime":"$context.requestTime", "httpMethod":"$context.httpMethod", "resourcePath":"$context.resourcePath", "status":"$context.status", "protocol":"$context.protocol", "responseLength":"$context.responseLength" })
      }
      
      depends_on = [aws_api_gateway_account.this, aws_cloudwatch_log_group.this]
    }
      
    # Création du domaine personnalisé pour l'api gateway, si seulement vous avez un domaine.
    resource "aws_api_gateway_domain_name" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      domain_name              = "api.${var.domain}"
      regional_certificate_arn = aws_acm_certificate.this[0].arn
      
      endpoint_configuration {
        types = ["REGIONAL"]
      }
      
      # On ajoute le certificat ssl pour le domaine personnalisé avec l'Authority Certificate Manager (ACM) d"AWS qui est truster par la plupart des navigateurs et outils modernes.
      # On doit valider le certificat avec ACM avant de pouvoir l'utiliser avec API Gateway.
      #voir le fichier 02-route53.tf
      depends_on = [aws_acm_certificate.this, aws_acm_certificate_validation.this]
    }
      
    #Association du domaine personnalisé avec l'API Gateway.
    resource "aws_api_gateway_base_path_mapping" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      api_id      = aws_api_gateway_rest_api.this.id
      stage_name  = aws_api_gateway_stage.this.stage_name
      domain_name = aws_api_gateway_domain_name.this[0].domain_name
    }
      
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    data "aws_route53_zone" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      name         = var.domain
    }
      
    # Création de ligne du nom de domaine dans la zone DNS route53 et le target est l'api gateway (Regional)
    # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/api_gateway_domain_name#regional-acm-certificate
    resource "aws_route53_record" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      name    = "api.${var.domain}"
      type    = "A"
      zone_id = data.aws_route53_zone.this[0].zone_id
      
      alias {
        evaluate_target_health = true
        name                   = aws_api_gateway_domain_name.this[0].regional_domain_name
        zone_id                = aws_api_gateway_domain_name.this[0].regional_zone_id
      }
    }
      
    resource "aws_acm_certificate" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      domain_name               = "api.${var.domain}"
      subject_alternative_names = ["api.${var.domain}"]
      validation_method         = "DNS"
    }
      
    resource "aws_acm_certificate_validation" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      certificate_arn   = aws_acm_certificate.this[0].arn
    }
      
    
    • Définition de lambda authorizer (Infra et Code Source ) :

      Les lambdas sont des ressources particulières. En plus de leur déploiement via Terraform, elles nécessitent également du code applicatif pour implémenter la logique métier qu’elles doivent exécuter. Nous aborderons cela en détail dans la section suivante.

    • le code infra : infra-as-code/modules/common/03-lambda-authorizer.lambda.tf :
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    
        locals {
          authorizer_lambda_name = "lambda-authorizer"
        }
          
        data "aws_iam_policy_document" "lambda_authorizer_assume_role" {
          statement {
            effect = "Allow"
          
            principals {
              type        = "Service"
              identifiers = ["lambda.amazonaws.com"]
            }
          
            actions = ["sts:AssumeRole"]
          }
        }
          
        data "aws_iam_policy_document" "lambda_authorizer_policy_document" {
          statement {
            effect = "Allow"
          
            actions = [
              "logs:CreateLogGroup",
              "logs:CreateLogStream",
              "logs:PutLogEvents"
            ]
          
            resources = [
              "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/aws/lambda/${local.authorizer_lambda_name}:*"
            ]
          }
        }
          
        resource "aws_iam_policy" "lambda_authorizer_policy" {
          name   = "${local.authorizer_lambda_name}-policy"
          policy = data.aws_iam_policy_document.lambda_authorizer_policy_document.json
        }
          
        resource "aws_iam_role" "lambda_authorizer_role" {
          name               = "${local.authorizer_lambda_name}-role"
          assume_role_policy = data.aws_iam_policy_document.lambda_authorizer_assume_role.json
        }
          
        resource "aws_iam_policy_attachment" "lambda_authorizer_policy_attachment" {
          name       = "${local.authorizer_lambda_name}-policy-attachment"
          roles      = [aws_iam_role.lambda_authorizer_role.name]
          policy_arn = aws_iam_policy.lambda_authorizer_policy.arn
        }
          
        resource "aws_lambda_function" "lambda_authorizer" {
          filename      = "${path.module}/dist/authorizer.zip"
          function_name = local.authorizer_lambda_name
          role          = aws_iam_role.lambda_authorizer_role.arn
          handler       = "app.lambdaHandler"
          kms_key_arn = aws_kms_key.this.arn
          
          source_code_hash = filebase64sha256("${path.module}/dist/authorizer.zip")
          
          runtime = "nodejs22.x"
          
          environment {
            variables = {
              ISSUER_URI           = var.issuer_uri,
              JWKS_URI             = var.jwks_uri,
              AUDIENCE             = var.audience,
              POWERTOOLS_LOG_LEVEL = "INFO"
            }
          }
        }
          
        resource "aws_lambda_permission" "allow_api_gw_to_invoke_authorizer" {
          action        = "lambda:InvokeFunction"
          function_name = aws_lambda_function.lambda_authorizer.function_name
          principal     = "apigateway.amazonaws.com"
          source_arn    = "${aws_api_gateway_rest_api.this.execution_arn}/*"
        }
      
    
    • Initialisation du code de la lambda authorizer :

      Nous allons utiliser TypeScript (avec Node.js) comme langage de programmation pour ce projet. Pour démarrer rapidement, nous utiliserons AWS SAM (Serverless Application Model) afin de générer un code de base incluant quelques bonnes pratiques et configurations essentielles, telles que :

    • tsconfig : pour la configuration de TypeScript,

    • Prettier : pour le formatage du code,

    • ESLint : pour la vérification de la qualité du code.

    Une fois ce code de départ généré, nous le modifierons pour utiliser Terraform comme outil de déploiement.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    # A partir de la racine du projet
    cd lambda-src
    sam init --runtime nodejs22.x --dependency-manager npm  --package-type Zip  --name tmp-sam
    Which template source would you like to use?
            1 - AWS Quick Start Templates
            2 - Custom Template Location
    Choice: 1 # Choisir 1 pour utiliser les templates AWS
      
    Choose an AWS Quick Start application template
            1 - Hello World Example
            2 - GraphQLApi Hello World Example
            3 - Hello World Example with Powertools for AWS Lambda
            4 - Multi-step workflow
            5 - Standalone function
            6 - Scheduled task
            7 - Data processing
            8 - Serverless API
            9 - Full Stack
            10 - Lambda Response Streaming
    Template: 1 # Choisir 1 pour le Hello World Example
      
    Select your starter template
            1 - Hello World Example
            2 - Hello World Example TypeScript
    Template: 2 # Choisir 2 pour le Hello World Example TypeScript
      
    Would you like to enable X-Ray tracing on the function(s) in your application?  [y/N]: n
      
    Would you like to enable monitoring using CloudWatch Application Insights?
    For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]: n
      
    Would you like to set Structured Logging in JSON format on your Lambda functions?  [y/N]: y
    Structured Logging in JSON format might incur an additional cost. ....
      
    

    Une fois que la génération est terminée, récupérer le dossier projet typescript généré :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
    #Suppression des fichiers dont on a pas besoin
    rm -f tmp-sam/.gitignore  tmp-sam/samconfig.toml  tmp-sam/README.md tmp-sam/template.yaml
    cp -r tmp-sam/* .
    rm -rf tmp-sam
      
    # Renommer le projet, créer un dossier src et y déplacer le fichier app.ts
    mv hello-world authorizer
    cd authorizer && mkdir src && mv app.ts src
      
    # Je vais profiter de l'occasion pour copier le projet authorizer et le renommer en :
    # (get-account-info et init-user-account) deux lambdas qui seront utilisés plus tard
    cd .. && cp -r authorizer get-account-info && cp -r authorizer init-user-account
      
    # L'arborescence du projet authorizer ressemble à ça :
    ├── jest.config.ts
    ├── package-lock.json
    ├── package.json
    ├── src
    │   └── app.ts
    ├── tests
    │   └── unit
    │       └── test-handler.test.ts
    └── tsconfig.json
      
    # Installation des différents dépendances :
    cd authorizer
      
    yarn add @aws-lambda-powertools/logger jsonwebtoken jwks-rsa
    ou 
    npm install @aws-lambda-powertools/logger jsonwebtoken jwks-rsa
      
    

    Au final le package.json ressemble à ça, j’ai rajouté dans la section scripts, un script build qui utilise esbuild pour transpiler le code typescript en javascript.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    
    {
      "name": "authorizer",
      "version": "1.0.0",
      "description": "Lambda charger de l'authorization",
      "main": "src/app.js",
      "repository": "https://github.com/mombe090/blog-source-code.git ",
      "author": "Mamadou Yaya DAILLO @generated-with-sam-cli",
      "license": "MIT",
      "scripts": {
        "build": "esbuild src/app.ts --sourcemap --bundle --platform=node --target=es2020 --minify --external:aws-sdk --outfile=dist/app.js ",
        "unit": "jest",
        "lint": "eslint 'src/**/*.ts' --quiet --fix",
        "compile": "tsc",
        "test": "npm run compile && npm run unit"
      },
      "dependencies": {
        "@aws-lambda-powertools/logger": "^2.13.1",
        "esbuild": "^0.14.14",
        "jsonwebtoken": "^9.0.2",
        "jwks-rsa": "^3.1.0"
      },
      "devDependencies": {
        "@jest/globals": "^29.2.0",
        "@types/aws-lambda": "^8.10.92",
        "@types/jest": "^29.2.0",
        "@types/node": "^20.5.7",
        "@typescript-eslint/eslint-plugin": "^5.10.2",
        "@typescript-eslint/parser": "^5.10.2",
        "eslint": "^8.8.0",
        "eslint-config-prettier": "^8.3.0",
        "eslint-plugin-prettier": "^4.0.0",
        "jest": "^29.2.1",
        "prettier": "^2.5.1",
        "ts-jest": "^29.0.5",
        "ts-node": "^10.9.1",
        "typescript": "^4.8.4"
      }
    }
      
    

    Pour le code de la lambda elle-même, pour simplifier, j’ai choisi de mettre le code dans un seul fichier lambda-src/authorizer/src/app.ts, mais AWS récommande d’utiliser une architecture hexagonale pour les lambdas. consulter l’article de AWS

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    
      import { APIGatewayAuthorizerResult, APIGatewayTokenAuthorizerEvent } from 'aws-lambda';
      import jwt from 'jsonwebtoken';
      import jwksClient from 'jwks-rsa';
      import { Logger } from '@aws-lambda-powertools/logger';
        
      // Initialisation du logger avec la librairie installée précédemment aws-lambda-powertools
      const logger = new Logger({ serviceName: 'lambda-authorizer' });
        
      // Définition de la fonction lambdaHandler qui sera appelée par l'API Gateway
      export const lambdaHandler = async (event: APIGatewayTokenAuthorizerEvent): Promise<APIGatewayAuthorizerResult> => {
        logger.info('Le processus authorization commence ...');
        
        // Vérification des variables d'environnement nécessaires pour le processus d'authorization, elles sont définies dans le fichier [infra-as-code/modules/common/03-lambda-authorizer.lambda.tf]().
        if (
          process.env.AUDIENCE === undefined ||
          process.env.JWKS_URI === undefined ||
          process.env.ISSUER_URI === undefined
        ) {
          throw new Error("La variable d'environnement doit contenir AUDIENCE ou JWKS_URI ou ISSUER_URI ");
        }
        
        // Récupération du token d'authentification depuis l'événement
        logger.debug('Event', { event });
        const token = event.authorizationToken.replace('Bearer ', '');
        
        // Initialisation du client JWKS avec l'URI JWKS
        const client = jwksClient({ jwksUri: process.env.JWKS_URI });
        
        try {
          // On commence par décoder le token pour récupérer les informations nécessaires pour la vérification
          const decodedToken = jwt.decode(token, { complete: true });
          logger.debug('Decoded Token', { decodedToken });
        
          // Récupération de l'audience et de l'issuer depuis le token décodé
          const audience: string = decodedToken?.['payload']['aud'];
          const issuer: string = decodedToken?.['payload']['iss'];
        
          // Récupération de la clé publique depuis le JWKS avec l'ID de la clé (kid)
          const key = await client.getSigningKey(decodedToken?.['header']['kid']);
        
          // Vérification du token avec la clé publique
          jwt.verify(token, key.getPublicKey());
        
          // On vérifie que l'audience et l'issuer sont corrects, il est possible de pousser encore plus loin en vérifiant les scopes ou les rôles
          if (audience !== process.env.AUDIENCE) {
            throw new Error('Invalid audience');
          } else if (issuer !== process.env.ISSUER_URI) {
            throw new Error('Invalid issuer');
          }
        
          // Avec le principe de lambda Authorizer, l'Api attend après une réponse de type IAM Policy
        } catch (err) {
          logger.error('Error', { err });
        
          return {
            principalId: 'user',
            policyDocument: {
              Version: '2012-10-17',
              Statement: [
                {
                  Action: 'execute-api:Invoke',
                  Effect: 'Deny',
                  Resource: event.methodArn,
                },
              ],
            },
          };
        }
        
        logger.info('Utilisateur ou service authorise');
        return {
          principalId: 'user',
          policyDocument: {
            Version: '2012-10-17',
            Statement: [
              {
                Action: 'execute-api:Invoke',
                Effect: 'Allow',
                Resource: event.methodArn,
              },
            ],
          },
        };
      };
      
      
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    variable "apply_custom_domain" {
      type    = bool
      default = false
      description = "Utiliser pour s'avoir si on map l'api gateway à votre domaine via la Route53"
    }
      
    variable "domain" {
      type = string
      description = "Le domaine que vous voulez utiliser pour votre api gateway"
    }
      
    variable "aws_region" {
      type        = string
      default     = "ca-central-1"
      description = "AWS Region, changez par la région de votre choix"
    }
      
    variable "aws_account_id" {
      type = string
      description = "Le numéro de compte AWS que vous utilisez, vous pouvez le trouver en haut à droite de la console AWS"
    }
      
    variable "issuer_uri" {
      type        = string
      description = "L'url du serveur d'authentification (Auth0/github/entraID) par exemple"
    }
      
    variable "jwks_uri" {
      type        = string
      description = "Le lien vers le fichier jwks.json de votre serveur d'authentification, qui contient les informations de vos clés publiques"
    }
      
    variable "audience" {
      type = string
      description = "L'audience de votre serveur d'authentification, nous ferons une validation de l'audience dans les tokens JWT"
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    output "gateway_api_role" {
      value = {
        name = aws_iam_role.gateway_rest_api_role.name
        arn  = aws_iam_role.gateway_rest_api_role.arn
      }
    }
    output "gateway_api_rest" {
      value = {
        name          = aws_api_gateway_rest_api.this.name
        id            = aws_api_gateway_rest_api.this.id
        execution_arn = aws_api_gateway_rest_api.this.execution_arn
        stage_name    = aws_api_gateway_stage.this.stage_name
        domain_name   = var.apply_custom_domain ? aws_api_gateway_domain_name.this[0].domain_name : null
      }
    }
      
    output "route53_record" {
      value = var.apply_custom_domain ? {
        hostname = aws_route53_record.this[0].fqdn
        records  = aws_route53_record.this[0].records
        type     = aws_route53_record.this[0].type
      } : null
    }
      
    output "acm_certificate" {
      value = var.apply_custom_domain ? {
        certificate_arn       = aws_acm_certificate.this[0].arn
        certificate_authority = aws_acm_certificate.this[0].certificate_authority_arn
      } : null
    }
    
  • Configuration de l’environnement de dev :

    Pour chaque environnement, on peut avoir un dossier dans lequel on va initialiser le terraform et appliquer les configurations : Par exemple pour dev on :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    
    terraform {
      # Utilisation du provider AWS avec la version 5.86.0
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "5.86.0"
        }
      }
      
      # On utilise le backend S3 pour stocker l'état de notre infrastructure avec le bucket S3 aws-serverless-fintech-solution-statefile-bucket-xsxd3 crée précédemment.
      backend "s3" {
        bucket = "aws-serverless-fintech-solution-statefile-bucket-xsxd3" # remplace par votre bucket
        key    = "serverless-app-statefile/default/terraform.tfstate"
        region = "ca-central-1"
      
        #Vous devez avec terraform 1.10.0 et hashicorp/aws 5.86.0 ou plus pour utiliser le verrouillage
        # Lire la documentation pour plus d'informations : https://developer.hashicorp.com/terraform/language/upgrade-guides#s3-native-state-locking
        use_lockfile = true
      }
    }
      
    module "this" {
      source = "../../modules/common"
      
      aws_account_id = var.aws_account_id #remplacez par votre numéro de compte AWS
      aws_region     = var.aws_region     #remplacez par la region AWS de votre choix
      
      issuer_uri = var.issuer_uri
      jwks_uri   = var.jwks_uri
      
      sms_provider_api_url       = "https://api.nimbasms.com/v1/messages"
      sms_provider_client_id     = var.sms_provider_client_id
      sms_provider_client_secret = var.sms_provider_client_secret
      
      //remplacez par 'ON' pour activer les notifications SMS
      enable_sms_notifications   = "OFF"
      
      //remplacez par 'OFF' pour ne pas activer les notifications EMAIL
      enable_email_notifications = "ON"
      
      //remplacez par votre domaine si apply_custom_domain est true
      domain                     = var.domain
      apply_custom_domain        = true //remplacez par false si vous ne souhaitez pas appliquer de domaine personnalisé
      
      test_destination_email     = var.test_email
    }
      
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    
    variable "aws_region" {
      type    = string
      default = "ca-central-1"
    }
      
    variable "aws_account_id" {
      type    = string
    }
      
    variable "issuer_uri" {
      type        = string
      description = "L'url du serveur d'authentification (Auth0/github/entraID) par exemple"
    }
      
    variable "jwks_uri" {
      type        = string
      description = "Le lien vers le fichier jwks.json de votre serveur d'authentification"
    }
      
    variable "domain" {
      type    = string
      default = "remplacez-par-votre-domaine"
    }
      
    variable "audience" {
      type        = string
      description = "Utilisé pour la validation du token"
    }
      
    
  • infra-as-code/environments/dev/outputs.tf :

    1
    2
    3
    
    output "module_common" {
      value = module.this
    }
    
  • Builder le projet de la Lambda Authorizer :

    Le terraform étant configuré, il faut maintenant builder le projet de la Lambda Authorizer afin d’avoir l’artefact que va utiliser la lambda Authorizer (zip).

    Pour builder, zipper et déplacer le zip dans le bon repertoire du module commun, j’utiliserai l’outil Taskfile

    La tâcje build-and-deploy-dev, permet de builder, package en zip et le déposer dans le dossier commun des modules terraform dans un dossier dist pour que terraform puisse le deployer.

  • A la racine du projet : Taskfile.yml :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    
     version: '3'
        
     env:
       # Remplacer par les valeurs de votre environnement
       # Lectures variables d'environnement definies dans le fichier .envrc et definition de nouvelles prefixer TF_VAR_ pour terraform
       TF_VAR_aws_account_id: ""
       TF_VAR_domain: ""
       TF_VAR_issuer_uri: ""
       TF_VAR_jwks_uri: ""
       TF_VAR_audience: ""
        
     tasks:
        
       build-and-deploy-dev:
         cmds:
           - task: build-authorizer
           - task: tf-plan-dev
           - task: tf-apply-dev
        
       tf-plan-dev:
         dir: "infra-as-code/environments/dev"
         cmds:
           - terraform init
           - terraform plan
        
       build-authorizer:
         dir: "lambda-src/authorizer"
         cmds:
           - yarn run build
           - cd dist && zip -r authorizer.zip .
           - mkdir -p ../../infra-as-code/modules/common/dist
           - cp dist/authorizer.zip ../../infra-as-code/modules/common/dist/authorizer.zip
        
       tf-apply-dev:
         dir: "infra-as-code/environments/dev"
         cmds:
           - pwd
           - terraform fmt -recursive
           - terraform apply -auto-approve
        
       tf-destroy-dev:
         dir: "infra-as-code/environments/dev"
         cmds:
           - terraform destroy -auto-approve
        
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
     export AWS_ACCESS_KEY_ID=votre-access-key-id
     export AWS_SECRET_ACCESS_KEY=votre-secret-access-key
     export AWS_DEFAULT_REGION=votre-region
     export AWS_ACCOUNT_ID=votre-account-id
        
     # Fintech Solution
     export FINTECH_SOLUTION_DOMAINE="votre-domaine"
     export FINTECH_SOLUTION_ISSUER_URI="https://remplacer-par-votre-domaine.auth0.com/"
     export FINTECH_SOLUTION_JWKS_URI="https://remplacer-par-votre-domaine.auth0.com/.well-known/jwks.json"
     export FINTECH_SOLUTION_AUDIENCE="remplacer-par-votre-audience"
       
     cd infra-as-code/environments/dev
     direnv  allow # pour activer les nouvelles variables d'environnement
       
    
    • Déploiement de l’api gateway et de la lambda authorizer :

      Pour build la lambda et déployer toutes les resources terraform, exécutez la commande suivante :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    
     task build-and-deploy-dev
       
     # Le déploiement pourra prendre quelques minutes, donc soyez patient surtout si vous avez la certification qui activé.
     # Le resultat devrait être quelque chose comme ceci :
     *
     *
     *
     Apply complete! Resources: 21 added, 0 changed, 0 destroyed.
        
     Outputs:
        
     module_common = {
       "acm_certificate" = {
         "certificate_arn" = "arn:aws:acm:ca-central-1:654654388255:certificate/7b76c99c-b3b6-43ab-9f9d-f120cac9b326"
         "certificate_authority" = ""
       }
       "gateway_api_rest" = {
         "domain_name" = "api.doudhal-devops.com"
         "execution_arn" = "arn:aws:execute-api:ca-central-1:654654388255:mcgh1l274d"
         "id" = "mcgh1l274d"
         "name" = "fintech-solution-api"
         "stage_name" = "dev"
       }
       "gateway_api_role" = {
         "arn" = "arn:aws:iam::654654388255:role/fintech-solution-api-role"
         "name" = "fintech-solution-api-role"
       }
       "lambda_authorizer" = {
         "arn" = "arn:aws:lambda:ca-central-1:654654388255:function:lambda-authorizer"
         "execution_role_arn" = "arn:aws:iam::654654388255:role/lambda-authorizer-role"
         "name" = "lambda-authorizer"
       }
       "route53_record" = {
         "hostname" = "api.doudhal-devops.com"
         "records" = toset(null) /* of string */
         "type" = "A"
       }
     }
        
    
  • Vérification des resources créer sur la console web d’AWS :

    Assurez-vous d’avoir sélectionner la bonne region puis rechercher api gateway

    Selectionnez parmi la liste fintech-solution-api ou le nom que vous avez donner à votre api gateway :

    Sur l’interface à votre menu de gauche, cliquez sur authorizers puis sur JwtAuthorizeret remplisser le champ Token valueet cliquez sur Test Authorizer

    On voit que la lambda retourne une policy avec un effet de Deny

    Recupération d’un bon token sur Auth0 :

    Connectez-vous sur https://auth0.com puis sur le menu de gauche Applications -> Apis -> Selectionnez le nom de votre application créer ci-haut -> Parmi les onglets, choissez Test -> Copiez l'output du curl et executez-le sur un terminal

    Maintenant coller le token obtenu ci-haut juste après le **Bearer **

    On voit cette fois-ci la lambda retour une policy avec un effet de Allow

    Afficher les logs de l’authorizer dans cloud watch : Sur le champs de recherche, tapez cloud-watch :

    On voit une erreur sur la première invocation et tous se passe bien pour la séconde.

Tester l’Api Gateway via un client http :

Ci-dessous j’utilise mon domaine, mais si vous n’avez pas de domaine personnalisé, vous pouvez utiliser l’url par défaut de l’Api Gateway.

Cliquer sur stages, puis sur dev copier l’invoke url : https://8qojszj2f3.execute-api.ca-central-1.amazonaws.com/dev par exemple.

Normal pour le moment l’intégration entre l’Api Gateway et la lambda get-account-info n’est pas fait

Affichage des logs de l’Api pour vérifier le message d’erreur :

Maintenant on vas ajouter la lambda charger de retourner le solde du compte client avec ses intégrations avec les autres services AWS :

  • Ajout de la function lambda get account info:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    
      locals {
        get_account_info_name = "get-account-info"
      }
        
      data "aws_iam_policy_document" "get_account_info_lambda_assume_role" {
        statement {
          effect = "Allow"
        
          principals {
            type        = "Service"
            identifiers = ["lambda.amazonaws.com"]
          }
        
          actions = ["sts:AssumeRole"]
        }
      }
        
      data "aws_iam_policy_document" "get_account_info_lambda_policy_document" {
        statement {
          effect = "Allow"
        
          #Autorisation de créer des logs group, stream et d'envoyer des logs
          actions = [
            "logs:CreateLogGroup",
            "logs:CreateLogStream",
            "logs:PutLogEvents"
          ]
        
          resources = [
            "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/aws/lambda/${local.get_account_info_name}:*"
          ]
        }
        
        statement {
          effect = "Allow"
        
          #Autorisation de décrypter les données avec la clé KMS
          actions = [
            "kms:Decrypt",
          ]
        
          resources = [
            aws_kms_key.this.arn
          ]
        }
        
        statement {
          effect = "Allow"
        
          #Autorisation de lire les données dans dynamoDB
          actions = [
            "dynamodb:GetItem",
          ]
        
          resources = [
            aws_dynamodb_table.this.arn
          ]
        }
        
        statement {
          effect = "Allow"
        
          #Autorisation d'envoyer des messages dans la SQS
          actions = [
            "sqs:SendMessage",
          ]
        
          resources = [
            aws_sqs_queue.this.arn
          ]
        }
      }
        
      resource "aws_iam_policy" "get_account_info_lambda_policy" {
        name   = "${local.get_account_info_name}-policy"
        policy = data.aws_iam_policy_document.get_account_info_lambda_policy_document.json
      }
        
      resource "aws_iam_role" "get_account_info_lambda_role" {
        name               = "${local.get_account_info_name}-role"
        assume_role_policy = data.aws_iam_policy_document.get_account_info_lambda_assume_role.json
      }
        
      resource "aws_iam_policy_attachment" "get_account_info_lambda_policy_attachment" {
        name       = "${local.get_account_info_name}-policy-attachment"
        roles      = [aws_iam_role.get_account_info_lambda_role.name]
        policy_arn = aws_iam_policy.get_account_info_lambda_policy.arn
      }
        
      #Déclaration de la fonction lambda avec le code source zip
      resource "aws_lambda_function" "get_account_info" {
        filename      = "${path.module}/dist/get-account-info.zip"
        function_name = local.get_account_info_name
        role          = aws_iam_role.get_account_info_lambda_role.arn
        handler       = "app.lambdaHandler"
        
        source_code_hash = filebase64sha256("${path.module}/dist/get-account-info.zip")
        
        runtime = "nodejs22.x"
        
        environment {
          variables = {
            aws_region             = var.aws_region
            TABLE_NAME             = aws_dynamodb_table.this.name,
            INIT_ACCOUNT_QUEUE_URL = aws_sqs_queue.this.url
            POWERTOOLS_LOG_LEVEL   = "DEBUG"
          }
        }
      }
      
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    
      import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
      import type { GetItemOutput } from '@aws-sdk/client-dynamodb';
      import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
      import type { SendMessageResult } from '@aws-sdk/client-sqs';
      import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs';
      import * as process from 'node:process';
      import { Logger } from '@aws-lambda-powertools/logger';
        
      const logger = new Logger({ serviceName: 'get-account-info' });
        
      const awsConfig = {
        region: process.env.REGION,
      };
        
      export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
        logger.debug('evenement recu', { event });
        
        try {
          const msisdn: string = event.pathParameters?.msisdn;
        
          // Validation de la presence du numero de telephone dans la requete
          if (!msisdn) {
            logger.error('Le numero de telephone est requis');
            return getApiGatewayProxyResult(400, { message: 'Le numero de telephone est requis' });
          }
        
          // Verification si le numéro existe en base de données
          const accountInfo = await getAccountInfo(msisdn);
        
          if (!accountInfo.Item) {
            logger.debug('aucun de compte pour ce numero', { msisdn });
            logger.debug('Ajout de message a la queue de creation de compte', { msisdn });
        
            // Ajouter les informations pour initialiser le compte dans la file d'attente, s'il n'existe pas
            await putMessageToInitAccountQueue(getCloudEventFromContext(event));
        
            logger.debug('Message ajoute avec succes', { msisdn });
        
            return getApiGatewayProxyResult(200, {
              message:
                "Vous n'avez pas encore de compte de Paiement Mobile, un message vous sera envoye par SMS pour l'initialiser",
            });
          }
        
          return getApiGatewayProxyResult(200, {
              message: 'account info',
              data: {'msisdn': accountInfo.Item.msisdn.S, balance: accountInfo.Item.balance.N}
          });
        } catch (err) {
          logger.error(err);
          return getApiGatewayProxyResult(500, {
            message: "Une erreur s'est produite lors de la recuperation des informations du compte",
          });
        }
      };
        
      //Fonction charger de recuperer les informations du compte dans la table dynamoDB
      export const getAccountInfo = async (msisdn: string): Promise<GetItemOutput> => {
        const client = new DynamoDBClient(awsConfig);
        
        const command = new GetItemCommand({
          TableName: process.env.TABLE_NAME,
          Key: {
            msisdn: { S: msisdn },
          },
        });
        return await client.send(command);
      };
        
      //Fonction chargée d'envoyer un message à la file d'attente SQS pour la création de compte
      export const putMessageToInitAccountQueue = async (body: any): Promise<SendMessageResult> => {
        const client = new SQSClient({ awsConfig });
        
        const command = new SendMessageCommand({
          QueueUrl: process.env.INIT_ACCOUNT_QUEUE_URL,
          DelaySeconds: 10,
          MessageAttributes: {
            author: {
              DataType: 'String',
              StringValue: 'Mombesoft',
            },
            blogUrl: {
              DataType: 'String',
              StringValue: 'https://mombe090.github.io',
            },
          },
          MessageBody: JSON.stringify(body),
        });
        
        return await client.send(command);
      };
        
      //On utilise cloudEvent pour passer les messages entre les services
      //voir https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md#example
      export const getCloudEventFromContext = (event: APIGatewayProxyEvent) => {
        const date = new Date(event.requestContext.requestTimeEpoch);
        
        return {
          specversion: '1.0',
          type: 'gn.mombesoft.fintech-solution.get-account-info',
          source: 'https://github.com/mombe090/fintech-solution/spec/pull' #remplacer par votre spec,
          subject: 'get account info',
          id: event.requestContext.requestId,
          time: date.toISOString(),
          datacontenttype: 'application/json',
          data: {
            msisdn: event.pathParameters?.msisdn,
          },
        };
      };
        
      export const getApiGatewayProxyResult = (statusCode: number, body: any): APIGatewayProxyResult => {
        return {
          statusCode,
          body: JSON.stringify(body),
        };
      };
      
    

    son fichier de configuration est ici : lambda-src/get-account-info/package.json

    • Ajout de la Table Dynamodb qui vas stocker les informations sur le compte client :

    infra-as-code/modules/common/04-accounts.dynamodb.tf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    resource "aws_dynamodb_table" "this" {
      name         = "user-account-table"
      billing_mode = "PAY_PER_REQUEST"
      hash_key     = "msisdn" # numéro de telephone
      
      server_side_encryption {
        enabled     = true
        kms_key_arn = aws_kms_key.this.arn
      }
      
      attribute {
        name = "msisdn"
        type = "S"
      }
    }
      
    
    • Ajout de la File SQS pour stocker les messages d’ajout de nouveaux clients :

    infra-as-code/modules/common/05-init-account.sqs.tf :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    locals {
      init_user_account_queue_name = "init-user-account"
    }
      
    resource "aws_sqs_queue" "this" {
      name = local.init_user_account_queue_name
    }
      
    data "aws_iam_policy_document" "init_user_account_queue_policy_document" {
      statement {
        sid    = uuidv5("dns", "init-user-account-queue-policy-document")
        effect = "Allow"
      
        principals {
          type        = "AWS"
          identifiers = ["*"]
        }
      
        actions   = ["sqs:SendMessage"]
        resources = [aws_sqs_queue.this.arn]
          
        #Autorisation pour la lambda get_account_info à envoyer des message dans la queue
        condition {
          test     = "ArnEquals"
          variable = "aws:SourceArn"
          values   = [aws_lambda_function.get_account_info.arn]
        }
      }
    }
      
    resource "aws_sqs_queue_policy" "this" {
      queue_url = aws_sqs_queue.this.id
      policy    = data.aws_iam_policy_document.init_user_account_queue_policy_document.json
    }
      
    
    • Ajout du Secret Store Parameter pour enregistrer le client Id et Secret du sms provider:

    infra-as-code/modules/common/07-secret-store.tf :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    resource "aws_ssm_parameter" "sms_provider_credentials" {
      name   = "sms_provider_credentials"
      type   = "String"
      key_id = aws_kms_key.this.id
      value = jsonencode({
        "client_id" : var.sms_provider_client_id
        "client_secret" : var.sms_provider_client_secret
        "url" : var.sms_provider_api_url
      })
    }
      
    
    • Ajout du service de notification par email :

    infra-as-code/modules/common/08-ses.tf :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    
    // Creation de l'identité du domaine dans AWS SES
    resource "aws_ses_domain_identity" "domain_identity" {
      count = var.apply_custom_domain ? 1 : 0
      
      domain = var.domain
    }
      
    // Creation de l'identité du domaine dans AWS SES
    resource "aws_ses_domain_dkim" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      domain = aws_ses_domain_identity.domain_identity[0].domain
    }
      
    # Enregistrement des enregistrements DKIM dans la zone DNS
    resource "aws_route53_record" "ses_dkim_record" {
      count = var.apply_custom_domain ? 3 : 0
      
      zone_id = aws_route53_record.this[0].zone_id
      name    = "${aws_ses_domain_dkim.this[0].dkim_tokens[count.index]}._domainkey"
      type    = "CNAME"
      ttl     = "600"
      records = ["${aws_ses_domain_dkim.this[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
    }
      
    # Verification de l'identité du domaine dans AWS SES
    resource "aws_ses_domain_identity_verification" "this" {
      count = var.apply_custom_domain ? 1 : 0
      
      domain     = aws_ses_domain_identity.domain_identity[0].id
      depends_on = [aws_route53_record.ses_dkim_record]
    }
      
    # Ajout de l'identité de l'email de destination 
    resource "aws_ses_email_identity" "email_identity" {
      email = var.test_destination_email
    }
      
    

Ajout de la lambda pour initialiser un nouveau compte client :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
  locals {
    init_account_name = "init-user-account"
  }
  
  data "aws_iam_policy_document" "init_account_lambda_assume_role" {
    statement {
      effect = "Allow"
  
      principals {
        type        = "Service"
        identifiers = ["lambda.amazonaws.com"]
      }
  
      actions = ["sts:AssumeRole"]
    }
  }
  
  data "aws_iam_policy_document" "init_account_lambda_policy_document" {
    statement {
      effect = "Allow"
  
      actions = [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ]
  
      resources = [
        "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/aws/lambda/${local.init_account_name}:*"
      ]
    }
  
    statement {
      effect = "Allow"
  
      actions = [
        "kms:Decrypt",
      ]
  
      resources = [
        aws_kms_key.this.arn
      ]
    }
  
    statement {
      effect = "Allow"
  
      actions = [
        "dynamodb:PutItem",
      ]
  
      resources = [
        aws_dynamodb_table.this.arn
      ]
    }
  
    statement {
      effect = "Allow"
  
      actions = [
        "sqs:ReceiveMessage",
        "sqs:GetQueueAttributes",
        "sqs:DeleteMessage",
      ]
  
      resources = [
        aws_sqs_queue.this.arn
      ]
    }
  
    statement {
      effect = "Allow"
  
      actions = [
        "ssm:GetParameter",
      ]
  
      resources = [
        aws_ssm_parameter.sms_provider_credentials.arn
      ]
    }
  
    statement {
      effect = "Allow"
  
      actions = [
        "ses:SendEmail",
      ]
  
      resources = compact([
        var.apply_custom_domain ? aws_ses_domain_identity.domain_identity[0].arn : "",
        aws_ses_email_identity.email_identity.arn
      ])
    }
  }
  
  resource "aws_iam_policy" "init_account_lambda_policy" {
    name   = "${local.init_account_name}-policy"
    policy = data.aws_iam_policy_document.init_account_lambda_policy_document.json
  }
  
  resource "aws_iam_role" "init_account_lambda_role" {
    name               = "${local.init_account_name}-role"
    assume_role_policy = data.aws_iam_policy_document.init_account_lambda_assume_role.json
  }
  
  resource "aws_iam_policy_attachment" "init_account_lambda_policy_attachment" {
    name       = "${local.init_account_name}-policy-attachment"
    roles      = [aws_iam_role.init_account_lambda_role.name]
    policy_arn = aws_iam_policy.init_account_lambda_policy.arn
  }
  
  resource "aws_lambda_function" "init_account_lambda" {
    filename      = "${path.module}/dist/${local.init_account_name}.zip"
    function_name = local.init_account_name
    role          = aws_iam_role.init_account_lambda_role.arn
    handler       = "app.lambdaHandler"
  
    source_code_hash = filebase64sha256("${path.module}/dist/${local.init_account_name}.zip")
  
    runtime = "nodejs22.x"
  
    environment {
      variables = {
        aws_region                                  = var.aws_region
        TABLE_NAME                                  = aws_dynamodb_table.this.name,
        SMS_PROVIDER_API_CREDENTIALS_PARAMETER_NAME = aws_ssm_parameter.sms_provider_credentials.name,
        ENABLE_SMS_NOTIFICATIONS                    = var.enable_sms_notifications,
        ENABLE_EMAIL_NOTIFICATIONS                  = var.enable_email_notifications,
        DOMAIN_NAME                                 = var.domain,
        APPLY_CUSTOM_DOMAIN                         = var.apply_custom_domain,
        DESTINATION_EMAIL_EMAIL                     = var.test_destination_email,
        POWERTOOLS_LOG_LEVEL                        = "DEBUG"
      }
    }
  }
  
  resource "aws_lambda_event_source_mapping" "this" {
    event_source_arn = aws_sqs_queue.this.arn
    function_name    = aws_lambda_function.init_account_lambda.arn
  }
  
  resource "aws_lambda_permission" "allow_sqs_to_invoke_lambda" {
    action        = "lambda:InvokeFunction"
    function_name = aws_lambda_function.init_account_lambda.function_name
    principal     = "sqs.amazonaws.com"
    source_arn    = aws_sqs_queue.this.arn
  }
  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
    import { SSMClient, GetParameterCommand, GetParameterResult } from '@aws-sdk/client-ssm';
    import { DynamoDBClient, PutItemCommand, PutItemOutput } from '@aws-sdk/client-dynamodb';
    import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
    import * as process from 'node:process';
    import { Logger } from '@aws-lambda-powertools/logger';
    import type { SendEmailRequest, SendEmailResponse } from '@aws-sdk/client-ses';
    
    const logger = new Logger({ serviceName: 'get-account-info' });
    
    const awsConfig = {
      region: process.env.REGION,
    };
    
    export interface SQSRecord {
      messageId: string;
      receiptHandle: string;
      body: string;
      attributes: SQSRecordAttributes;
      messageAttributes: SQSMessageAttributes;
      md5OfBody: string;
      md5OfMessageAttributes?: string;
      eventSource: string;
      eventSourceARN: string;
      awsRegion: string;
    }
    
    export interface SQSEvent {
      Records: SQSRecord[];
    }
    
    export const lambdaHandler = async (event: SQSEvent): any => {
      try {
        const records = event.Records;
    
        for (const record of records) {
          const body = JSON.parse(record.body);
    
          logger.debug('information sur le nouveau compte', { body });
    
          const response = await addNewAccount(body.data.msisdn);
    
          logger.debug('compte créé avec succès !', { response });
    
          logger.info(
            'var envs : ',
            process.env.ENABLE_SMS_NOTIFICATIONS,
            process.env.ENABLE_EMAIL_NOTIFICATIONS,
            '',
          );
    
          if (process.env.ENABLE_SMS_NOTIFICATIONS === 'ON') {
            const credential = await getSmsProviderApiCredentials();
    
            const notification = await sendSmsNotificationToUser(body.data.msisdn, null, credential);
            logger.debug('notification envoyée avec succès !', { notification });
          }
    
          if (process.env.ENABLE_EMAIL_NOTIFICATIONS === 'ON') {
            const message = `Merci de rejoindre notre service ! Nous sommes ravis de vous avoir à bord. <br />`;
    
            const notification = await sendEmailNotificationToUser(
              body.data.msisdn,
              'Bienvenue sur notre service',
              message,
            );
    
            if (notification.$metadata.httpStatusCode !== 200) {
              logger.error(notification);
              throw new Error("Erreur lors de l'envoi de la notification", { notification });
            } else {
              logger.debug('notification envoyée avec succès !', { notification });
            }
          }
        }
      } catch (error) {
        logger.error({ error });
        throw new Error(error);
      }
    };
    
    export const addNewAccount = async (msisdn: string): Promise<PutItemOutput> => {
      const client = new DynamoDBClient(awsConfig);
    
      logger.debug("ajout d'un nouvel utilisateur avec le msisdn suivant : ", { msisdn });
      // https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-dynamodb/README.md
      const input = {
        Item: {
          msisdn: {
            S: msisdn,
          },
          balance: {
            N: '1000',
          }, // Montant initial en GNF
          createdAt: {
            S: new Date().toISOString(),
          },
        },
        TableName: process.env.TABLE_NAME,
      };
    
      return await client.send(new PutItemCommand(input));
    };
    
    export const getSmsProviderApiCredentials = async (): Promise<any> => {
      const client = new SSMClient(awsConfig);
      const command = new GetParameterCommand({
        Name: process.env.SMS_PROVIDER_API_CREDENTIALS_PARAMETER_NAME,
        WithDecryption: true,
      });
    
      logger.info('Récupération des credentials SMS_PROVIDER_API_KEY et SMS_PROVIDER_API_SECRET');
    
      const response: GetParameterResult = await client.send(command);
    
      const { client_id, client_secret, url } = JSON.parse(response.Parameter.Value);
    
      if (!client_id || !client_secret || !url) {
        throw new Error('le client_id ou client_secret ou url est manquant dans le paramètre SSM');
      }
    
      return { client_id, client_secret, url };
    };
    
    export const sendSmsNotificationToUser = async (msisdn: string, message: string, credential): Promise<any> => {
      logger.debug(`Envoi de notification à l'utilisateur ${msisdn} avec le message ${message} via ${credential.url}`);
      const response = await fetch(credential.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Basic ${Buffer.from(`${credential.client_id}:${credential.client_secret}`).toString(
            'base64',
          )}`,
        },
        body: JSON.stringify({
          to: [msisdn],
          sender_name: 'SMS 9080',
          message:
            message ||
            `Bonjour ceci est just un message de test, votre compte esssaie serverless fintech est active, avec un solde de 10000 frs, vous pouvez commencer à utiliser votre compte.`,
        }),
      });
    
      if (!response.ok) {
        logger.error(response.body);
        throw new Error('Erreur lors de l envoi de notification' + response.body);
      }
    
      logger.debug('Server response', { response: await response.json() });
    
      return await response.json();
    };
    
    export const sendEmailNotificationToUser = async (
      msisdn: string,
      title: string,
      message: string,
    ): Promise<SendEmailResponse> => {
      logger.debug(`Envoi de l email de notification à l'utilisateur ${msisdn} avec le message ${message} `);
    
      const client = new SESClient(awsConfig);
    
      const input: SendEmailRequest = {
        //On vérifie si on utilise un domaine personnalisé ou pas
        //Sinon on utilise l'email de destination par défaut
        Source: process.env.APPLY_CUSTOM_DOMAIN === true ? `api@${process.env.DOMAIN_NAME}` : process.env.DESTINATION_EMAIL_EMAIL,
        Destination: {
          ToAddresses: [process.env.DESTINATION_EMAIL_EMAIL],
        },
        Message: {
          Subject: {
            Data: 'Test from mombe090.github.io blog',
            Charset: 'UTF-8',
          },
          Body: {
            Html: {
              Data: `
                        <!DOCTYPE html>
                            <html lang="en">
                            <head>
                                <meta charset="UTF-8">
                                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                                <title>Mombe090.github.io blog serverless fintech mobile money tranfert sample</title>
                            </head>
                            <body style="font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0;">
                                <div style="width: 100%; max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);">
                                    <div style="text-align: center; padding: 20px 0; background-color: #007bff; color: #ffffff;">
                                        <h1 style="margin: 0; font-size: 24px;">${title}</h1>
                                    </div>
                                    <div style="padding: 20px;">
                                        <h2 style="color: #333333; font-size: 20px; margin-top: 0;">Bonjour numéro ${msisdn},</h2>
                                        <p style="color: #666666; font-size: 16px; line-height: 1.6;"></p>
                                        <p>
                                        ${message}
                                        </p>
                                        <ul style="color: #666666; font-size: 16px; line-height: 1.6;">
                                            <li>Username: nom complet </li>
                                            <li>Email : votre email</li>
                                            
                                        </ul>
                                        <a href="https://mombe0909.github.io" target="_blank" rel="noopener noreferrer" title="https://mombe0909.github.io" style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 5px; margin-top: 20px;">Lisez d'autres articles sur le blog.</a>
                                    </div>
                                    <div style="text-align: center; padding: 20px 0; background-color: #f8f9fa; color: #6c757d;">
                                        <p style="margin: 0; font-size: 14px;">&copy; 2025 Learn, Share, Grow. All rights reserved.</p>
                                    </div>
                                </div>
                            </body>
                            </html>
                        `, // required
              Charset: 'UTF-8',
            },
          },
        },
      };
    
      return await client.send(new SendEmailCommand(input));
    };

son fichier de configuration est ici : lambda-src/init-user-account/package.json

  • infra-as-code/modules/common/variables.tf :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    
      # ajouter ces nouvelles variables à la fin du fichier infra-as-code/modules/common/output.tf
      variable "sms_provider_client_id" {
        type      = string
        sensitive = true
      }
          
      variable "sms_provider_client_secret" {
        type      = string
        sensitive = true
      }
          
      variable "sms_provider_api_url" {
        type = string
      }
          
      variable "enable_sms_notifications" {
        type = string
        validation {
          condition     = contains(["ON", "OFF"], var.enable_sms_notifications)
          error_message = "La variable enable_sms_notifications doit être soit \"ON\" soit \"OFF\". \""
        }
      }
          
      variable "enable_email_notifications" {
        type = string
        validation {
          condition     = contains(["ON", "OFF"], var.enable_email_notifications)
          error_message = "La variable enable_sms_notifications doit être soit \"ON\" soit \"OFF\". \""
        }
      }
          
      variable "test_destination_email" {
        // nous utilisons la version production d'SES, donc nous devons fournir une adresse email et valider l'adresse email pour pouvoir envoyer des emails
        type = string
        validation {
          condition     = can(regex("^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", var.test_destination_email))
          error_message = "La variable test_destination_email doit être une adresse email valide."
        }
      }
        
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    
    # ajouter ces nouveaux output à la fin du fichier infra-as-code/modules/common/output.tf
    output "dynamo_db_table" {
      value = {
        name = aws_dynamodb_table.this.name
        arn  = aws_dynamodb_table.this.arn
      }
    }
        
    output "init_account_sqs_queue" {
      value = {
        name = aws_sqs_queue.this.name
        arn  = aws_sqs_queue.this.arn
        url  = aws_sqs_queue.this.url
      }
    }
        
    output "get_account_info_lambda" {
      value = {
        name               = aws_lambda_function.get_account_info.function_name
        arn                = aws_lambda_function.get_account_info.arn
        execution_role_arn = aws_iam_role.get_account_info_lambda_role.arn
      }
    }
        
    output "init_account_lambda" {
      value = {
        name               = aws_lambda_function.init_account_lambda.function_name
        arn                = aws_lambda_function.init_account_lambda.arn
        execution_role_arn = aws_iam_role.init_account_lambda_role.arn
      }
    }
        
    

Ajustement de l’environnement de dev :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  # ajouter en fin de fichier 
  variable "sms_provider_client_id" {
    type = string
  }
  
  variable "sms_provider_client_secret" {
    type = string
  }
  
  variable "test_email" {
    // nous utilisons la version production d'SES, donc nous devons fournir une adresse email et valider l'adresse email pour pouvoir envoyer des emails
    type = string
    validation {
      condition     = can(regex("^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", var.test_email))
      error_message = "La variable test_destination_email doit être une adresse email valide."
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
     #ajouter dans de module commun 
    sms_provider_api_url       = "https://api.nimbasms.com/v1/messages"
    sms_provider_client_id     = var.sms_provider_client_id
    sms_provider_client_secret = var.sms_provider_client_secret
  
    //remplacez par 'ON' pour activer les notifications SMS
    enable_sms_notifications = "OFF"
  
    //remplacez par 'OFF' pour ne pas activer les notifications EMAIL
    enable_email_notifications = "ON"

    test_destination_email = var.test_email

Ajustement du fichier Taskfile :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
  version: '3'
  
  env:
    FINTECH_SOLUTION_DIR : "./"
    TF_VAR_domain: ""
    TF_VAR_issuer_uri: ""
    TF_VAR_jwks_uri: ""
    TF_VAR_audience: ""
    TF_VAR_sms_provider_client_id: ""
    TF_VAR_sms_provider_client_secret : ""
    TF_VAR_test_email : ""
  
  tasks:
  
    build-and-deploy-dev:
      cmds:
        - task: build-authorizer
        - task: build-get-account-info
        - task: build-init-user-account
        - task: tf-plan-dev
        - task: tf-apply-dev
  
    tf-plan-dev:
      dir: "/infra-as-code/environments/dev"
      cmds:
        - terraform init
        - terraform plan
  
    tf-apply-dev:
      dir: "/infra-as-code/environments/dev"
      cmds:
        - pwd
        - terraform fmt -recursive
        - terraform apply -auto-approve
  
    tf-destroy-dev:
      dir: "/infra-as-code/environments/dev"
      cmds:
        - terraform destroy -auto-approve
  
    build-authorizer:
      dir: "/lambda-src/authorizer"
      cmds:
        - yarn
        - yarn run build
        - cd dist && zip -r authorizer.zip .
        - mkdir -p ../../infra-as-code/modules/common/dist
        - cp dist/authorizer.zip ../../infra-as-code/modules/common/dist/authorizer.zip
  
    build-get-account-info:
      dir: "/lambda-src/get-account-info"
      cmds:
        - yarn
        - yarn run build
        - cd dist && zip -r get-account-info.zip .
        - mkdir -p ../../infra-as-code/modules/common/dist
        - cp dist/get-account-info.zip ../../infra-as-code/modules/common/dist/get-account-info.zip
    
    build-init-user-account:
      dir: "/lambda-src/init-user-account"
      cmds:
        - yarn
        - yarn run build
        - cd dist && zip -r init-user-account.zip .
        - mkdir -p ../../infra-as-code/modules/common/dist
        - cp dist/init-user-account.zip ../../infra-as-code/modules/common/dist/init-user-account.zip
  

Builder et déployer à nouveau:

1
 task build-and-deploy-dev 

Quelques capture de nouvelles essaies :

Envoie d’une requête de verification de solde pour la première fois

Logs de la lambda get account info

Logs de la lambda init user account

Notification Par email

Vérification du solde après initialisation

Dans la partie 2, nous implémenterons le dépôt d’argent en utilisant des intégrations totalement asynchronnes.

Amusez-vous bien et faites des feedbacks, si ça jamais, il y a un problème avec les étapes ci-haut !

L’ensemble du code source utilisé dans ce blog se trouve sur github
This post is licensed under CC BY 4.0 by the author.