Securing File Delivery with Terraform, Lambda@Edge, CloudFront, and S3

2023.07.18

Introduction:

Delivering files securely while ensuring proper authentication is crucial for many applications. In this tutorial, we will explore how to leverage the power of Lambda@Edge, CloudFront, and S3 to create a robust file delivery system with authentication capabilities.

Prerequisites:

Before we begin, make sure you have the following:

1.An AWS account 2.Basic knowledge of AWS Lambda, CloudFront, and S3

Diagram

I tried

Step 1: Set up an S3 bucket: First, create an S3 bucket and upload the files you want to deliver securely. Ensure that the bucket permissions allow access from CloudFront.

Step 2: Configure CloudFront: Create a CloudFront distribution and configure it to use your S3 bucket as the origin. Specify the caching behavior, default root object, and any additional settings according to your requirements.

Step 3: Create an AWS Lambda function: Now, it's time to create the Lambda function that will handle authentication and request processing at the edge locations. You can write this function in the Lambda console or use your preferred programming language. Remember that the function runs in response to CloudFront events, so you can take advantage of the viewer-request event to perform authentication.

Step 4: Associate the Lambda function with CloudFront: Associate the Lambda function you created in the previous step with the appropriate CloudFront distribution. This ensures that the function is triggered for each incoming request at the edge locations.

Step 5: Implement authentication logic: Within your Lambda function, implement the authentication logic based on your requirements. This can involve checking credentials, validating tokens, or any other authentication mechanism you prefer. You can access request information and modify headers using the event object provided to the function.


import base64

# username = "admin"
# password = "secret123"

# credentials = f"{username}:{password}"
# base64_credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")

#Base64 encoded username:password
AUTHORIZATION = "Basic YWRtaW46c2VjcmV0MTIz"

ERROR_RESPONSE_AUTH = {
    'status': '401',
    'statusDescription': 'Unauthorized',
    'body': 'Authentication Failed',
    'headers': {
        'www-authenticate': [
            {
                'key': 'WWW-Authenticate',
                'value': 'Basic'
            }
        ]
    }
}

def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    headers = request['headers']

    # Retrieve the Authorization header from the request
    auth_header = headers.get('authorization', None)
    if auth_header:
        # Decode the Base64 encoded username:password
        decoded_auth = auth_header[0]['value']
        # Check if the decoded username:password matches the expected value
        if decoded_auth == AUTHORIZATION:
            return request

    # If the authorization fails or no authorization header is present, return the authentication error response
    return ERROR_RESPONSE_AUTH

Step 6: Grant access or return an error response: Based on the authentication result, you need to either grant access to the requested file or return an error response. If the authentication is successful, modify the request headers in the viewer-request event to allow access to the file. On the other hand, if authentication fails, return an appropriate error response to prevent unauthorized access.

Step 7: Deploy the Lambda function: Once you have implemented and tested your Lambda function locally, it's time to deploy it to the AWS Lambda service. Deploying the function makes it available for execution across the CloudFront edge locations.

Step 8: Test the authentication flow: Finally, test the authentication flow by accessing your files through the CloudFront distribution URL. Verify that the authentication logic is working as expected. Test both successful authentication and failed authentication scenarios to ensure the proper handling of access requests.

Architecture

Settings

the link provideded in this blog could be in japanese please translate if required

S3 Bucket general settings

The configuration includes two buckets: bucket1 and bucket2. Bucket1 contains all the files, while bucket2 is set up to redirect requests to bucket1.

Configuration bucket1 Remark
Name www.xxxx.info To ensure uniqueness and alignment with static website hosting domains, the bucket name should match the hosted domain.
AWS Region us-east-1 the cost of us-east-1 is cheaper then other region for PerGB storage and easily configured to service like cloudfront and lambda@edge
Object Ownership ACLs disabled
Block Public Access settings for this bucket Disable for website hosting Enable this setting to restrict public access to the bucket and its objects. only rest api can be used after enabling
Bucket Versioning Enable Enabling versioning allows recovery from unintended user actions or application failures by preserving all versions of objects.
Tags Name: .xxx.xxxxxx.xxx
ManagedBy: Management Console
Environment: Dev
Tags can help with resource identification and management.
Encryption Type SSE-S3 Server-side encryption with SSE-S3 ensures data at rest is encrypted using AWS S3-managed keys.
Bucket Key Enable Enabling Bucket Keys reduces the request traffic from Amazon S3 to AWS KMS and reduces the cost of SSE-KMS.
Object Lock Disabled Object Lock provides an additional layer of protection by preventing object deletion or modification for a specified retention period.
Multi-factor authentication (MFA) delete Disabled Enabling MFA delete adds an extra layer of security, requiring MFA authentication for object and version deletion operations.

Other S3 Settings

Configuration values Remarks
Intelligent Tiering N/A https://dev.classmethod.jp/articles/amazon-s3-intelligent-tiering-further-automating-cost-savings-for-short-lived-and-small-objects/
Server Access logging Enable
Target Bucket s3://logs/s3
Lifecycle rules N/A https://dev.classmethod.jp/articles/understand-how-the-s3-lifecycle-rules-work/
Replication rules N/A  
Inventory configurations N/A https://dev.classmethod.jp/articles/s3-inventory-reinvent/
Access Points N/A https://dev.classmethod.jp/articles/s3-access-restrict/ 

CloudFront Distribution

Configuration values Remarks
Distribution domain name dxxxxxxxx.cloudfront.net
Origin domain www.xxx.xxxxxx.xxx.s3.us-east-1.amazonaws.com  this time we are using s3 as origin
Origin path -
Name www.xxx.xxxxxx.xxxinfo
Origin access public to only allow access from cloudfront to s3
Add custom header -
Enable Origin Shield - https://dev.classmethod.jp/articles/amazon-cloudfront-support-cache-layer-origin-shield/
Connection attempts 3 
Connection timeout 10

Behavior

Configuration Values
Behavior Default cache behavior
Compress objects automatically No
Viewer protocol policy Redirect HTTP to HTTPS
Allowed HTTP methods GET, HEAD
Restrict viewer access No
Cache key and origin requests Legacy cache settings
Minimum TTL: 0
Maximum TTL: 31536000
Default TTL: 86400 sec
Response headers policy -
Read more
Smooth streaming No
Enable real-time logs No
Function association Yes (refer to a different table)
Web Application Firewall (WAF) Do not enable security protections

Function association:

event function Type  Function ARN / Name |Include body
Viewer request Lambda@edge arn:aws:lambda:us-east-1:xxxxxxxxxx:IPAuth:x|no

Settings

Configuration values Remarks
Price class all edge location all edge location provide best performance
Alternate domain name (CNAME) www.xxx.xxxxxxx.info This is the domain name to be set on the CloudFront side. Access to CloudFront will be possible using this domain name.
Custom SSL certificate www.xxx.xxxxxxx.info  If using ACM the certificate should be in us-east-1 region
Supported HTTP versions HTTP 1.0, HTTP 1.1,HTTP/2
Default root object index.html https://dev.classmethod.jp/articles/cloudfront-distributions-should-have-a-default-root-object-configured/
Standard logging Off
S3 bucket for logging - https://dev.classmethod.jp/articles/cloudfront-access-log-dont-choose-no-acl-s3-bucket/
IPv6 off
Security Policy TLSv1

Lambda@Edge

Configuration value Remarks
Function name dio-dev-lambda-function
Runtime Python 3.7
Architecture x86_64 graviton instance will give better price performance
Update runtime version Auto
Role name cloudfront_access_lambda
Policy name my_inline_policy
Enable Code signing No check
Enable function URL No check
tags ManagedBy terraform Project dio-dev-blog
Configure cross-origin resource sharing (CORS) no check
Enable VPC No check
Memory 128MB
Ephemeral storage 512MB
SnapStar None
Logs and metrics (default) Enabled
Enhanced monitoring Not enabled
Code profiling Not enabled
Active tracing Not enabled

Route 53

Record name Type Routing policy Alias Route traffic to TTL (seconds) Evaluate target health
www..xxx.xxxxxx.xxxinfo A Simple Yes dxxxxxxxxxxx.cloudfront.net. - yes

 Template


data "aws_caller_identity" "current" {}

#redirect to other bucket
resource "aws_s3_bucket" "redirect_website" {
  bucket = "xxx.xxxxxx.xxxinfo"
  tags = {
    Name        = ".xxx.xxxxxx.xxxinfo"
    Environment = "Dev"
  }
}

resource "aws_s3_bucket_website_configuration" "redirect_website_configuration" {
  bucket = aws_s3_bucket.redirect_website.bucket

  redirect_all_requests_to{
    host_name = aws_s3_bucket.static_website.bucket
  }
}

#for static website hosting keep the bucketname same as domain name

resource "aws_s3_bucket" "static_website" {
  bucket = "www.xxx.xxxxxx.xxx.info"
  tags = {
    Name        = "www.xxx.xxxxxx.xxx.info"
    Environment = "Dev"
  }
}

resource "aws_s3_bucket_public_access_block" "public_access_block" {
  bucket = aws_s3_bucket.static_website.id
  ignore_public_acls      = false
  block_public_acls       = false
  restrict_public_buckets = false
  block_public_policy     = false
}

resource "aws_s3_bucket_policy" "allow_access_from_internet" {
  bucket = aws_s3_bucket.static_website.id
  policy = data.aws_iam_policy_document.allow_access_from_internet.json
}

data "aws_iam_policy_document" "allow_access_from_internet" {
  statement {
    principals {
      type        = "*"
      identifiers = ["*"]
    }

    actions = [
      "s3:GetObject"
    ]

    resources = [
      "${aws_s3_bucket.static_website.arn}/*",
    ]
  }
}

resource "aws_s3_bucket_versioning" "versioning_example" {
  bucket = aws_s3_bucket.static_website.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket" "log_bucket" {
  bucket = "static-website-log-bucket"
}

resource "aws_s3_bucket_logging" "static_website_log" {
  bucket = aws_s3_bucket.static_website.id

  target_bucket = aws_s3_bucket.log_bucket.id
  target_prefix = "log/"
}

#uploading the static website files to s3 
# resource "aws_s3_object" "object" {
#  for_each = fileset("build/", "**")
#   bucket = aws_s3_bucket.static_website.id
#   key    = each.value
#   source = "build/${each.value}"
#   content_type = "text/html"
# }

resource "aws_s3_object" "object" {
  bucket = aws_s3_bucket.static_website.id
  key    = "index.html"
  source = "./index.html"
  content_type = "text/html"
}

resource "aws_s3_object" "object2" {
  bucket = aws_s3_bucket.static_website.id
  key    = "lockedindex.html"
  source = "./lockedindex.html"
  content_type = "text/html"
}

resource "aws_s3_bucket_website_configuration" "website_configuration" {
  bucket = aws_s3_bucket.static_website.bucket

  index_document {
    suffix = "index.html"
  }
}

#if want to use s3 without cloudfront
# resource "aws_route53_record" "route53_record1" {
#   zone_id = "Zxxxxxxxxxxxxxxxx"
#   name    = "www.xxx.xxxxxx.xxx.info"
#   type    = "A"
#   alias {
#     name                   = aws_s3_bucket.static_website.website_domain
#     zone_id                = aws_s3_bucket.static_website.hosted_zone_id
#     evaluate_target_health = true
#   }
# }

resource "aws_route53_record" "route53_record1" {
  zone_id = "Zxxxxxxxxxxxxxxx"
  name    = "www..xxx.xxxxxx.xxxinfo"
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.static-www.domain_name
    zone_id                = "Z2FDTNDATAQYW2"
    evaluate_target_health = true
  }
}


resource "aws_acm_certificate" "cert" {
  domain_name       = "www.xxx.xxxxxx.xxxinfo"
  validation_method = "DNS"

  tags = {
    Environment = "dev"
  }

  lifecycle {
    create_before_destroy = true
  }
}


resource "aws_cloudfront_distribution" "static-www" {
    origin {
        domain_name = aws_s3_bucket.static_website.bucket_regional_domain_name
        origin_id = aws_s3_bucket.static_website.id
    }

    enabled =  true

    default_root_object = "index.html"

    aliases = ["www.xxx.xxxxxx.xxx.info"]

    default_cache_behavior {
        allowed_methods = [ "GET", "HEAD" ]
        cached_methods = [ "GET", "HEAD" ]
        target_origin_id = aws_s3_bucket.static_website.id
        
        forwarded_values {
            query_string = false

            cookies {
              forward = "none"
            }
        }

        lambda_function_association {
            event_type   = "viewer-request"
      lambda_arn   = "${aws_lambda_function.lambda.arn}:${aws_lambda_function.lambda.version}"
            include_body = false
        }

        viewer_protocol_policy = "redirect-to-https"
        min_ttl = 0
        default_ttl = 3600
        max_ttl = 86400
    }

    restrictions {
      geo_restriction {
          restriction_type = "whitelist"
          locations = [ "JP" ]
      }
    }
    viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.cert.arn
    ssl_support_method = "sni-only"
}
}


#Lambda用IAMロールの信頼関係の定義
data aws_iam_policy_document assume_role {
    statement {
        effect = "Allow"
        principals {
            type = "Service"
            identifiers = [
                "lambda.amazonaws.com",
                "edgelambda.amazonaws.com"
            ]
        }
        actions = ["sts:AssumeRole"]
    }
}
#Lambda用IAMロールの作成
resource aws_iam_role iam_for_lambda {
    name               = "cloudfront_access_lambda"
    assume_role_policy = data.aws_iam_policy_document.assume_role.json
    inline_policy {
        name = "my_inline_policy"
        policy = jsonencode({
            Version = "2012-10-17"
            Statement = [
                {
                    Action   = [
                        "lambda:InvokeFunction",
                        "lambda:GetFunction",
                        "lambda:EnableReplication",
                        "cloudfront:UpdateDistribution"
                    ]
                    Effect   = "Allow"
                    Resource = "*"
                },
            ]
        })
    }
}
data archive_file lambda {
    type        = "zip"
    source_file = "lambda/lambda.py"
    output_path = "lambda_handler.zip"
}
resource aws_lambda_function lambda {
    filename      = "lambda_handler.zip"
    function_name = "IPAuth"
    role          = aws_iam_role.iam_for_lambda.arn
    handler       = "lambda.lambda_handler"
    source_code_hash = data.archive_file.lambda.output_base64sha256
    runtime = "python3.7"
}

Conclusion:

By following these steps, you have successfully created a secure file delivery system using Lambda@Edge, CloudFront, and S3. The Lambda function at the edge locations handles authentication, ensuring that only authorized users can access the files. This combination of services provides efficient and secure file delivery with fine-grained control over access.

Additional Considerations:

You can enhance the authentication logic by integrating with other authentication services like AWS Cognito, OAuth providers, or custom user databases. Implementing caching mechanisms within CloudFront can further optimize the delivery of files while maintaining security. Monitor and log authentication attempts to identify any suspicious activities and improve the overall security of your application.