19

I am looking to add Basic User Authentication to a Static Site I will have up on AWS so that only those with the proper username + password which I will supply to those users have access to see the site. I found s3auth and it seems to be exactly what I am looking for, however, I am wondering if I will need to somehow set the authorization for pages besides the index.html. For example, I have 3 pages- index, about and contact.html, without authentication setup for about.html what is stopping an individual for directly accessing the site via www.mywebsite.com/about.html? I am more so looking for clarification or any resources anyone can provide to explain this!

Thank you for your help!

Gideon B
  • 415
  • 1
  • 3
  • 16
  • I haven't used s3auth, but it looks be a gateway that sits in front of an entire bucket. You don't need to worry about permissions for individual objects. – bwest Apr 26 '19 at 22:04
  • @bwest Thanks for the reply. In any case, do you have another solution that would accomplish the same thing? I read about setting a Cloudfront Distribution in front of the bucket (somehow? still new so not sure how) and then using a lambda function to simulate an http basic auth? More or less looking for something relatively secure for a test environment where I and others have access via username and password. – Gideon B Apr 26 '19 at 22:08

5 Answers5

46

This is the perfect use for Lambda@Edge.

Because you're hosting your static site on S3, you can easily and very economically (pennies) add some really great features to your site by using CloudFront, AWS's content distribution network, to serve your site to your users. You can learn how to host your site on S3 with CloudFront (including 100% free SSL) here.

While your CloudFront distribution is deploying, you'll have some time to go set up your Lambda that you'll be using to do the basic user auth. If this is your first time creating a Lambda or creating a Lambda for use @Edge the process is going to feel really complex, but if you follow my step-by-step instructions below you'll be doing serverless basic-auth that is infinitely scalable in less than 10 minutes. I'm going to use us-east-1 for this and it's important to know that if you're using Lambda@Edge you should author your functions in us-east-1, and when they're associated with your CloudFront distribution they'll automagically be replicated globally. Let's begin...

  1. Head over to Lambda in the AWS console, and click on "Create Function"
  2. Create your Lambda from scratch and give it a name
  3. Set your runtime as Node.js 8.10
  4. Give your Lambda some permissions by selecting "Choose or create an execution role"
  5. Give the role a name
  6. From Policy Templates select "Basic Lambda@Edge permissions (for CloudFront trigger)"
  7. Click "Create function"
  8. Once your Lambda is created take the following code and paste it in to the index.js file of the Function Code section - you can update the username and password you want to use by changing the authUser and authPass variables:
'use strict';
exports.handler = (event, context, callback) => {

    // Get request and request headers
    const request = event.Records[0].cf.request;
    const headers = request.headers;

    // Configure authentication
    const authUser = 'user';
    const authPass = 'pass';

    // Construct the Basic Auth string
    const authString = 'Basic ' + new Buffer(authUser + ':' + authPass).toString('base64');

    // Require Basic authentication
    if (typeof headers.authorization == 'undefined' || headers.authorization[0].value != authString) {
        const body = 'Unauthorized';
        const response = {
            status: '401',
            statusDescription: 'Unauthorized',
            body: body,
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic'}]
            },
        };
        callback(null, response);
    }

    // Continue request processing if authentication passed
    callback(null, request);
};

  1. Click "Save" in the upper right hand corner.
  2. Now that your Lambda is saved it's ready to attach to your CloudFront distribution. In the upper menu, select Actions -> Deploy to Lambda@Edge.
  3. In the modal that appears select the CloudFront distribution you created earlier from the drop down menu, leave the Cache Behavior as *, and for the CloudFront Event change it to "Viewer Request", and finally select/tick "Include Body". Select/tick the Confirm deploy to Lambda@Edge and click "Deploy".

And now you wait. It takes a few minutes (15-20) to replicate your Lambda@Edge across all regions and edge locations. Go to CloudFront to monitor the deployment of your function. When your CloudFront Distribution Status says "Deployed", your Lambda@Edge function is ready to use.

jarmod
  • 71,565
  • 16
  • 115
  • 122
hephalump
  • 5,860
  • 1
  • 22
  • 23
  • 1
    You are amazing! Thank you for such an in depth step by step. It is very appreciated. – Gideon B Apr 27 '19 at 03:48
  • 1
    My pleasure; glad it is helpful. – hephalump Apr 27 '19 at 10:09
  • 3
    `callback(null, response);` should be preceded with `return` or this code will call the callback twice in this case, which might have difficult to detect but still negative side effects. I would expect the node process to be killed and transparently respawned, resulting in a longer than necessary processing time, somewhere between a warm start and a cold start, for the next invocation hitting the same container... if not container destruction... due to the exception that may occur (but probably won't be logged, or might be thrown during the next invocation). – Michael - sqlbot Apr 27 '19 at 18:36
  • @hephalump just wanted to let you know your solution worked perfectly and it was exactly what I was looking for. – Gideon B Apr 28 '19 at 05:15
  • Thanks for the great write up. Won't end users be able to access S3 static site directly via S3 website URL? Also, won't implement Lambda @ Edge for authentication mean all pages would require authentication? – openwonk Sep 30 '19 at 22:20
  • 3
    if you don't see the "Deploy to Lambda@Edge" option at step 10, make sure you're in the us-east-1 region. [see aws docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-how-it-works-tutorial.html) – mac Nov 03 '19 at 04:11
  • Thanks for explaining about creating the lambda in us-east-1 and it distributing globally. I thought that being in ap-southeast-2 I was out of luck with Lambda@Edge, but with your help I was able to implement this. – jontsnz Nov 15 '19 at 07:32
  • I had this error when deploying to Lambda@Edge: "Your function's execution role must be assumable by the edgelambda.amazonaws.com service principal." - I found this answer and it fixed my issue. https://stackoverflow.com/a/53796764/637777 (I just needed to add "edgelambda.amazonaws.com" to the services in the IAM role) – Zip184 Apr 12 '20 at 16:07
  • What's the level of security of this approach? I am doing the same but I am struggling to understand the best way to secure the Api Gateway / Lambda that I have as backend. – Mattia Dec 25 '20 at 21:48
4

Deploying Lambda@edge is quiet difficult to replicate via console. So I have created CDK Stack that you just add your own credentials and domain name and deploy.

https://github.com/apoorvmote/cdk-examples/tree/master/password-protect-s3-static-site

I have tested the following function with Node12.x

exports.handler = async (event, context, callback) => {

const request = event.Records[0].cf.request

const headers = request.headers

const user = 'my-username'

const password = 'my-password'

const authString = 'Basic ' + Buffer.from(user + ':' + password).toString('base64')

if (typeof headers.authorization === 'undefined' || headers.authorization[0].value !== authString) {

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

    callback(null, response)
}

callback(null, request)
}
Apoorv Mote
  • 523
  • 3
  • 25
4

By now, this is also possible with CloudFront functions which I like more because it reduces the complexity even more (from what is already not too complex with Lambda). Here's my writeup on what I just did...

It's basically 3 things that need to be done:

  1. Create a CloudFront function to add Basic Auth into the request.
  2. Configure the Origin of the CloudFront distribution correctly in a few places.
  3. Activate the CloudFront function. That's it, no particular bells & whistles otherwise. Here's what I've done:

First, go to CloudFront, then click on Functions on the left, create a new function with a name of your choice (no region etc. necessary) and then add the following as the code of the function:

function handler(event) {

    var user = "myuser";
    var pass = "mypassword";

    function encodeToBase64(str) {
        var chars =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
        for (
            // initialize result and counter
            var block, charCode, idx = 0, map = chars, output = "";
            // if the next str index does not exist:
            //   change the mapping table to "="
            //   check if d has no fractional digits
            str.charAt(idx | 0) || ((map = "="), idx % 1);
            // "8 - idx % 1 * 8" generates the sequence 2, 4, 6, 8
            output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))
        ) {
        charCode = str.charCodeAt((idx += 3 / 4));
        if (charCode > 0xff) {
            throw new InvalidCharacterError("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range."
        );
        }
        block = (block << 8) | charCode;
      }
      return output;
    }


    var requiredBasicAuth = "Basic " + encodeToBase64(`${user}:${pass}`);
    var match = false;
    if (event.request.headers.authorization) {
        if (event.request.headers.authorization.value === requiredBasicAuth) {
            match = true;
        }
    }

    if (!match) {
      return {
        statusCode: 401,
        statusDescription: "Unauthorized",
        headers: {
          "www-authenticate": { value: "Basic" },
        },
      };
    } 

    return event.request;
}

Then you can test with directly on the UI and assuming it works and assuming you have customized username and password, publish the function.

Please note that I have found individual pieces of the function above on the Internet so this is not my own code (other than piecing it together). I wish I would still find the sources so I can quote them here but I can't find them anymore. Credits to the creators though! :-)

Next, open your CloudFront distribution and do the following:

  1. Make sure your S3 bucket in the origin is configured as a REST endpoint and not a website endpoint, i.e. it must end on .s3.amazonaws.com and not have the word website in the hostname.

  2. Also in the Origin settings, under "S3 bucket access", select "Yes use OAI (bucket can restrict access to only CloudFront)". In the setting below click on "Create OAI" to create a new OAI (unless you have an existing one and know what you're doing). And select "Yes, update the bucket policy" to allow AWS to add the necessary permissions to your OAI.

  3. Finally, open your Behavior of the CloudFront distribution and scroll to the bottom. Under "Function associations", for "Viewer request" select "CloudFront Function" and select your newly created CloudFront function. Save your changes.

And that should be it. With a bit of luck a matter of a couple of minutes (realistically more, I know) and especially not additional complexity once this is all set up.

hendrikbeck
  • 630
  • 6
  • 13
  • Thanks for this answer, It worked like a charm for my use case. But with this approach, if I authenticate the request once, It'll not ask me another time. How can I modify this such that it asks for authentication on every reload? – Anjaan Jun 05 '23 at 11:31
1

Thanks for the useful post. An alternative to listing the pain text user name and password in the code, and to having base64 encoding logic, is to pre-generate the base64 encoded string. One such encoder, https://www.debugbear.com/basic-auth-header-generator

From there the script becomes simpler. The following is for 'user' / 'password'

function handler(event) {
var base64UserPassword = "Y3liZXJmbG93c3VyZmVyOnRhbHR4cGNnIzIwMjI="

if (event.request.headers.authorization && 
    event.request.headers.authorization.value === ("Basic " + base64UserPassword)) {
        return event.request;
}

return {
    statusCode: 401,
    statusDescription: "Unauthorized ",
    headers: {
        "www-authenticate": { value: "Basic" },
    },
}  

}

1

Here is already exists answer how to use Cloudfront functions, but I want to add improved version of the function:

  1. Hardcoded credentials stored as SHA256 hash instead of plain (or base64 that is the same as plain) text. And that is more secure.
  2. It is possible to allow access by whitelisted global IP addresses:
function handler(event) {
    var crypto = require('crypto');
    var headers = event.request.headers;
    var wlist_ips = [
        "1.1.1.1",
        "2.2.2.2"
    ];
    var authString = "9c06d532edf0813659ab41d26ab8ba9ca53b985296ee4584a79f34fe9cd743a4";
    if (
        typeof headers.authorization === "undefined" ||
        crypto.createHash(
          'sha256'
          ).update(headers.authorization.value).digest('hex') !== authString
    ) {
        if (
            !wlist_ips.includes(event.viewer.ip)
        ) {
            return {
                statusCode: 401,
                statusDescription: "Unauthorized",
                headers: {
                    "www-authenticate": { value: "Basic" },
                    "x-source-ip": { value: event.viewer.ip}
                }
            };
        }
    }
    return event.request;
}

Command below may be used to get correct authString hash value for username user and password password:

printf "Basic $(printf 'user:password' | base64 -w 0)" | sha256sum | awk '{print$1}'
rzlvmp
  • 7,512
  • 5
  • 16
  • 45