You've set up a new server in AWS EC2, and you want your team to be able to access it by SSH.

This means authorising access via an EC2 Security Group. Best practice dictates you don't leave this port open to the entire world of hackers and miscreants, but how do you deal with mobile users and ever-changing IP addresses?

Here's a way to use AWS Lambda to maintain temporary security group firewall rules for users in an organisation with google logins to a specific domain.

A Firewall-opener web application

EC2 security group entries each come with a Description field, a text field which isn't used by AWS, but the data is stored alongside the firewall rule.

To minimise required resources, our application code is stateless, requiring no database, but instead uses this Description field.

The structure of this solution is:

  • a couple of python-based Lambda functions, one for logging in, another for auto-expiring old firewall rules
  • Google API for authentication
  • Additional AWS resources, including IAM role policy and API gateway

Setup

import boto3, json, sys, time, os, traceback
from oauth2client.client import OAuth2WebServerFlow
from botocore.exceptions import ClientError

flow = OAuth2WebServerFlow(client_id=os.environ['google_client_id'],
  client_secret=os.environ['google_client_secret'],
  scope='email',
  redirect_uri=os.environ['google_redirect_uri'],
  hd=os.environ['domain'])

The heavy lifting of talking to Google for login and authentication is done by the oauth2client library.

We're using environment variables for configuration. (The contents of the google_* variables are supplied by Google when you sign up for an OAuth workflow.)

Using the hd parameter makes the Google login page limit available login choices to only the email domain we care about, so users with multiple Google accounts don't see unsupported choices in their login list.

Login page

welcome_html = '<a href="{url}">Login with Google</a>'
def welcome():
  return(welcome_html.format(url=flow.step1_get_authorize_url()))

This bare minimum sample login screen can be replaced by a HTML template file by opening and reading the file into the welcome_html python variable. The {url} string will be replaced dynamically with one generated by the OAuth library.

Opening a port

def add_ip(ip, username):
  sg_group = os.environ['security_group_id']
  ec2_client = boto3.client('ec2')
  cidr = ip + "/32"
  ip_data = { 'CidrIp': cidr,  'Description': username + ' ' + str(int(time.time())) }
  ...

This function will be called to modify the EC2 security group. The env var security_group_id is expected to contain the AWS ID of the security group to modify.

The Description field of the security group entry stores our own custom lookup key, consisting of both the username and a timestamp, which will later allow us to remove the entry based on either one.

  ...
  existing = None
  response = ec2_client.describe_security_groups(GroupIds=[sg_group])
  for ip_perm_entry in response['SecurityGroups'][0]['IpPermissions']:
    for entry in ip_perm_entry['IpRanges']:
      if(entry['Description'].split(' ')[0] == username):
        revoke_ip(entry['CidrIp'])
      elif(entry['CidrIp'] == cidr):
        existing = entry
  ...

The first step is to look for existing entries for this username or IP.

If we find an existing one for the username, we revoke that first.

If we find an existing one for the IP, we just leave it alone and exit. The reason for this is to allow team members in a central office (with a shared IP) to retain access, based on the first person to have logged in from that location.

  ...
  if not existing:
    ec2_client.authorize_security_group_ingress(
      GroupId=sg_group,
      IpPermissions=ip_perms = [ { 'FromPort': 22, 'ToPort': 22, 'IpProtocol': 'tcp', 'IpRanges': [ ip_data ] } ]
    )

Finally, we add the IP as a new firewall ingress rule, in this example, for port 22 (SSH).

Closing the port

def revoke_ip(cidr):
  ec2_client.revoke_security_group_ingress(
    GroupId=sg_group,
    IpPermissions=[ { 'FromPort': 22, 'ToPort': 22, 'IpProtocol': 'tcp', 'IpRanges': [ { 'CidrIp': cidr } ] } ]
  )

This function should be self-explanatory, it just removes the firewall rule.

OAuth verification

def login(code, ip):
  credentials = flow.step2_exchange(code)
  if credentials.id_token and credentials.id_token.get('hd') == os.environ['domain']:
    username = credentials.id_token['email'].split('@')[0]
    add_ip(ip, username)
    return('Logged in!)
  else:
    return('Access denied!')

This function will be called to process a login request. Google will return a code which needs to be verified by the library, then we verify the username is from the domain we're expecting. Upon confirmation we call the add_ip function to modify the firewall.

Lambda main function

def handle(event, context):
  if not event.get("pathParameters"):
    content = welcome()
  elif event["pathParameters"]["proxy"] == "login" and event.get("queryStringParameters") and event.get('requestContext'):
    content = login(event["queryStringParameters"]["code"], event['requestContext']['identity']['sourceIp'])
  else:
    content = "Something went wrong."
  return { "statusCode": 200, "body": content }

Bringing it all together, the main Lambda handler. An API gateway should be set up for this serverless application, with a resource for proxying the path in (via path part /{proxy+}).

The root / will serve the login page. After successful authentication, Google will redirect to the /login endpoint with the code to verify. The Lambda environment also lets us see the user's IP address via requestContext in the event structure.

Expiring old rules

To stop old entries from remaining in the security group, we can use this seperate lambda function to expire based on time. In this example, logging in will grant you 3 days of access.

import boto3, import time, import json, import os
ec2_client = boto3.client('ec2')
cutoff_days = 3
def handle(event, context):
  sg_group = os.environ['security_group_id']
  response = ec2_client.describe_security_groups(GroupIds=[sg_group])
  cutoff = int(time.time()) - (86400 * cutoff_days)

  for entry in response['SecurityGroups'][0]['IpPermissions'][0]['IpRanges']:
    try:
      createdat = int(entry['Description'].split(' ')[1])
      if(createdat < cutoff):
        revoke_ip(entry['CidrIp'])
  return None

This function can be set up run daily as a Scheduled Event in AWS.

Required AWS resources

Publishing this Lambda function as a public-facing web application can be achieved by creating an API Gateway to proxy requests to it.

You should create a dedicated security group just for these dynamically created firewall rules. This way there'll be no parse errors when the lambda function iterates over the rules.

Additionally there's a limit to the number of rules that can be added to one security group so this can be maximized with a dedicated group, by default this is 50.

An IAM role with appropriate policy should be created to attach to the Lambda function, to give it access to modify the security group. Here's a sample policy document for this, with the account ID and security group ID blanked out:

{
    "Effect": "Allow",
    "Action": ["ec2:DescribeSecurityGroups"],
    "Resource": ["*"]
},
{
    "Effect": "Allow",
    "Action": [
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:UpdateSecurityGroupRuleDescriptionsIngress",
        "ec2:RevokeSecurityGroupIngress"
    ],
    "Resource": [
        "arn:aws:ec2:us-east-1:xxxxxxx:security-group/sg-xxxxxxxxx"
    ]
}

Setting up Google login worklow

To enable OAuth to work, you have to enable it inside Google's API service at https://console.cloud.google.com/apis/

First, create a project, then create new Credentials for "OAuth 2.0 client ID". This will allocate you unique client_id and secret which are needed to feed in to the Lambda function as environment variables. We're only using the standard emails OAuth scope, which should be provided by the Google+ or Contacts API.

You will have to supply an Authorized Redirect URI. For this you need to use the API Gateway URL appended with the for example, https://xxxx.execute-api.us-east-1.amazonaws.com/stagename/login?

Don't forget to login!

Now you're ready to let your team know where to login, and grant themselves access through the firewall.