S3 and CloudFront basic auth "hack"

With the wave of doing serverless sites, I faced the use-case where we want to have all the content of a bucket publicly available but want to restrict access to certain pages. For example, some pages are for all the world to see but some other are for friends only.

But S3 doesn't offer today any basic authentication. And even though you can do some nice tricks with forcing a webpage and allow another to be reached only under the condition of having some specific S3 sent headers, that last trick doesn't work in CloudFront because it doesnt send headers in the same way.

With quite a bit of research, I could find some external services that will front your site, but, I don't quite want to depend on it or have to install a NGINX machine just so I have basic auth. I want do it serverless. So, past those considerations and problems, I came across a very simple trick to force some pages of my CF Distribution to be only available to people who were granted access via a password.

Note

In the code today, we are going to use a single password with nothing special like having many users and different passwords stored in a DB, but that is very easily doable.

The S3 bucket

There is nothing specific to setup about the bucket. Simply treat it like you would normally do with a static website, but, do not turn the static website hosting on!!

In our case here we will have 2 very simple flat HTML files and that's it

  • index.html
  • auth.html

Deny access to index.html

To secure the distribution, we will indicate CloudFront that our origin is to point on auth.html by default, and deny all non-signed to index.html

01 - Setup the distribution

Point your distribution to your bucket, and enable "Origin Access Identity" so that only CF can access your bucket.

Create the distribution

I highly recommend to point the HTTP to redirect to HTTPS

Redirect HTTP resquests to HTTPs

In the last part we indicate that the default page is our index.html

Force to point to auth.html by default

02 - Configure a new behavior

The behavior indicates how a file or specific path can be accessed in a distribution. We are going to use that to explictly say "if you want to reach index.html, you need to do so via Signed URL"

We indicate the path we want to be only reachable via Signed URLs

Now we say that only Signed URL can work

We indicate the path we want to be only reachable via Signed URLs We indicate the path we want to be only reachable via Signed URLs

Our site pages

index.html

Our home page index.html is the page we want to give access to only when granted. So, the objective is for someone who goes to our https://site.domain/index.html to be denied access. However, all the content that index.html calls (images, sounds, js files etc) should remain available to that page without making it overcomplicated, so all those resources will be available.

Note

If we want to hide the other files from obvious GETs (ie GET doc/img.jpg) to avoid some DDoS, I would recommend to have your site generated with complete random names and render the index.html with those random names paths. You keep all the functionality and make it harder for someone to scan. You can also add some CORS so only the site will load the files.

auth.html

This will be our https://site.domain landing page : we want people to first login with a password before loading index.html and loading the site. What our landing auth.html page will do is quite simple : present a form to the users, and upon entry of the valid password, allow our users to get onto index.html

Here is a very simplistic auth.html page. JS developers will know way better how to make it look nice and work better.

<html>
<head>
<title>Sample Login Page</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.3.7.min.js"></script>
<h1>Sample Login Page</h1>
<div id="info">
Login
</div>
<table>
<tr>
<td>Password</td>
<td><input type="password" id="password" size="20">
</tr>
<tr>
<td colspan="2">
<button type="submit" id="login-button">Login</button>
</td>
</tr>
</table>
<script>
AWS.config.update({region: 'eu-west-1'});
var lambda = new AWS.Lambda({accessKeyId: 'ACCESS_KEY',
secretAccessKey: 'SECRET_KEY'});
var password = document.getElementById('password');
var loginButton = document.getElementById('login-button');

   loginButton.addEventListener('click', function() {
   info.innerHTML = 'Login...';

   if (password.value == null || password.value == '') {
   info.innerHTML = 'Please specify a password.';

   } else {
   var input = {
   password: password.value
   };

   lambda.invoke({
   FunctionName: 'basicAuth',
   Payload: JSON.stringify(input)
   },
   function(err, data) {
   if (err) console.log(err, err.stack);
   else {
   var output = JSON.parse(data.Payload);

   if (output.code == 403) {
   info.innerHTML = 'Access denied';
   } else {
   window.location.replace(output.url);
   }
   }
   });
   }
   });
   </script>
</body>
</html>

Note

I am not yet comfortable enough with JS to understand how I am supposed to allow to use the site with temporary credentials instead of using a fake user access and secret key.

As you can see in the auth.html, we display a password field and when the user sends the data, that is passed onto a Lambda function. That function will check on the password given (which is where we can also implement a deeper authentication method). Of course, using external authenticators can certainly be integrated there.

Lambda basicAuth function

This function is where we will sign the CloudFront URL to be returned if our password is validated.

import datetime

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from botocore.signers import CloudFrontSigner


def rsa_signer(message):
    """
    Function to signe the URL with the pem from CF
    """
    with open('private.pem', 'rb') as key_file:
      private_key = serialization.load_pem_private_key(
      key_file.read(),
      password=None,
      backend=default_backend()
      )
    signer = private_key.signer(padding.PKCS1v15(), hashes.SHA1())
    signer.update(message)
    return signer.finalize()

def lambda_handler(event, context):
    """
    Lambda triggered script
    """

    if event['password'] == 'passw0rd123':
      key_id = 'ACCESS_KEY_CF'
      url = 'https://mydistributionID.cloudfront.net/index.html'
      expire_date = datetime.datetime.now() + datetime.timedelta(hours=1)
      cloudfront_signer = CloudFrontSigner(key_id, rsa_signer)
      signed_url = cloudfront_signer.generate_presigned_url(
        url,
        date_less_than=expire_date
        )
      print("--- Password is okay - signing URL ---")
      return {'code' : 200, 'url' : signed_url}
    else:
      print "Wrong password"
     return {'code': 403}

Note

I spent quite a long time before finding how to get this URL signed. Thanks to all folks who posted on Stackoverflow and other sites to get me to the right place ! :)

The function code itself is pretty basic. However, as you can notice, we need a keyfile to sign the URL. To do so.

Hint

Go to your account -> security credentials -> CloudFront KeyPairs. Here you will create a new KeyPair which comes with the ID (in the code, ACCESS_CF_KEY). Download the private key file.

Get your CF key and certificates

Tip

Alternate method to keep that key secured would be to store it as a string in DynamoDB or S3, encrypted itself with KMS, and have the Lambda function decrypt the string, write the file, sign the URL, delete the file.

Conclusion

Very simple hack to lock down your CF Distribution pages to allow to share your content with a simple (or more complex) yet basic auth mechanism without using an external service.

Please leave some comments and feedback for any suggestions / solutions you'd be willing to share on how you worked around that situation.

Comments

Comments powered by Disqus