Enforce MFA to AWS IAM users
Ensure secure authentication by attaching a policy to enforce MFA to IAM users
Welcome to yet another blog post, where we learn how to enforce Multi-factor authentication to IAM users and ensure secure authentication.
This solution will block access to all AWS resources until the user enables MFA on AWS. We will implement this at scale (assuming you have multiple accounts under AWS Organizations)
We will add a feature where we can whitelist some users using tags. The reason is, sometimes an IAM user is created and used as a service account using access keys. (Which is a bad practice. Instead, IAM Roles should be used)
Overview
To achieve our goal, and make sure that our solution is scalable, we will use the following AWS resources:
AWS Stack and StackSet
AWS Stack and StackSet will help us deploy our solution to all the accounts that are a part of the Organization. (Assuming AWS Organization is enabled).
If AWS organization is not enabled then the manual overhead of deploying the Stack to every account increases. (Feel free to correct me in the comments)
AWS Eventbridge
- We will use AWS Eventbridge to create an event pattern rule. Using this event pattern, whenever a defined event occurs, the event pattern rule will trigger our Lambda.
AWS Lambda
- We will parse the data sent by AWS Eventbridge and send an appropriate slack alert.
Implementation
Lambda
- Let's start by defining the packages to be exported
import json
import boto3
import os, math
import requests
import datetime, time
from botocore.exceptions import ClientError
- We'll now define the policy that we will attach to the user, to enforce MFA
policyJson = {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowViewAccountInfo",
"Effect": "Allow",
"Action": [
"iam:GetAccountPasswordPolicy",
"iam:GetAccountSummary",
"iam:ListVirtualMFADevices"
],
"Resource": "*"
},
{
"Sid": "AllowManageOwnPasswords",
"Effect": "Allow",
"Action": [
"iam:ChangePassword",
"iam:GetUser"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "AllowManageOwnAccessKeys",
"Effect": "Allow",
"Action": [
"iam:CreateAccessKey",
"iam:DeleteAccessKey",
"iam:ListAccessKeys",
"iam:UpdateAccessKey"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "AllowManageOwnSigningCertificates",
"Effect": "Allow",
"Action": [
"iam:DeleteSigningCertificate",
"iam:ListSigningCertificates",
"iam:UpdateSigningCertificate",
"iam:UploadSigningCertificate"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "AllowManageOwnSSHPublicKeys",
"Effect": "Allow",
"Action": [
"iam:DeleteSSHPublicKey",
"iam:GetSSHPublicKey",
"iam:ListSSHPublicKeys",
"iam:UpdateSSHPublicKey",
"iam:UploadSSHPublicKey"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "AllowManageOwnGitCredentials",
"Effect": "Allow",
"Action": [
"iam:CreateServiceSpecificCredential",
"iam:DeleteServiceSpecificCredential",
"iam:ListServiceSpecificCredentials",
"iam:ResetServiceSpecificCredential",
"iam:UpdateServiceSpecificCredential"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "AllowManageOwnVirtualMFADevice",
"Effect": "Allow",
"Action": [
"iam:CreateVirtualMFADevice",
"iam:DeleteVirtualMFADevice"
],
"Resource": "arn:aws:iam::*:mfa/${aws:username}"
},
{
"Sid": "AllowManageOwnUserMFA",
"Effect": "Allow",
"Action": [
"iam:DeactivateMFADevice",
"iam:EnableMFADevice",
"iam:ListMFADevices",
"iam:ResyncMFADevice"
],
"Resource": "arn:aws:iam::*:user/${aws:username}"
},
{
"Sid": "DenyAllExceptListedIfNoMFA",
"Effect": "Deny",
"NotAction": [
"iam:CreateVirtualMFADevice",
"iam:EnableMFADevice",
"iam:GetUser",
"iam:ListUsers",
"iam:ListMFADevices",
"iam:ListVirtualMFADevices",
"iam:ResyncMFADevice",
"iam:DeleteVirtualMFADevice",
"iam:ChangePassword",
"iam:CreateLoginProfile",
"sts:GetSessionToken"
],
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "false"
}
}
}
]
}
- Following global variables are used:
client = boto3.client('iam')
sns = boto3.client('sns')
sts = boto3.client('sts')
iam_resource = boto3.resource('iam')
paginator = client.get_paginator('list_account_aliases')
whitelist_tags = os.environ['WHITELIST_TAG']
response = client.list_users()
url = os.environ['WEBHOOK_URL']
MFA_POLICY_NAME = "ForceMFA"
slack_emoji = ":aws-iam:"
- Next, we check for some constraints before attaching the policy.
- First we need to check if the managed policy exists in the account.
def is_policy_exist(policy_name):
policy_exist = True
account_id = sts.get_caller_identity()['Account']
policy_arn = f'arn:aws:iam::{account_id}:policy/{policy_name}'
try:
# Check if policy exist Fast and direct
_ = client.get_policy(PolicyArn=policy_arn)['Policy']
except client.exceptions.NoSuchEntityException as error:
print("Creating a new ForceMFA Policy")
policy_exist = False
return policy_exist
- If the policy doesn't exist, we will create the policy
- Next, we will check if the user is whitelisted (using tags). The whitelisted tags can be passed as an environment variable. The environment variable can be passed in the format
<key>:<value>
. - For example, the following tag can be applied to the users to be whitelisted:
userType: Service
- Following code is used to check if the user is whitelisted:
def is_user_whitelisted(username):
iam_user = iam_resource.User(username)
# key = 'userType'
# value = 'Service'
# If user has no tags, return False
print("is_user_whitelisted",iam_user.tags)
if iam_user.tags == None:
return False
whitelist_tag_list = whitelist_tags.split(',')
for tag_pair in whitelist_tag_list:
key = tag_pair.split(':')[0].strip()
value = tag_pair.split(':')[1].strip()
print(key, value)
for tag in iam_user.tags:
if tag["Key"] == key and tag["Value"].lower() == value.lower():
print("Ignoring user {}. Whitelisted using Tag".format(username))
return True
return False
- If the user is not whitelisted, we can check if the MFA is enabled for the user. We will skip the users that have MFA enabled.
- If in the future, the user deletes their virtual MFA device, our automation will detect it and apply the MFA enforcing policy.
- The code to check if the MFA is enabled is as follows:
def is_mfa_enabled(username):
userMfa = client.list_mfa_devices(UserName=username)
print("UserName " + username, userMfa)
if len(userMfa['MFADevices']) == 0:
return False
else:
print("Ignoring user {}. MFA is already enabled".format(username))
return True
- Next, if the MFA is not enabled, we'll check if the policy is already attached to the user
def is_policy_attached(user,userPolicyList):
polList = []
userPolicies = userPolicyList['AttachedPolicies']
print("is Policy Attached for + " + user, userPolicies)
for policy in userPolicies:
if policy['PolicyName'] == MFA_POLICY_NAME:
print("Ignoring user {}. MFA Policy already exist".format(user))
return True
return False
- AWS constraints on how many policies (AWS managed + Customer managed) can be attached to an IAM user. Only 10 policies can be attached to an IAM user.
- Following code block will give total policy attached count:
# Get number of managed Policies attached to the user
def get_attached_policy_count(username):
# iam_client = get_iam_client()
managed_user_policies = client.list_attached_user_policies(UserName=username)
deny_policy_name = 'ForceMFA'
attached_policies = managed_user_policies['AttachedPolicies']
policy_count = len(attached_policies)
for policy in attached_policies:
# This is to make sure we don't count our very own attached policy. Because that can be deleted and attached again after updating
if policy['PolicyName'] == deny_policy_name:
policy_count = policy_count - 1
return policy_count
- Following is the code to send Slack alerts:
def send_slack_notification(status_code,user,account_id):
account_alias = get_account_alias()
payload = ""
if status_code == 1:
payload = """{
\n\t\"channel\": \"aws-custom-alerts\",
\n\t\"username\": \"Enforce MFA\",
\n\t\"icon_emoji\": \"""" + slack_emoji + """\",
\n\t\"attachments\":[\n
{\n
\"fallback\":\"MFA Enabled\",\n
\"pretext\":\"MFA Enabled\",\n
\"color\":\"#34bb13\",\n
\"fields\":[\n
{\n
\"value\":\"*User:* """ + user + """\n*AccountId:* """ + account_id + """\n*Account Alias:* """ + account_alias + """ \"\n
}\n
]\n
}\n
]\n
}"""
elif status_code == 2:
payload = """{
\n\t\"channel\": \"aws-custom-alerts\",
\n\t\"username\": \"Enforce MFA\",
\n\t\"icon_emoji\": \"""" + slack_emoji + """\",
\n\t\"attachments\":[\n
{\n
\"fallback\":\"MFA Enabled\",\n
\"pretext\":\"MFA Enabled\",\n
\"color\":\"#34bb13\",\n
\"fields\":[\n
{\n
\"value\":\":x: Could not attach ForceMFA Policy to the user :x:\n*Reason*: Cannot exceed quota for PoliciesPerUser: 10\n*Account:* """ + account_alias + """\n*User:* """ + user + """\n*AccountId:* """ + account_id + """\",\n
}\n
]\n
}\n
]\n
}"""
time.sleep(3) # To avoid slack api rate limit.
return payload
- Lastly, let's combine all the checks and let's define our lambda handler:
def lambda_handler(event,context):
# Check if the policy exist in this account. If not create one.
if not is_policy_exist(MFA_POLICY_NAME):
policyStr = json.dumps(policyJson)
client.create_policy(PolicyName=MFA_POLICY_NAME,PolicyDocument=policyStr,Description="Policy Creation from Lambda function - Enforcing MFA")
for user in response['Users']:
username = user['UserName']
userPolicyList = client.list_attached_user_policies(UserName=username)
account_id = sts.get_caller_identity()['Account']
if get_attached_policy_count(username) == 10:
slack_response = requests.request("POST", url, data=send_slack_notification(2,username,account_id), headers=headers)
elif not is_user_whitelisted(username) and not is_policy_attached(username,userPolicyList) and not is_mfa_enabled(username):
policy_arn = f'arn:aws:iam::{account_id}:policy/{MFA_POLICY_NAME}'
response2 = client.attach_user_policy(PolicyArn=policy_arn,UserName=username)
print("Attaching ForceMFA policy to the user {}".format(username))
slack_response = requests.request("POST", url, data=send_slack_notification(1,username,account_id), headers=headers)
- In the above code we are checking the above-discussed conditions for every user, and if all the conditions are in favor of attaching the MFA enforcing policy, we go ahead and attach the policy and send an alert on slack.
AWS Eventbridge
- We will leverage AWS Eventbridge to schedule and trigger the above-defined Lambda.
- Since we're automating our solution and deploying it at scale, we will use CloudFormation Stacks and StackSets.
AWS CloudFormation template
- This template will be similar to the one we defined in the Previous Blog.
- Upload the code on AWS S3 in
.zip
format and copy S3 Bucket Name and S3 Key. Enter this data as parameter values in the CloudFormation template. - This is how the CloudFormation template looks like:
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description" : "Deploy Lambda Function to attach Force MFA policy to the user who had not enabled Physical/Virtual MFA.",
"Parameters" : {
"SlackWebhookParameter" : {
"Type" : "String",
"Default" : "",
"Description" : "Webhook for Slack Channel"
},
"SlackChannelName" : {
"Type" : "String",
"Default" : "",
"Description" : "Name of the slack channel where you want alerts"
},
"WhitelistTag" : {
"Type" : "String",
"Default" : "userType:Service",
"Description" : "List of tags that will be whitelisted."
},
"S3Bucket" : {
"Type" : "String",
"Default" : "",
"Description" : "Name of the S3 bucket where the lambda is stored"
},
"S3Key" : {
"Type" : "String",
"Default" : "",
"Description" : "Key name of the S3 object"
},
"LambdaHandler" : {
"Type" : "String",
"Default" : "",
"Description" : "Lambda Handler name E.g: <file_name>.lambda_handler"
}
},
"Resources": {
"EnforceMFALambda": {
"Type": "AWS::Lambda::Function",
"Properties": {
"FunctionName": "enforceMFA",
"Tags": [
{
"Key": "CreatedBy",
"Value": "Security Team"
}
],
"Handler": { "Ref": "LambdaHandler" },
"Environment" : {
"Variables": {
"WEBHOOK_URL": { "Ref": "SlackWebhookParameter" },
"SLACK_CHANNEL_NAME": { "Ref": "SlackChannelName" },
"WHITELIST_TAG": { "Ref": "WhitelistTag" }
}
},
"Role": {
"Fn::GetAtt": [
"mfaEnforceLambdaRole",
"Arn"
]
},
"Code": {
"S3Bucket": { "Ref": "S3Bucket" },
"S3Key": { "Ref": "S3Key" }
},
"Runtime": "python3.7",
"Timeout": 900
}
},
"mfaEnforceLambdaRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"RoleName": "mfaEnforceLambdaRole",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Service": [ "lambda.amazonaws.com" ]
},
"Action": [ "sts:AssumeRole" ]
}]
},
"Path": "/",
"Policies": [{
"PolicyName": "EnforceMFALambdaPolicy",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:CreatePolicy",
"iam:ListPolicies",
"iam:ListAttachedUserPolicies",
"iam:AttachUserPolicy",
"iam:ListAccountAliases",
"iam:ListUsers",
"iam:ListUserPolicies",
"iam:ListMFADevices",
"iam:ListVirtualMFADevices",
"iam:GetLoginProfile",
"iam:ListUserTags",
"iam:GetAccountSummary",
"iam:GetPolicy",
"iam:GetUser",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:CreateLogGroup"
],
"Resource": "*"
}
]
}
}]
}
},
"ScheduledRule": {
"Type": "AWS::Events::Rule",
"Properties": {
"Description": "Rule to trigger EnforceMFA Lambda",
"Name" : "enforeMFALambdaRule",
"ScheduleExpression": "cron(0 12 * * ? *)",
"State": "ENABLED",
"Targets": [{
"Arn": { "Fn::GetAtt": ["EnforceMFALambda", "Arn"] },
"Id": "TargetFunctionV1"
}]
}
},
"PermissionForEventsToInvokeLambda": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"FunctionName": { "Ref": "EnforceMFALambda" },
"Action": "lambda:InvokeFunction",
"Principal": "events.amazonaws.com",
"SourceArn": { "Fn::GetAtt": ["ScheduledRule", "Arn"] }
}
}
}
}
Slack Alerts
- The Lambda will be triggered at 12 PM UTC and whenever a policy is attached to the user, a Slack alert will be sent.
Conclusion
We have successfully deployed our solution to all the cloud accounts using Cloudformation Stacks and StackSets. To verify that we have deployed it correctly, you can visit any account and check for the Lambda that is created, the Cloudwatch event-pattern rule, and the IAM Role created for the Lambda.
We used Cloudformation StackSets to implement AWS security measures that are scalable. In the future, if there is any new account added to the Organization, the same stack will be created for that account automatically.