Secure an AWS account by requiring MFA on all console and API calls

This post shows how to configure and deploy an IAM policy that will require MFA on all console logins and API calls.

Prerequisites

  1. Terraform installed on your workstation..
  2. AWS CLI installed on your workstation and familiarity with how to setup named profiles.

Procedure

  1. Create an AWS profile on your workstation named something-long-term with your IAM user access and secret keys.

    Your .aws/credentials file should look like this:

    > cat ~/.aws/credentials 
    
    [something-long-term]
    aws_access_key_id = AKIAXXXXXXXXXXXX
    aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXX
    
  2. Test the credentials by attempting to list IAM users with the AWS CLI. You should find the cloud_user:

    > aws iam list-users --profile something-long-term
    
    {
        "Users": [
            {
                "UserName": "cloud_user", 
                "PasswordLastUsed": "2019-04-14T18:13:04Z", 
                "CreateDate": "2019-04-14T16:00:21Z", 
                "UserId": "XXXXXXXXXXXXXXXXXXXXX", 
                "Path": "/", 
                "Arn": "arn:aws:iam::123456789000:user/cloud_user"
            }
        ]
    }
    
  3. Create a directory for your Terraform project.

  4. Create a file named terraform.tf and configure the AWS provider to use your profile credentials and region us-east-1.

    terraform.tf:

    provider "aws" {
      profile = "something-long-term"
      region  = "us-east-1"
    }
    
  5. Execute terraform init in your directory. This will download the AWS provider plugin in your project.

  6. You should now be able to execute terraform plan and get a response that says: No changes. Infrastructure is up-to-date.

  7. Create iam-groups.tf and add one group named require-mfa:

    iam-groups.tf:

    resource "aws_iam_group" "require_mfa" {
      name = "require-mfa"
    }
    
  8. Create iam-policy-mfa.tf.

    This policy allows IAM users to create an MFA device on their first login in the console. Once the MFA is configured, they can then self-manage their credentials: console password, access keys, MFAs.

    Note that some statements target different types of resources:

    • arn:aws:iam::*:mfa/$${aws:username}: for the MFA device
    • arn:aws:iam::*:user/$${aws:username}: for the user

    Terraform interpolation are wrapped in ${}. This is why a double dollar sign is required to escape one of them and render ${aws:username} in the actual IAM policy.

    The last statement is a little bit confusing. It will allow a user to perform a set of actions only if aws:MultiFactorAuthPresent is set to false. The aws:MultiFactorAuthPresent key is never present when an API or CLI command is called with long-term credentials, such as standard access key pairs. This means that it would only be possible for a user to perform those allowed actions if they logged in on the console and don’t already have an MFA device configured. As soon as there’s an MFA device configured, the console login will request a valid MFA authentication before providing access.

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    
    data "aws_iam_policy_document" "iam_selfservice" {
      statement {
        sid = "AllowViewAccountInfo"
    
        effect = "Allow"
    
        actions = [
          "iam:GetAccountPasswordPolicy",
          "iam:GetAccountSummary",
          "iam:ListVirtualMFADevices",
        ]
    
        resources = [
          "*",
        ]
      }
    
      statement {
        sid = "AllowManageOwnPasswords"
    
        effect = "Allow"
    
        actions = [
          "iam:ChangePassword",
          "iam:GetUser",
        ]
    
        resources = [
          "arn:aws:iam::*:mfa/$${aws:username}",
        ]
      }
    
      statement {
        sid = "AllowManageOwnAccessKeys"
    
        effect = "Allow"
    
        actions = [
          "iam:CreateAccessKey",
          "iam:DeleteAccessKey",
          "iam:ListAccessKeys",
          "iam:UpdateAccessKey",
        ]
    
        resources = [
          "arn:aws:iam::*:user/$${aws:username}",
        ]
      }
    
      statement {
        sid = "AllowManageOwnVirtualMFADevice"
    
        effect = "Allow"
    
        actions = [
          "iam:CreateVirtualMFADevice",
          "iam:DeleteVirtualMFADevice",
        ]
    
        resources = [
          "arn:aws:iam::*:mfa/$${aws:username}",
        ]
      }
    
      statement {
        sid = "AllowManageOwnUserMFA"
    
        effect = "Allow"
    
        actions = [
          "iam:DeactivateMFADevice",
          "iam:EnableMFADevice",
          "iam:ListMFADevices",
          "iam:ResyncMFADevice",
        ]
    
        resources = [
          "arn:aws:iam::*:user/$${aws:username}",
        ]
      }
    
      statement {
        sid = "DenyAllExceptListedIfNoMFA"
    
        effect = "Deny"
    
        not_actions = [
          "iam:CreateVirtualMFADevice",
          "iam:EnableMFADevice",
          "iam:GetUser",
          "iam:ListMFADevices",
          "iam:ListVirtualMFADevices",
          "iam:ResyncMFADevice",
          "sts:GetSessionToken",
        ]
    
        resources = ["*"]
    
        condition = {
          test     = "BoolIfExists"
          variable = "aws:MultiFactorAuthPresent"
          values   = ["false"]
        }
      }
    }
    
    resource "aws_iam_policy" "iam_selfservice" {
      name   = "selfservice"
      policy = "${data.aws_iam_policy_document.iam_selfservice.json}"
    }
    
    resource "aws_iam_group_policy_attachment" "iam_selfservice" {
      group      = "${aws_iam_group.require_mfa.name}"
      policy_arn = "${aws_iam_policy.iam_selfservice.arn}"
    }
    
  9. Apply the changes by executing terraform apply. It should create the group, the policy and attach the policy to the group.

  10. Create an MFA device on your IAM user cloud-user.

  11. Logout the AWS console and login again. It should prompt you for your MFA code.

  12. Go back under your cloud-user IAM user configuration and add yourself to the require-mfa group. Note that this will require MFA on all console and API requests made thereafter.

  13. Attempt to list users again using long-term credentials. It should be unauthorized given the new policy requires MFA.

    > aws iam list-users --profile something-long-term
    
  14. Read the section below on MFA usage in AWS. Configure and install aws-mfa. Use it to generate temporary credentials. You should then be able to list users using the something profile that contains temporary credentials with MFA authentication (as opposed to the something-long-term profile that uses long-term credentials):

    > aws iam list-users --profile something
    

    From now on if you want to use Terraform with those temporary credentials, you’ll need to adjust terraform.tf to use the something profile.

MFA usage in AWS

Console

You will automatically be prompted for your MFA after entering your username and password when logging in the AWS console.

AWS CLI and API calls

Once you configure a policy that requires MFA on your IAM users, any AWS CLI or API call made with their long-term access key will be unauthorized.

Your users need to request temporary credentials by calling STS GetSessionToken.

You can do it manually with the AWS CLI like this. The serial number parameter is the ARN of the MFA device configured on your IAM user. The token code is the value provided by the MFA device.

aws sts get-session-token 
  --serial-number arn:aws:iam::123456789000:mfa/cloud_user 
  --token-code 983106 
  --profile something-long-term
{
    "Credentials": {
        "SecretAccessKey": "XXXXXXXXXXXXXXXXXXXXX", 
        "SessionToken": "XXXXXXXXXXXXXXXXXXXXX", 
        "Expiration": "2019-04-15T06:54:09Z", 
        "AccessKeyId": "XXXXXXXXXXXXXXXXXXXXX"
    }
}

You can use the supplied temporary credentials until their expiration.

This is a very cumbersome process when you need to frequently interact with the AWS APIs using either the AWS CLI or direct API requests.

While you could build your own scripts to populate your .aws/credentials file with the temporary credentials automatically, there’s this open source project called aws-mfa that provides this functionality. Please note that you should always carefully review third-party code before using it. This project is fortunately quite small and easy to understand if you know Python.

Here’s an example of .aws/credentials ready to be used with AWS-MFA. You should append -long-term to the name of your AWS profiles that contain long-term credentials so that the tool detects them automatically. You should also add the MFA serial number (ARN) as a paramter called aws_mfa_device:

1
2
3
4
[something-long-term]
aws_access_key_id = XXXXXXXXXXXXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXX
aws_mfa_device = arn:aws:iam::123456789000:mfa/cloud_user

Once this is in place, you can run:

> aws-mfa --profile something
1
2
3
4
5
6
7
INFO - Validating credentials for profile: something 
INFO - Your credentials have expired, renewing.
Enter AWS MFA code for device [arn:aws:iam::123456789000:mfa/cloud_user]
  (renewing for 43200 seconds):250361
INFO - Fetching Credentials - Profile: something, Duration: 43200
INFO - Success! Your credentials will expire in 43200 seconds at: 
  2019-04-15 07:03:44+00:00

The tool will use the long-term credentials present in your .aws/credentials along with the MFA device serial number and your MFA code to craft the GetSessionToken call to AWS, grab the response, and configure it under the base name of your profile. So in my case my long-term profile was something-long-term so it populated temporary credentials in .aws/credentials:

1
2
3
4
5
6
7
[something]
assumed_role = False
aws_access_key_id = XXXXXXXXXXXXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXXXXXXXXXX
aws_session_token = XXXXXXXXXXXXXXXXXXXXX
aws_security_token = XXXXXXXXXXXXXXXXXXXXX
expiration = 2019-04-15 07:03:44

At this point I can now use the something profile, which has temporary credentials that were MFA authenticated (aws:MultiFactorAuthPresent set to true).