pabis.eu

Easy MFA in AWS CLI, Terraform and others - Bash Script

03 December 2023

Multi-factor authentication is a must-have for any serious developer and administrator. It's Security 101: you should always use second factor if offered by the service. This is true also for AWS but not only in the Management Console. AWS STS offers time-limited tokens that can be configured (with the power of IAM policies) to only be issued with an MFA code. This all sounds good and all but in practice writing AWS CLI commands is straight up painful - write long command, very verbal arguments and then parse the JSON response. There are many scripts online that help with this but as I use multiple AWS accounts and don't want to relate them by IAM roles (so that they are fully separate), I happen to have many users, credentials and MFA entries.

The expected end result

Preparing the environment

Assuming you have AWS CLI installed, let's look at the basic example. We will use one account here for simplicity. First, we will go to IAM in our AWS Management Console (or use IaC if you prefer) and create two users that will have different permissions. If you want to configure it for multiple accounts, just log to the second account and create the second user there - it doesn't really matter as the process is so straightforward. Give them only programmatic access.

Creating the user

I will attach two different policies for these users. One will have AmazonEC2FullAccess and the other AmazonS3FullAccess. Now we will create a policy that denies these users access to anything if they don't have an MFA session active. The only thing they will be able to do is to assign an MFA device and do some basic account management on their account. The policy below is taken directly from AWS Documentation.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowViewAccountInfo",
            "Effect": "Allow",
            "Action": "iam:ListVirtualMFADevices",
            "Resource": "*"
        },
        {
            "Sid": "AllowManageOwnVirtualMFADevice",
            "Effect": "Allow",
            "Action": [
                "iam:CreateVirtualMFADevice"
            ],
            "Resource": "arn:aws:iam::*:mfa/*"
        },
        {
            "Sid": "AllowManageOwnUserMFA",
            "Effect": "Allow",
            "Action": [
                "iam:DeactivateMFADevice",
                "iam:EnableMFADevice",
                "iam:GetUser",
                "iam:GetMFADevice",
                "iam:ListMFADevices",
                "iam:ResyncMFADevice"
            ],
            "Resource": "arn:aws:iam::*:user/${aws:username}"
        },
        {
            "Sid": "DenyAllExceptListedIfNoMFA",
            "Effect": "Deny",
            "NotAction": [
                "iam:CreateVirtualMFADevice",
                "iam:EnableMFADevice",
                "iam:GetUser",
                "iam:ListMFADevices",
                "iam:ListVirtualMFADevices",
                "iam:ResyncMFADevice",
                "sts:GetSessionToken"
            ],
            "Resource": "*",
            "Condition": {
                "BoolIfExists": {"aws:MultiFactorAuthPresent": "false"}
            }
        }
    ]
}

Put this policy under the name DenyWithoutMFA and attach it to both users.

Attaching the policy to both users

Now go to each user's details and create access keys. Copy them and paste into ~/.aws/credentials file as two separate profiles like so.

[EC2Developer]
aws_access_key_id = AKIA1234567890
aws_secret_access_key = abababba/bababa/abababab

[S3Developer]
aws_access_key_id = AKIA0987654321
aws_secret_access_key = babababa/ababab/abababa

Create access keys

Now you should be able to use the AWS CLI with these profiles. Let's try it out.

$ aws --profile EC2Developer ec2 describe-instances --region eu-west-1

An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation: You are not authorized to perform this operation. User: arn:aws:iam::1234567890:user/EC2Developer is not authorized to perform: ec2:DescribeInstances with an explicit deny in an identity-based policy

Ok, the policy works. Now we need to obtain temporary credentials in order to create an MFA session. But before that, we need to attach an MFA device. Using our administrative account that we used for creating both users, let's assign them an MFA device as well. It's on the same page as Access Keys. We can only use Authenticator app unfortunately as CLI doesn't support hardware tokens or Yubikeys. In order to use it, get your latest greatest iPhone 6S or Galaxy S3, download Google Authenticator/Authy/Microsoft Authenticator and follow the steps AWS gives you in the Management Console. After assigning the device, copy its identifier.

Assigning MFA device to the user

Now we can create a session using AWS CLI. To do this we will call STS service with appropriate parameters. We will also use jq to parse the JSON response and put the temporary credentials into environment variables. By running this in the current console we should be able in the next step to retry running the describe-instances command without specifying the profile. Replace the --serial-number with your MFA device ARN and --token-code with the code you have in your Authenticator app.

# Get temporary credentials
JSON_ACCESS_KEY="$(aws sts get-session-token\
 --serial-number arn:aws:iam::1234567890:mfa/EC2DeveloperMFA\
 --token-code 000111\
 --profile EC2Developer)"

# Overriding credentials using environment variables which takes precedent over configured profiles/credentials
export AWS_ACCESS_KEY_ID=$(jq -r '.Credentials.AccessKeyId' <<< $JSON_ACCESS_KEY)
export AWS_SECRET_ACCESS_KEY=$(jq -r '.Credentials.SecretAccessKey' <<< $JSON_ACCESS_KEY)
export AWS_SESSION_TOKEN=$(jq -r '.Credentials.SessionToken' <<< $JSON_ACCESS_KEY)

# Now we can run the command - it should output some JSON
aws ec2 describe-instances --region eu-west-1

Building a helper application

Now, writing above commands seem daunting. The computers were designed to make our lives easier by programming. Let's write a simple Bash script that will help us manage all the MFA ARNs, credentials and commands. The draft version will include only one user and one MFA. I base my code on this GitHub Project: nickGermi/aws-force-mfa

#!/bin/bash
### Easy MFA for AWS CLI - version 1.0

if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <MFA code>"
    exit 1
fi

MFA="arn:aws:iam::1234567890:mfa/EC2DeveloperMFA"
MFACODE=$1
PROFILE="EC2Developer"

# Get temporary credentials
JSON_ACCESS_KEY="$(aws sts get-session-token\
 --serial-number $MFA\
 --token-code $MFACODE\
 --profile $PROFILE\
)"

export AWS_ACCESS_KEY_ID=$(jq -r '.Credentials.AccessKeyId' <<< $JSON_ACCESS_KEY)
export AWS_SECRET_ACCESS_KEY=$(jq -r '.Credentials.SecretAccessKey' <<< $JSON_ACCESS_KEY)
export AWS_SESSION_TOKEN=$(jq -r '.Credentials.SessionToken' <<< $JSON_ACCESS_KEY)

# Print logged-in user details using STS
CURRENT_USER="$(aws sts get-caller-identity)"
USER_NAME=$(jq -r '.Arn' <<< $CURRENT_USER | cut -d/ -f2)
ACCOUNT_ID=$(jq -r '.Account' <<< $CURRENT_USER)
echo "✅ Logged in as $USER_NAME @ $ACCOUNT_ID"

We will put this script in a /bin directory. Depending on your taste, you can put it under /usr/local/bin so that it's available to everyone or create ~/.local/bin and add it to your PATH variable. To use the script, you need to append a dot before it. This is needed so that the script is run in the current shell and exported variables are copied over.

$ . aws-auth
Usage: /Users/ppabis/.local/bin/aws-auth <MFA code>
$ . aws-auth 223344 Logged in as EC2Developer @ 999123456789

Extending the script

Now we can go to the real programming. This time we will not hardcode the values in the script but rather keep them in a separate file - ~/.aws/credentials to be specific. How will we do it? Using comments with specific markers. As an example:

# @AuthEntry
# @Label:Staging/EC2Developer
# @MFA:arn:aws:iam::1234567890:mfa/EC2DeveloperMFA
[EC2Developer]
aws_access_key_id = AKIA1234567890
aws_secret_access_key = abababba/bababa/abababab

# @AuthEntry
# @Label:Staging/S3Developer
# @MFA:arn:aws:iam::1234567890:mfa/S3DeveloperMFA
[S3Developer]
aws_access_key_id = AKIA0987654321
aws_secret_access_key = babababa/ababab/abababa

In Bash we are going scan the file line by line and if we find @AuthEntry, we will start parsing next lines until we find profile name in square brackets. For convenience of use, we will build a GUI using dialog package. On Mac, you can install it with Homebrew (brew install dialog). On Linux, it should be installed by default (at least on Debian/Ubuntu).

Let's start by defining the arrays that will be filled with required data.

#!/bin/bash
### Easy MFA for AWS CLI - version 2.0

LIST=()
MFAS=()
PROFILES=()

Next, we will scan the file and fill the arrays. This part will be more complex. We will use grep with Perl regex compatibility to check if the line contains values we want. If we find the line with profile name, we will append all the temporary variables into the arrays and reset them. If we find @AuthEntry, we will mark a variable (AUTH_ENTRY_MARKER) that we are in the parsing mode. As each item in dialog's list needs to have an id, we will also count how many records we found.

On Mac, use grep -E instead of grep -P as the latter is not supported.

AUTH_ENTRY_MARKER=0         # Parsing on/off
AUTH_ENTRIES=0              # Number of entries found
CURRENT_ENTRY_LABEL=""      # Label of the entry
CURRENT_ENTRY_MFA=""        # MFA ARN of the entry
CURRENT_ENTRY_PROFILE=""    # Profile name of the entry

### The parsing logic
while IFS= read -r line; do
  if [[ $AUTH_ENTRY_MARKER -eq 1 ]]; then
    ## We are in a parsing mode
    ## Logic to handle single entry

    ## First: if line is the profile entry (`[]`), finish this routine and append the
    ## new entry to the list. Also reset the variables.
    if (echo "$line" | grep -Pq '^\[.*\]'); then

      CURRENT_ENTRY_PROFILE=$(echo "$line" | grep -oP '\[.*\]' | tr -d '[]')
      ## If the label was not specified, just use the profile name.
      [[ "$CURRENT_ENTRY_LABEL" == "" ]] && CURRENT_ENTRY_LABEL=$CURRENT_ENTRY_PROFILE

      ## Append the entry to the list only if it has a MFA
      if [[ "$CURRENT_ENTRY_MFA" != "" ]]; then
        LIST+=($AUTH_ENTRIES $CURRENT_ENTRY_LABEL)
        MFAS+=($CURRENT_ENTRY_MFA)
        PROFILES+=($CURRENT_ENTRY_PROFILE)
        AUTH_ENTRIES=$((AUTH_ENTRIES+1))
      fi

      ## Reset all the values
      AUTH_ENTRY_MARKER=0
      CURRENT_ENTRY_LABEL=""
      CURRENT_ENTRY_MFA=""
      CURRENT_ENTRY_PROFILE=""

    ## Match only lines starting with # @MFA: or # @Label: and extract the values
    elif (echo "$line" | grep -Pq '^#\s*@MFA:.*'); then
      CURRENT_ENTRY_MFA=$(echo "$line" | grep -oP '@MFA:.*\s*' | cut -d':' -f2- | tr -d ' ')
    elif (echo "$line" | grep -Pq '^#\s*@Label:.*'); then
      CURRENT_ENTRY_LABEL=$(echo "$line" | grep -oP '@Label:.*' | cut -d':' -f2- | tr -d ' ')
    fi

  else

    ## We are not parsing so continue searching the lines for the auth entry marker
    if (echo "$line" | grep -Pq '^#\s*@AuthEntry'); then
      ## We found @AuthEntry
      AUTH_ENTRY_MARKER=1
    fi
  fi
done < ~/.aws/credentials

Now we have all the lists needed. We will now create a list using dialog and after selection, we will ask for MFA code. In order to capture dialog's input to a variable, we need to do some Bash magic with file descriptors (3>&1 1>&2 2>&3 3>&-).

SELECTION=$(\
 dialog --no-tags\
 --menu "Profile:"\
 11 30 3\
 ${LIST[@]}\
 3>&1 1>&2 2>&3 3>&-\
)

if [[ $? -ne 0 ]]; then
  echo "Canceled"
  exit 1
fi

PROFILE=${PROFILES[$SELECTION]}
MFA=${MFAS[$SELECTION]}

MFACODE=$(\
 dialog --inputbox "Enter MFA code: "\
 8 20\
 3>&1 1>&2 2>&3 3>&-\
)

if [[ $? -ne 0 ]] || [[ "$MFACODE" == "" ]]; then
  echo "Canceled"
  exit 1
fi

Now we just append the code from the first version and we are good to go. The finished script is available on GitHub: