├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app.yml ├── config.example.json ├── deploy ├── index.js └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lambci/lambda:build-nodejs8.10 2 | 3 | # working folder 4 | RUN mkdir /build 5 | WORKDIR /build 6 | 7 | # install dependencies (highly cacheable) 8 | COPY package.json /build/package.json 9 | RUN npm install --production 10 | 11 | # add source code 12 | COPY index.js /build/index.js 13 | 14 | # zip entire context and stream output 15 | RUN zip -r /build/dist.zip . > /dev/null 16 | CMD ["cat", "/build/dist.zip"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 thumbsup 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambda-cloudfront-cookies 2 | 3 | > AWS Lambda to protect your CloudFront content with username/passwords 4 | 5 | ## How it works 6 | 7 | The first step is to have CloudFront in front of your S3 bucket. 8 | 9 | ``` 10 | Browser ----> CloudFront ----> S3 bucket 11 | ``` 12 | 13 | We then add a Lambda function responsible for logging-in. 14 | When given valid credentials, this function creates signed session cookies. 15 | CloudFront will verify every request has valid cookies before forwarding them. 16 | 17 | ``` 18 | Browser CloudFront Lambda S3 19 | | | | | 20 | | ---------- get ---------> | | | 21 | | | | | 22 | | [no cookie] | | 23 | | | | | 24 | | | | | 25 | | | | | 26 | | <------ error page ------ | | | 27 | | | | 28 | | -------------------- login ------------------> | | 29 | | <------------------- cookies ----------------- | | 30 | | | 31 | | ---------- get ---------> | | 32 | | | | 33 | | [has cookie] | 34 | | | | 35 | | | -----------------------------------> | 36 | | | <------------ html page ------------ | 37 | | <------ html page ------- | 38 | ``` 39 | 40 | ## Pre-requisites 41 | 42 | ### 1. Encryption key 43 | 44 | - Create an encryption key in KMS, or choose one that you already have. Note that each KMS key costs $1/month. 45 | - Take note of the key ID. 46 | 47 | ### 2. CloudFront key pair 48 | 49 | - Logging in with your AWS **root** account, generate a CloudFront key pair 50 | - Take note of the key pair ID 51 | - Download the private key, and encrypt it with KMS using 52 | 53 | ```bash 54 | aws kms encrypt --key-id $KMS_KEY_ID --plaintext "$(cat pk-000000.pem)" --query CiphertextBlob --output text 55 | ``` 56 | 57 | - Write down the encrypted value, then secure the private key or delete it 58 | 59 | ### 3. Htpasswd 60 | 61 | - Create a local `htpasswd` file with your usernames and passwords. You can generate the hashes from the command-line: 62 | 63 | ``` 64 | $ htpasswd -nB username 65 | New password: ********** 66 | Re-type new password: ********** 67 | username:$2a$08$eTTe9DM5N0w50CxL5OL0D.ToMtpAuip/4TCSWCSDJddoIW9gaQIym 68 | ``` 69 | 70 | - Encrypt your `htpasswd` file using KMS again 71 | 72 | ```bash 73 | aws kms encrypt --key-id $KMS_KEY_ID --plaintext "$(cat htpasswd)" --query CiphertextBlob --output text 74 | ``` 75 | 76 | ## Deployment 77 | 78 | Create a configuration file called `dist/config.json`, based on [config.example.json](config.example.json). 79 | Make sure you don't commit this file to source control (the `dist` folder is ignored). 80 | 81 | It should contain the following info - minus the comments: 82 | 83 | ```js 84 | [ 85 | // ------------------- 86 | // PLAIN TEXT SETTINGS 87 | // ------------------- 88 | 89 | // the website domain, as seen by the users 90 | "websiteDomain=website.com", 91 | // how long the CloudFront access is granted for, in seconds 92 | // note that the cookies are session cookies, and will get deleted when the browser is closed anyway 93 | "sessionDuration=86400", 94 | // if false, a successful login will return HTTP 200 (typically for Ajax calls) 95 | // if true, a successful login will return HTTP 302 to the Referer (typically for form submissions) 96 | "redirectOnSuccess=true", 97 | // KMS key ID created in step 1 98 | "kmsKeyId=00000000-0000-0000-0000-000000000000", 99 | // CloudFront key pair ID from step 2 100 | // This is not sensitive, and will be one of the cookie values 101 | "cloudFrontKeypairId=APK...", 102 | 103 | // ------------------ 104 | // ENCRYPTED SETTINGS 105 | // ------------------ 106 | 107 | // encrypted CloudFront private key from step 2 108 | "encryptedCloudFrontPrivateKey=AQECAH...", 109 | 110 | // encrypted contents of the file from step 3 111 | "encryptedHtpasswd=AQECAH..." 112 | ] 113 | ``` 114 | 115 | You can then deploy the full stack using: 116 | 117 | ```bash 118 | export AWS_PROFILE="your-profile" 119 | export AWS_REGION="ap-southeast-2" 120 | 121 | # name of an S3 bucket for storing the Lambda code 122 | ./deploy my-bucket 123 | ``` 124 | 125 | The output should end with the AWS API Gateway endpoint: 126 | 127 | ``` 128 | Endpoint URL: https://0000000000.execute-api.ap-southeast-2.amazonaws.com/Prod/login" 129 | ``` 130 | 131 | Take note of that URL, and test it out! 132 | 133 | ```bash 134 | # with a HTTP Form payload 135 | curl -X POST -d "username=hello&password=world" -H "Content-Type: x-www-form-encoded" -i "https://0000000000.execute-api.ap-southeast-2.amazonaws.com/Prod/login" 136 | 137 | # with a JSON payload 138 | curl -X POST -d "{\"username\":\"hello\", \"password\":\"world\"}" -H "Content-Type: application/json" -i "https://0000000000.execute-api.ap-southeast-2.amazonaws.com/Prod/login" 139 | ``` 140 | 141 | Final steps: 142 | 143 | - optionally setup CloudFront in front of this URL too, so you can use a custom domain like `https://website.com/login` 144 | - once everything is working, change your CloudFront distribution to require signed cookies 145 | and it will return `HTTP 403` for users who aren't logged in 146 | - setup CloudFront to serve a nice login page for `403` errors, or use an existing page from your website to trigger the Lambda function 147 | -------------------------------------------------------------------------------- /app.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Lambda function to login and sign CloudFront cookies 4 | 5 | Parameters: 6 | websiteDomain: 7 | Description: "Website domain" 8 | Type: "String" 9 | sessionDuration: 10 | Description: "Number of seconds the user has access to the file" 11 | Type: "Number" 12 | redirectOnSuccess: 13 | Description: "Whether to send a HTTP 200 or 302 on successful login (boolean)" 14 | Type: "String" 15 | kmsKeyId: 16 | Description: "ID of the KMS key used to encrypt other parameters" 17 | Type: "String" 18 | NoEcho: true 19 | cloudFrontKeypairId: 20 | Description: "CloudFront keypair ID encrypted with KMS" 21 | Type: "String" 22 | Default: "" 23 | NoEcho: true 24 | encryptedCloudFrontPrivateKey: 25 | Description: "CloudFront private key encrypted with KMS" 26 | Type: "String" 27 | NoEcho: true 28 | encryptedHtpasswd: 29 | Description: "htpasswd file contents encrypted with KMS" 30 | Type: "String" 31 | NoEcho: true 32 | 33 | Resources: 34 | 35 | # 36 | # Lambda function definition 37 | # 38 | LoginFunction: 39 | Type: AWS::Serverless::Function 40 | Properties: 41 | Handler: index.handler 42 | Runtime: nodejs8.10 43 | CodeUri: dist/lambda.zip 44 | Role: !GetAtt LambdaRole.Arn 45 | Environment: 46 | Variables: 47 | WEBSITE_DOMAIN: !Ref websiteDomain 48 | SESSION_DURATION: !Ref sessionDuration 49 | REDIRECT_ON_SUCCESS: !Ref redirectOnSuccess 50 | CLOUDFRONT_KEYPAIR_ID: !Ref cloudFrontKeypairId 51 | ENCRYPTED_CLOUDFRONT_PRIVATE_KEY: !Ref encryptedCloudFrontPrivateKey 52 | ENCRYPTED_HTPASSWD: !Ref encryptedHtpasswd 53 | Events: 54 | GetResource: 55 | Type: Api 56 | Properties: 57 | Path: /login 58 | Method: post 59 | 60 | # 61 | # IAM role so the Lambda can log (CloudWatch) and decrypt secrets (KMS) 62 | # 63 | LambdaRole: 64 | Type: "AWS::IAM::Role" 65 | Properties: 66 | Path: "/" 67 | ManagedPolicyArns: 68 | - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 69 | AssumeRolePolicyDocument: 70 | Version: "2012-10-17" 71 | Statement: 72 | - Sid: "AllowLambdaServiceToAssumeRole" 73 | Effect: "Allow" 74 | Action: [ "sts:AssumeRole" ] 75 | Principal: 76 | Service: [ "lambda.amazonaws.com" ] 77 | Policies: 78 | - PolicyName: KmsDecrypt 79 | PolicyDocument: 80 | Statement: 81 | - Effect: "Allow" 82 | Resource: !Sub 83 | - "arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/${id}" 84 | - id: !Ref kmsKeyId 85 | Action: [ "kms:Decrypt" ] 86 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | "websiteDomain=website.com", 3 | "sessionDuration=86400", 4 | "redirectOnSuccess=false", 5 | "kmsKeyId=00000000-0000-0000-0000-000000000000", 6 | "cloudFrontKeypairId=APK...", 7 | "encryptedCloudFrontPrivateKey=AQECAH...", 8 | "encryptedHtpasswd=AQECAH..." 9 | ] 10 | -------------------------------------------------------------------------------- /deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage:" 5 | echo " deploy my.bucket.name" 6 | echo "The bucket will be used to store the Lambda code when deploying." 7 | echo "It will be created if needed" 8 | exit 1 9 | else 10 | s3Bucket=$1 11 | fi 12 | 13 | output_template_file=$(mktemp) 14 | 15 | # build Lambda code for Linux 16 | docker build -t "cloudformation-lambda-cookies" . 17 | docker run "cloudformation-lambda-cookies" > dist/lambda.zip 18 | 19 | # create the target S3 bucket if needed 20 | aws s3 ls ${s3Bucket} > /dev/null 21 | if [ $? -ne 0 ]; then 22 | aws s3 mb ${s3Bucket} 23 | fi 24 | 25 | # create and upload the CloudFormation package 26 | aws cloudformation package \ 27 | --template-file app.yml \ 28 | --output-template-file ${output_template_file} \ 29 | --s3-bucket ${s3Bucket} 30 | 31 | # deploy it 32 | aws cloudformation deploy \ 33 | --template-file ${output_template_file} \ 34 | --stack-name LambdaCloudformationCookies \ 35 | --capabilities CAPABILITY_IAM \ 36 | --parameter-overrides file://dist/config.json 37 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Note: no need to bundle , it's provided by Lambda 2 | const AWS = require('aws-sdk') 3 | const async = require('async') 4 | // const contentType = require('content-type') 5 | const qs = require('querystringparser') 6 | const htpasswd = require('htpasswd-auth') 7 | const cloudfront = require('aws-cloudfront-sign') 8 | 9 | // -------------- 10 | // Lambda function parameters, as environment variables 11 | // -------------- 12 | 13 | const CONFIG_KEYS = { 14 | websiteDomain: 'WEBSITE_DOMAIN', 15 | sessionDuration: 'SESSION_DURATION', 16 | redirectOnSuccess: 'REDIRECT_ON_SUCCESS', 17 | cloudFrontKeypairId: 'CLOUDFRONT_KEYPAIR_ID', 18 | cloudFrontPrivateKey: 'ENCRYPTED_CLOUDFRONT_PRIVATE_KEY', 19 | htpasswd: 'ENCRYPTED_HTPASSWD' 20 | } 21 | 22 | // -------------- 23 | // Main function exported to Lambda 24 | // Checks username/password against the entries 25 | // -------------- 26 | 27 | exports.handler = (event, context, callback) => { 28 | // try to parse the request payload based on Content-Type 29 | const requestHeaders = normaliseHeaders(event.headers) 30 | const body = parsePayload(event.body, requestHeaders) 31 | if (!body || !body.username || !body.password) { 32 | return callback(null, { 33 | statusCode: 400, 34 | body: 'Bad request' 35 | }) 36 | } 37 | // get and decrypt config values 38 | async.mapValues(CONFIG_KEYS, getConfigValue, function (err, config) { 39 | if (err) { 40 | callback(null, { 41 | statusCode: 500, 42 | body: 'Server error' 43 | }) 44 | } else { 45 | // validate username and password 46 | htpasswd.authenticate(body.username, body.password, config.htpasswd).then((authenticated) => { 47 | if (authenticated) { 48 | console.log('Successful login for: ' + body.username) 49 | var responseHeaders = cookiesHeaders(config) 50 | var statusCode = 200 51 | if (config.redirectOnSuccess === 'true') { 52 | statusCode = 302 53 | responseHeaders['Location'] = requestHeaders['referer'] || '/' 54 | } 55 | callback(null, { 56 | statusCode: statusCode, 57 | body: 'Authentication successful', 58 | headers: responseHeaders 59 | }) 60 | } else { 61 | console.log('Invalid login for: ' + body.username) 62 | callback(null, { 63 | statusCode: 403, 64 | body: 'Authentication failed', 65 | headers: { 66 | // clear any existing cookies 67 | 'Set-Cookie': 'CloudFront-Policy=', 68 | 'SEt-Cookie': 'CloudFront-Signature=', 69 | 'SET-Cookie': 'CloudFront-Key-Pair-Id=' 70 | } 71 | }) 72 | } 73 | }) 74 | } 75 | }) 76 | } 77 | 78 | // -------------- 79 | // Parse the body, either from JSON or Form data 80 | // -------------- 81 | 82 | function parsePayload (body, headers) { 83 | const type = headers['content-type'] 84 | // const parsedType = contentType.parse(rawType) 85 | if (type === 'application/json') { 86 | try { 87 | return JSON.parse(body) 88 | } catch (e) { 89 | console.log('Failed to parse JSON payload') 90 | return null 91 | } 92 | } else if (type === 'application/x-www-form-urlencoded') { 93 | return qs.parse(body) 94 | } else { 95 | return null 96 | } 97 | } 98 | 99 | // -------------- 100 | // Returns the corresponding config value 101 | // After decrypting it with KMS if required 102 | // -------------- 103 | 104 | function getConfigValue (configName, target, done) { 105 | if (/^ENCRYPTED/.test(configName)) { 106 | const kms = new AWS.KMS() 107 | const encrypted = process.env[configName] 108 | kms.decrypt({ CiphertextBlob: new Buffer(encrypted, 'base64') }, (err, data) => { 109 | if (err) done(err) 110 | else done(null, data.Plaintext.toString('ascii')) 111 | }) 112 | } else { 113 | done(null, process.env[configName]) 114 | } 115 | } 116 | 117 | // -------------- 118 | // Returns an object with all HTTP headers in lowercase 119 | // Because browsers will send inconsistent keys like 'Content-Type' or 'content-type' 120 | // -------------- 121 | 122 | function normaliseHeaders (headers) { 123 | return Object.keys(headers).reduce((acc, key) => { 124 | acc[key.toLowerCase()] = headers[key] 125 | return acc 126 | }, {}) 127 | } 128 | 129 | // -------------- 130 | // Creates 3 CloudFront signed cookies 131 | // They're effectively an IAM policy, and a private signature to prove it's valid 132 | // -------------- 133 | 134 | function cookiesHeaders (config) { 135 | const sessionDuration = parseInt(config.sessionDuration, 10) 136 | // create signed cookies 137 | const signedCookies = cloudfront.getSignedCookies('https://' + config.websiteDomain + '/*', { 138 | expireTime: new Date().getTime() + (sessionDuration * 1000), 139 | keypairId: config.cloudFrontKeypairId, 140 | privateKeyString: config.cloudFrontPrivateKey 141 | }) 142 | // extra options for all cookies we write 143 | // var date = new Date() 144 | // date.setTime(date + (config.cookieExpiryInSeconds * 1000)) 145 | const options = '; Domain=' + config.websiteDomain + '; Path=/; Secure; HttpOnly' 146 | // we use a combination of lower/upper case 147 | // because we need to send multiple cookies 148 | // but the AWS API requires all headers in a single object! 149 | return { 150 | 'Set-Cookie': 'CloudFront-Policy=' + signedCookies['CloudFront-Policy'] + options, 151 | 'SEt-Cookie': 'CloudFront-Signature=' + signedCookies['CloudFront-Signature'] + options, 152 | 'SET-Cookie': 'CloudFront-Key-Pair-Id=' + signedCookies['CloudFront-Key-Pair-Id'] + options 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-cloudfront-cookies", 3 | "version": "1.0.0", 4 | "description": "AWS Lambda function that creates CloudFront cookies for valid users", 5 | "author": "thumbsup", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "scripts": { 9 | "test": "standard" 10 | }, 11 | "dependencies": { 12 | "async": "^2.1.4", 13 | "aws-cloudfront-sign": "^2.2.0", 14 | "content-type": "^1.0.2", 15 | "htpasswd-auth": "^2.0.0", 16 | "querystringparser": "^0.1.1" 17 | }, 18 | "devDependencies": { 19 | "standard": "^8.6.0" 20 | } 21 | } 22 | --------------------------------------------------------------------------------