Creating a reCaptcha Form with an AWS backend and CDK

This Article was first posted on Medium by me. I made minor changes to the text but the overall content and the proposed solution is identical. - Marc

Furthermore article outdates the one i have written before about implementing a contact form protected by Google’s reCaptcha. This one is a bit more sophisticated when it comes to the details and its written with CDK, which makes copying it for your needs way easier. Why more sophisticated? Because i will show some nice new features in CDK like automated nodeJS packaging of functions (including libs) and custom domain handling in the API Gateway. The general architecture is the same as in my older article.

We still have a website with a simple contact form and a reCapatcha integration which sends it’s request to a lambda function which is behind an API Gateway. The lambda itself just does some parsing, a re-verfication of the Captcha via Google and puts a message in a SNS topic.

What you will need that’s not in the CDK project

Before getting to the CDK code, you need to do some things in the AWS console to get running. Since we want to communicate via HTTPS with the lambda (or the API gateway), we need a valid certificate. For this, you only need to visit “Certificate Manager” and create a new “public” certificate for your domain. You should do it in the US-EAST-1 region in order to be able to deploy the lambda on the Cloudfront Edge Servers. If you want to deploy it “regional”, you can of course just use your default region.

I wont go through the process of creating the certificate because it’s pretty self explanatory. At the end you should write down the ARN of your certificate because we will need it later in our CDK project.

Furthermore the CDK project assumes that you have your domain (and the DNS record) managed by Route53. If thats not the case, you need to change a bit of code in the CDK stack.

Let’s discuss the CDK code

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as lambdanode from '@aws-cdk/aws-lambda-nodejs';
import * as logs from '@aws-cdk/aws-logs';
import * as iam from '@aws-cdk/aws-iam';
import {EndpointType, LambdaRestApi, DomainName, SecurityPolicy} from "@aws-cdk/aws-apigateway";
import {Certificate} from "@aws-cdk/aws-certificatemanager";
import * as route53 from '@aws-cdk/aws-route53';
import * as sns from '@aws-cdk/aws-sns';
import * as targets from '@aws-cdk/aws-route53-targets';
import {EmailSubscription} from '@aws-cdk/aws-sns-subscriptions';

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

    let myDomain : string = "mydomain.de";
    let myEmail : string = `me@${myDomain}`;
    let certArn : string = "arn:aws:acm:us-east-1:XXXXXXXXX:certificate/YYYYYYYYY";
    let route53ZoneId : string = "XXXXXXXXX";
    
    const topic = new sns.Topic(this, 'CformTopic', {
      displayName: 'web cform topic',
      topicName: "CformTopic"
    });
    topic.addSubscription(new EmailSubscription(myEmail));

    let contactFormFunction = this.createContactFormFunction(topic.topicArn);

    let domainName = new DomainName(this, 'custom-domain', {
      domainName: `api.${myDomain}`,
      certificate: Certificate.fromCertificateArn(
          this,
          'apiGwCert',
          certArn
      ),
      endpointType: EndpointType.EDGE, // default is REGIONAL
      securityPolicy: SecurityPolicy.TLS_1_2
    });

    let api = new LambdaRestApi(this, 'contactFormGw', {
      handler: contactFormFunction,
    });

    domainName?.addBasePathMapping(api, {basePath: "cform"});
    this.createARecordForApiGw(myDomain, domainName, route53ZoneId);
  }

  private createARecordForApiGw(myDomain: string, domainName: DomainName, route53ZoneId : string) {
    let hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'okaycloudComZone', {
      zoneName: myDomain,
      hostedZoneId: route53ZoneId,
    })
    new route53.ARecord(this, 'CustomDomainAliasRecord', {
      zone: hostedZone,
      recordName: "api",
      target: route53.RecordTarget.fromAlias(new targets.ApiGatewayDomain(domainName))
    });
  }

  private createContactFormFunction(topicArn: string) : lambda.IFunction {
    let func = new lambdanode.NodejsFunction(this, 'MyFunctionNew', {
      entry: 'content/lambda/contactForm.ts', // accepts .js, .jsx, .ts and .tsx files
      handler: 'handler',
      environment: {TOPIC_ARN: topicArn},
      runtime: lambda.Runtime.NODEJS_12_X,
      logRetention: logs.RetentionDays.ONE_WEEK,
      description: "Contact Form Handler",
      functionName: "contactForm",
    });

    func.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      resources: ["*"],
      actions: ["cloudwatch:*",
        "logs:*",
        "lambda:*",
        "sns:*"],
    }));

    return func;
  }
}

As you can easily see, the four variables at the top of the constructor are user based variables which need to be modified by you. We will revisit this later on.

First we create a SNS topic. This topic is responsible for dispatching the incoming message to its subscribers. For now, we only add an email subscription to it. We use the email variable, which you should modify to suit your needs, as the target. You can easily add more subscribers like other lambda functions to put the contact into a CRM system or whatever you can think of. BTW the email generated by the emailSubscriber is pretty ugly, if you want to have a more eye friendly email, you could write a lambda for that too, but i am too lazy for that.

The handler function

The topic ARN will be provided to a function which is responsible for creating the lambda function. Up until recently i used different tools for deploying lambda functions like serverless framework or AWS SAM. But with the help of the quite new construct NodejsFunction from the @aws-cdk/aws-lambda-nodejs packge, this are getting better on the CDK side, when it comes to TS/JS packaging.

CDK Logo

The NodejsFunction construct behaves very mich like its counterpart Function from the @aws-cdk/aws-lambda package but has some more attributes to handle the packaging part. To read more about it, just head over to https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-nodejs-readme.html. For now, let’s just say that this construct is able to detect third party libraries in your code by analyzing the imports and package it accordingly. Something that @aws-cdk/aws-lambda.Function can’t provide. You will see later that we will use the Axios http 3rd party library in the lambda so we need a packaging solution here. At the end this will create a function with your code and all the 3rd party libs inlined. And to be surprised: When you deploy the stack, CDK will download a fairly large docker container to do all this packaging. The container is kind of a NodeJS Lambda Runtime right on your machine to mimic the AWS backend and to make sure things are ok. Furthermore please note the 

environment: {TOPIC_ARN: topicArn} (Line 65)

Here we put the ARN of the topic into the environment variable space of the function in order to access it later.

You will also note that we created a fairly large set of permissions in the policy. You should narrow those down. I didn’t had the nerve to fine tune them for this article. Note to myself: make it better next time :-)

Below is the typescript code for the lambda function which receives the form from the HTML frontend:

import Sns from "aws-sdk/clients/sns";
import axios from 'axios';
import * as querystring from 'querystring';

const reCapUrl = "https://www.google.com/recaptcha/api/siteverify";

// we got this from personal reCaptcha Google Page
const reCaptchaSecret = "xxxxxxxxxxxxxxxxxxxxxxxx" ;

function bodyToMap(parts: any) : Map<String, String>{
    let result = new Map();
    // grab the params
    for (let i = 0, len = parts.length; i < len; i++) {
        let kVal = parts[i].split('=');
        // replace the + space then decode
        let key = decodeURIComponent(kVal[0].replace(/\+/g, ' '));
        result.set(key, decodeURIComponent(kVal[1].replace(/\+/g, ' ')));
    }
    return result;
}

export const handler = async (event: any = {}): Promise<any> => {
    console.log("Starting ContactForm Processing for website okaycloud form.");

    let body = event.body;
    // process the urlencoded body of the form submit and put it in a
    // map structure
    let parts = body.split('&');
    let result = bodyToMap(parts);

    // its always a good idea to log so that we can inspect the params
    // later in Amazon Cloudwatch
    //console.log(result);

    let data = querystring.stringify({
        secret: reCaptchaSecret,
        response: result.get("g-recaptcha-response")
    });

    //console.log(`Verify Post Data: ${JSON.stringify(data)}`);
    //console.log(`Verify Post Data Form Encoded: ${data}`);

    // verify the result by POSTing to google backend with secret and
    // frontend recaptcha token as payload
    let verifyResult = await axios.post(reCapUrl, data);

    // if you like you can also print out the result of that. Its
    // a bit verbose though
    //console.log(`Success ist: ${JSON.stringify(verifyResult.data)}`);

    if (verifyResult.data.success) {
        let emailbody = `—— Contactform —-
            
            Name: ${result.get('FULLNAME')} 
            
            Email: ${result.get('EMAIL')}
            Tel: ${result.get('PHONE')}
            
            Thema: ${result.get('SUBJECT')}
            
            * Nachricht * 
            ${result.get("MESSAGE")}
         `;

        let sns = new Sns();

        let params = {
            Message: emailbody,
            Subject: `Contactform:  ${result.get("SUBJECT")}`,
            TopicArn: process.env.TOPIC_ARN
        };

        // we publish the created message to Amazon SNS now…
        await sns.publish(params).promise();

        // now we return a HTTP 302 together with a URL to redirect the
        // browser to success URL (we put in google.com for simplicty)
        return {
            statusCode: 302,
            headers: {
                Location: "https://mydomain.com/contact_success.html",
            }
        };
    } else {
        console.log("reCaptcha check failed. Most likely SPAM.");
        return {
            statusCode: 302,
            headers: {
                Location: "https://mydomain.com/contact_failure.html",
            }
        };
    }
};

This typescript function basically does 4things:

  • parsing the body of the form submission and putting the params in a map
  • Verifying the request against Google reCaptcha
  • creating a message which gets put into the SNS topic
  • redirecting the browser to a success or failure html page

The most important things for you are to change the variable reCapatchSecret to your secret key and to modify the URLs for the 302 redirect. Of course you need a reCaptcha Account in order to use it but this should be pretty self explanatory. You can grab the keys for the HTML Form and the secret key for this script from the google admin page. If you have problems with the runtime behavior of the script, you can simple uncomment the console.log() lines to check them in cloudwatch logs.

Wire the function into API Gateway

Now that we have a function created in the CDK project, the code on line 30 and following does the setup regarding AWS API Gateway. We first create a “custom Domain” for the GW and supply the ARN of our certificate and also define that we want to have Lambda Edge for better performance (we expect massive worldwide traffic of course :-)). Then we create the Api itself and supplying the NodeJS function and yes, its in fact only three lines of code or a one-liner if you put the braces into one line. Because we created the Custom Domain separately from the Api creation, we need to wire them together via the domainName.addBasePathMapping() call. You can do it also right into the LambdaRestApi options but then you cant have another ApiGateway pointing to the same custom domain. So it’s more future proof to do the way i have done it in the source code.

At the end i just import my Route53 DNS Zone into the CDK project and add a A-Record (api subdomain) which points to the API Gateway. You need the HostedZoneID for importing so change that ID at the top of the code. If you don’t use Route53 for your domains / DNS, just remove the createARecordForApiGw() function call and the function itself and do the A-record manually at your domain provider. But if you destroy the stack and recreate it, most likely you get a new hostname for your API Gateway which means you need to apply the changed hostname to the 3rd party DNS provider. If you use Route53, all this is handled for your automatically on stack creation. That’s the beauty of staying inside the AWS world, it makes things easier most of the time.

HTML frontend

reCaptcha in Action

The frontend is pretty straighforward, just replace the HTML website reCapcha key data-sitekey with the correct value you get from google and change the action in the onSubmit() to your domain as defined in the CDK project. You can of course also remove jQuery if you circumvent the use of the prop() function.

<html>
  <head>
    <script src="https://www.google.com/recaptcha/api.js"></script>
    <script src="https://code.jquery.com/jquery-3.5.1.min.js"
                  integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
                  crossorigin="anonymous">
    </script>
    <script>
       function onSubmit(token) {
          $("#ContactForm").prop("action", "https://api.mydomain.com/cform");
          document.getElementById("ContactForm").submit();
       }
     </script>
  </head>
  <body>
    <form name="contact-form" id="ContactForm" method="post" action="#">
           <div class="row">
               <div class="col-md-6">
                   <label for="InputName">Name*</label>
                   <input type="text" class="form-control" id="InputName" name="FULLNAME">
               </div>
               <div class="col-md-6">
                   <div class="form-group">
                       <label for="InputEmail"Email*</label>
                       <input type="email" class="form-control" id="InputEmail" name="EMAIL">
                   </div>
               </div>
               <div class="col-md-6">
                   <label for="InputMobile">Phone</label>
                   <input type="text" class="form-control" id="InputMobile" name="PHONE">
               </div>
               <div class="col-md-6">
                   <div class="form-group">
                       <label for="InputSubject">Subject*</label>
                       <input type="text" class="form-control" id="InputSubject" name="SUBJECT">
                   </div>
               </div>
               <div class="col-md-12">
                   <div class="form-group">
                       <label for="InputMessage">Message*</label>
                       <textarea class="form-control" id="InputMessage" name="MESSAGE"></textarea>
                   </div>
               </div>

                <div class="col-md-12 text-center">
                    <button class="g-recaptcha btn btn-theme-primary"
                        data-sitekey="yyyyyyyyyyyyyyyyyyyyyy"
                        data-callback='onSubmit'
                        data-action='submit'>Submit</button>
                </div>
           </div>
       </form>
   </body>
</html>

Finally

To run this code without problems you need to use the outlined stack inside a CDK project. The code also assumes that the typescript backend function resides in $PROJECT_ROOT$/content/lambda/ folder. Check the code on line 63: 

entry: ‘content/lambda/contactForm.ts’

Apart from the certificate, you have everything in place with this CDK project. You just need a website with a contact form which looks like the one i outlined. You can host this website with AWS Cloudfront, Netlify or whatever service you like.