From e6b9bd31d8bb42408aa9ba13283fba3d83cd8ba7 Mon Sep 17 00:00:00 2001 From: Henrik Johansson Date: Tue, 31 Oct 2017 03:00:44 -0700 Subject: [PATCH] Added force_user_mfa demo script --- force_user_mfa/ForceUserMFA.py | 501 +++++++++++++++++++++++++++ force_user_mfa/ForceUserMFA.template | 208 +++++++++++ force_user_mfa/ForceUserMFA.zip | Bin 0 -> 4470 bytes force_user_mfa/README.md | 57 +++ 4 files changed, 766 insertions(+) create mode 100644 force_user_mfa/ForceUserMFA.py create mode 100644 force_user_mfa/ForceUserMFA.template create mode 100644 force_user_mfa/ForceUserMFA.zip create mode 100644 force_user_mfa/README.md diff --git a/force_user_mfa/ForceUserMFA.py b/force_user_mfa/ForceUserMFA.py new file mode 100644 index 0000000..2e49555 --- /dev/null +++ b/force_user_mfa/ForceUserMFA.py @@ -0,0 +1,501 @@ +""" +Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. +Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy of the License is located at + http://aws.amazon.com/apache2.0/ +or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +Summary + +Attributes: + createPassword (bool): Description + IAM_CLIENT (TYPE): Description +""" +from __future__ import print_function +from base64 import b64encode +import json +import hmac +import struct +import base64 +import hashlib +import time +import sys +import boto3 + + +# Do you want to create a randomized password for the user? +createPassword = False + +# Do you want to delete the user if MFA assignment failed? +deleteOnFail = False + +# Log data to DynamoDB? +logActions = True +DynamoDBtable = "userMFA" + +# Central clients +IAM_CLIENT = boto3.client('iam') + + +def lambda_handler(event, context): + """Summary + + Args: + event (TYPE): Description + context (TYPE): Description + + Returns: + TYPE: Description + """ + logdata = create_log_data(event) + mfaFail = False + + # Verify if user is approved to create new IAM users + approved = check_approved(logdata['userName'], logdata['userArn']) + if approved is False: + if deleteOnFail is True: + deleteUser(logdata['newUserName'], logdata['serialNumber']) + print("IAM user " + logdata['userName'] + " not allowed to create users.\nUser " + logdata['newUserName'] + " deleted.") + sys.exit() + print("IAM user " + logdata['userName'] + " not allowed to create users.\nUser " + logdata['newUserName'] + " not deleted.") + + # Create virtual MFA + mfa = create_virtual_mfa(logdata['newUserName'], logdata['newUserArn']) + + # Verify MFA is created and get seed + if "SerialNumber" in str(mfa): + logdata['serialNumber'] = mfa['VirtualMFADevice']['SerialNumber'] + seed = mfa['VirtualMFADevice']['Base32StringSeed'] + enableResult = "" + i = 1 + while enableResult != "Success": + enableResult = enable_mfa(logdata['newUserName'], logdata['serialNumber'], seed) + time.sleep(i) + i += 1 + if i == 10: + print("MFA Creation failed, aborting") + mfaFail = True + if deleteOnFail is True: + deleteUser(logdata['newUserName'], logdata['serialNumber']) + print("Token creation failed, aborting.\nUser " + logdata['newUserName'] + " deleted.") + sys.exit() + else: + print("Token creation failed, aborting.\nUser " + logdata['newUserName'] + " not deleted.") + sys.exit() + print("Seed created") + else: + if deleteOnFail is True: + deleteUser(logdata['newUserName'], logdata['serialNumber']) + print("Token creation failed, aborting.\nUser " + logdata['newUserName'] + " deleted.") + sys.exit() + else: + print("Token creation failed, aborting.\nUser " + logdata['newUserName'] + " not deleted.") + sys.exit() + + # Encrypt the seed using aKMS CMK alias MFAUser. + encryptedSeed = encrypt_string(mfa['VirtualMFADevice']['Base32StringSeed']) + + # Send seed number to user to allow adding it to tokens, can use QR but easier tracking with text. + send_seed(encryptedSeed) + + # Add encrypted seed to logdata + logdata['encryptedSeed'] = str(encryptedSeed) + + # Set randomized password if module is enabled + if createPassword: + logdata['encryptedPass'] = generate_password(logData[userName]) + + # Store seed in parameter store for user to fetch + store_mfa(logdata['newUserName'], mfa['VirtualMFADevice']['Base32StringSeed'], logdata['region'], logdata['account']) + + # Logging + if logActions is True: + result = log_event(logdata) + + print("MFA Created for user " + logdata['newUserName'] + ". Users can retrieve the seed themselves from Parameter Store using:") + print("aws ssm get-parameters --names mfa-" + logdata['newUserName'] + " --with-decryption --region " + logdata['region']) + return 0 + + +def create_log_data(event): + """Summary + + Args: + event (TYPE): Description + + Returns: + TYPE: Description + """ + # Extract used info + try: + userName = event['detail']['userIdentity']['userName'] + except KeyError: + # User is federated/assumeRole + userName = event['detail']['userIdentity']['sessionContext']['sessionIssuer']['userName'] + userArn = event['detail']['userIdentity']['arn'] + accessKeyId = event['detail']['userIdentity']['accessKeyId'] + region = event['region'] + account = event['account'] + eventTime = event['detail']['eventTime'] + userAgent = event['detail']['userAgent'] + sourceIP = event['detail']['sourceIPAddress'] + newUserName = event['detail']['responseElements']['user']['userName'] + newUserArn = event['detail']['responseElements']['user']['arn'] + logData = {} + logData = {'userName': userName, 'userArn': userArn, 'accessKeyId': accessKeyId, 'region': region, 'account': account, 'eventTime': eventTime, 'userAgent': userAgent, 'sourceIP': sourceIP, 'newUserName': newUserName, 'newUserArn': newUserArn} + return logData + + +def mfa_store_policy(user, region, account): + # Let's try and attach the policy if it's created by the CFN template + try: + IAM_CLIENT.attach_user_policy( + UserName=user, + PolicyArn='arn:aws:iam::' + account + ':policy/user_mfa_access' + ) + # If failed we need to create the policy and attach the new one + except: + KMS_CLIENT = boto3.client('kms') + response = KMS_CLIENT.describe_key( + KeyId='alias/MFAUser', + ) + keyArn = response['KeyMetadata']['Arn'] + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameters" + ], + "Resource": "arn:aws:ssm:" + region + ":" + account + ":parameter/mfa-${aws:username}" + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": keyArn + } + ] + } + response = IAM_CLIENT.create_policy( + PolicyName='user_mfa_access', + PolicyDocument=json.dumps(policy), + Description='User policy for MFA token access' + ) + IAM_CLIENT.attach_user_policy( + UserName=user, + PolicyArn='arn:aws:iam::' + account + ':policy/user_mfa_access' + ) + return 0 + + +def store_mfa(user, seed, region, account): + SSM_CLIENT = boto3.client('ssm') + KMS_CLIENT = boto3.client('kms') + response = KMS_CLIENT.describe_key( + KeyId='alias/MFAUser', + ) + keyArn = response['KeyMetadata']['Arn'] + try: + response = SSM_CLIENT.put_parameter( + Name='mfa-' + user, + Description='MFA token seed', + Value=seed, + Type='SecureString', + KeyId=keyArn, + Overwrite=True + ) + mfa_store_policy(user, region, account) + print("Token stored in Parameter Store") + except Exception as e: + print("Failed to store seed. You will need to retrieve it from the used log DDB or create a new token manually.") + response = "Fail" + return response + + +def create_virtual_mfa(newUserName, newUserArn): + """Summary + + Args: + newUserName (TYPE): Description + newUserArn (TYPE): Description + + Returns: + TYPE: Description + """ + print("Creating virtual MFA token") + deviceName = newUserName + '-MFA' + # Try to delete token first to avoid conflict/stale tokens + try: + deviceArn = newUserArn + '-MFA' + response = IAM_CLIENT.delete_virtual_mfa_device( + SerialNumber=deviceArn + ) + except: + pass + # Try to create new token, we will try 5 times before giving up + tries = 0 + while tries < 5: + try: + response = IAM_CLIENT.create_virtual_mfa_device( + VirtualMFADeviceName=deviceName + ) + break + # Try one more time if fails, could be race issue with delete + except: + time.sleep(tries + 1) + response = str(sys.exc_info()[0]) + if "SerialNumber" in str(response): + return response + else: + return "FailedToCreateToken" + + +def deleteUser(userName, SN): + try: + response = client.deactivate_mfa_device( + UserName=userName, + SerialNumber=SN + ) + print("MFA device deactivated, trying to delete device.") + except: + print("Unable to deactivate MFA token. Could be that it's not created.") + try: + response = client.delete_virtual_mfa_device( + SerialNumber=SN + ) + print("MFA device deleted, trying to delete new user.") + except: + print("Unable to delete MFA token. Could be that it's not created.") + try: + response = client.delete_user( + UserName=userName + ) + print("User deleted") + except: + print("Unable to delete user: " + userName) + return True + + +def enable_mfa(userName, mfaArn, seed): + """Summary + + Args: + userName (TYPE): Description + mfaArn (TYPE): Description + seed (TYPE): Description + + Returns: + TYPE: Description + """ + # Get token 1 + token1 = generate_token(seed) + x = 0 + fail = False + while (len(str(token1)) != 6): + token1 = generate_token(seed) + time.sleep(5) + x = x + 1 + if x > 20: + fail = True + break + if fail: + print("Token1 creation failed. Token1 = " + str(token1)) + return "token1 fail" + + # Get token 2 + time.sleep(5) + token2 = generate_token(seed) + x = 0 + fail = False + while (token1 == token2) or (len(str(token2)) != 6): + time.sleep(5) + token2 = generate_token(seed) + x = x + 1 + if x > 20: + fail = True + break + if fail: + print("Token2 creation failed. Token1 = " + str(token2)) + return "token2 fail" + print("Token enabled") + + # Attach to user + try: + response = IAM_CLIENT.enable_mfa_device( + UserName=userName, + SerialNumber=mfaArn, + AuthenticationCode1=str(token1), + AuthenticationCode2=str(token2) + ) + except: + response = str(sys.exc_info()[0]) + print("Attach to user failed for user: " + userName) + print("Will try 10 times") + print(response) + else: + response = "Success" + print("Token assigned to user: " + userName) + return response + + +def generate_password(newUserName): + """Summary + + Args: + newUserName (TYPE): Description + + Returns: + TYPE: Description + """ + N = 17 + pwd = ''.join(random.SystemRandom().choice(string.ascii_letters + '!@#$%^&*()_+-=[]\{\}|\'' + string.digits) for _ in range(N)) + iam_resource = boto3.resource('iam') + user = iam_resource.User(newUserName) + login_profile = user.create_login_profile( + Password=pwd, + PasswordResetRequired=True + ) + pwd = "" + return 0 + + +def generate_token(seed): + """Summary + + Args: + seed (TYPE): Description + + Returns: + TYPE: Description + """ + seed = base64.b32decode(seed, True) + hmacHash = hmac.new( + seed, struct.pack( + ">Q", int( + time.time() // 30)), + hashlib.sha1).digest() + hashOffset = ord(hmacHash[19]) & 0xf + token = (struct.unpack( + ">I", + hmacHash[hashOffset:hashOffset + 4])[0] & 0x7fffffff) % 10 ** 6 + return token + + +def check_approved(userName, userArn): + """Summary + + Args: + userName (TYPE): Description + userArn (TYPE): Description + + Returns: + TYPE: Description + """ + # Default + approved = False + + # Connect change record DDB + + # Check if approved for adding users + + # Check how many users added + + # Determine if account should be locked + + approved = True + return approved + + +def encrypt_string(value): + """Summary + + Args: + value (TYPE): Description + + Returns: + TYPE: Description + """ + # Encrypt using AWS Key Management Service + KMS_CLIENT = boto3.client('kms') + try: + encryptedString = b64encode( + KMS_CLIENT.encrypt( + KeyId='alias/MFAUser', Plaintext=value + )['CiphertextBlob']) + except: + print("Failed to encrypt seed, no key with alias MFAUser.\nSeed will not be stored in logs.") + encryptedString = "Failed to encrypt using KMS CMK alias MFAUser, seed will not be stored." + return encryptedString + + +def send_seed(encryptedSeed): + """Summary + + Args: + encryptedSeed (TYPE): Description + + Returns: + TYPE: Description + """ + # Send to DDB or alternative recipient using for example SNS. + # Note that sending over unecnrypted protocols/methods is not recomended. + # This script also uses Parameter Store for per user access + result = "Using DDB and Parameter Store" + return result + + +def log_event(logData): + """Summary + + Args: + logData (TYPE): Description + + Returns: + TYPE: Description + """ + client = boto3.client('dynamodb') + resource = boto3.resource('dynamodb') + + # Verify that the table exists + tableExists = False + try: + result = client.describe_table(TableName=DynamoDBtable) + tableExists = True + except: + # Table does not exist, create it + table = resource.create_table( + TableName=DynamoDBtable, + KeySchema=[ + {'AttributeName': 'userName', 'KeyType': 'HASH'}, + {'AttributeName': 'eventTime', 'KeyType': 'RANGE'} + ], + AttributeDefinitions=[ + {'AttributeName': 'userName', 'AttributeType': 'S'}, + {'AttributeName': 'eventTime', 'AttributeType': 'S'} + ], + ProvisionedThroughput={'ReadCapacityUnits': 5, 'WriteCapacityUnits': 5} + ) + + # Wait for table creation + table.meta.client.get_waiter('table_exists').wait(TableName=DynamoDBtable) + tableExists = True + + response = client.put_item( + TableName=DynamoDBtable, + Item={ + 'userName': {'S': logData['newUserName']}, + 'userArn': {'S': logData['newUserArn']}, + 'encryptedSeed': {'S': logData['encryptedSeed']}, + 'callerUserName': {'S': logData['userName']}, + 'callerUserArn': {'S': logData['userArn']}, + 'callerAccessKeyId': {'S': logData['accessKeyId']}, + 'region': {'S': logData['region']}, + 'account': {'S': logData['account']}, + 'eventTime': {'S': logData['eventTime']}, + 'userAgent': {'S': logData['userAgent']}, + 'sourceIP': {'S': logData['sourceIP']} + } + ) + return 0 diff --git a/force_user_mfa/ForceUserMFA.template b/force_user_mfa/ForceUserMFA.template new file mode 100644 index 0000000..8d0bb58 --- /dev/null +++ b/force_user_mfa/ForceUserMFA.template @@ -0,0 +1,208 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + LambdaCodeBucket: + Description: What bucket is the code stored in? + Type: String + MinLength: 3 + MaxLength: 63 + AllowedPattern: ^([a-zA-Z0-9\-]+\.?[a-zA-Z0-9\-]*)+[^\.\s]$ + LambdaCodePath: + Default: ForceUserMFA.zip + Description: Path/name of zip library + Type: String + MinLength: 3 + MaxLength: 63 +Resources: + AutoMFALambda: + Type: 'AWS::Lambda::Function' + Properties: + Handler: "ForceUserMFA.lambda_handler" + Role: + Fn::GetAtt: + - "autoMfaLambdaRole" + - "Arn" + Code: + S3Bucket: + Ref: LambdaCodeBucket + S3Key: + Ref: LambdaCodePath + Runtime: "python2.7" + Timeout: "300" + userMFACMK: + Type: 'AWS::KMS::Key' + Properties: + Description: "MFAUser key" + KeyPolicy: + Version: "2012-10-17" + Id: "MFAUser" + Statement: + - + Sid: "Allow administration of the key" + Effect: "Allow" + Principal: + AWS: !Join ["", ["arn:aws:sts::", !Ref "AWS::AccountId", ":root"]] + Action: + - "kms:Create*" + - "kms:Describe*" + - "kms:Decrypt" + - "kms:Enable*" + - "kms:List*" + - "kms:Put*" + - "kms:Update*" + - "kms:Revoke*" + - "kms:Disable*" + - "kms:Get*" + - "kms:Delete*" + - "kms:ScheduleKeyDeletion" + - "kms:CancelKeyDeletion" + Resource: "*" + - + Sid: "Allow use of the key" + Effect: "Allow" + Principal: + AWS: + Fn::GetAtt: + - "autoMfaLambdaRole" + - "Arn" + Action: + - "kms:Encrypt" + - "kms:DescribeKey" + Resource: "*" + KMSAlias: + Type: 'AWS::KMS::Alias' + Properties: + AliasName: "alias/MFAUser" + TargetKeyId: !Ref userMFACMK + DependsOn: userMFACMK + autoMfaLambdaRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: "/" + Policies: + - + PolicyName: root + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:* + Resource: arn:aws:logs:*:*:* + userMFAPolicy: + Type: 'AWS::IAM::ManagedPolicy' + Properties: + Path : '/' + ManagedPolicyName: user_mfa_access + Description: "Policy for users to access their virtual mfa seed" + PolicyDocument: + Version: 2012-10-17 + Statement: + - + Effect: Allow + Action: 'ssm:GetParameters' + Resource: !Join ["", ["arn:aws:ssm:", !Ref "AWS::Region", ":", !Ref "AWS::AccountId", ":parameter/mfa-${aws:username}"]] + - + Effect: Allow + Action: 'kms:Decrypt' + Resource: + Fn::GetAtt: + - "userMFACMK" + - "Arn" + UserMFALambdaPolicy: + Type: 'AWS::IAM::Policy' + Properties: + Roles: + - !Ref autoMfaLambdaRole + PolicyName: user_mfa_lambda_exec_pol + PolicyDocument: + Version: 2012-10-17 + Statement: + - + Effect: Allow + Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + Resource: "*" + - + Effect: Allow + Action: + - 'iam:ListMFADevices' + - 'iam:ListUsers' + - 'iam:ListVirtualMFADevices' + - 'iam:CreateVirtualMFADevice' + - 'iam:DeactivateMFADevice' + - 'iam:DeleteVirtualMFADevice' + - 'iam:EnableMFADevice' + - 'iam:ListVirtualMFADevices' + - 'iam:CreatePolicy' + - 'iam:AttachUserPolicy' + Resource: "*" + - + Effect: Allow + Action: + - 'kms:describe_key' + Resource: + Fn::GetAtt: + - "userMFACMK" + - "Arn" + - + Effect: Allow + Action: + - 'ssm:PutParameter' + Resource: "*" + - + Effect: Allow + Action: + - 'dynamodb:PutItem' + - 'dynamodb:UpdateItem' + - 'dynamodb:CreateTable' + - 'dynamodb:DescribeTable' + Resource: "*" + - + Effect: Allow + Action: + - 's3:Get*' + - 's3:List*' + Resource: "*" + AutoMFAConfigRule: + Type: 'AWS::Events::Rule' + Properties: + Description: "MFA Auto Add" + EventPattern: + detail-type: + - "AWS API Call via CloudTrail" + detail: + eventSource: + - "iam.amazonaws.com" + eventName: + - "CreateUser" + State: "ENABLED" + Targets: + - + Arn: + Fn::GetAtt: + - "AutoMFALambda" + - "Arn" + Id: "AutoMFALambda" + PermissionForEventsToInvokeLambda: + Type: "AWS::Lambda::Permission" + Properties: + FunctionName: + Ref: "AutoMFALambda" + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: + Fn::GetAtt: + - "AutoMFAConfigRule" + - "Arn" diff --git a/force_user_mfa/ForceUserMFA.zip b/force_user_mfa/ForceUserMFA.zip new file mode 100644 index 0000000000000000000000000000000000000000..35548f102628063c963773418c84ef530e4c6c54 GIT binary patch literal 4470 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