From 9671730f166783f6b91c8145c1b1d460e997e0cd Mon Sep 17 00:00:00 2001 From: gruebel Date: Sun, 20 Oct 2019 22:39:57 +0200 Subject: [PATCH 1/4] Add iam.create_virtual_mfa_device --- IMPLEMENTATION_COVERAGE.md | 4 +- moto/iam/exceptions.py | 4 +- moto/iam/models.py | 47 ++++++++++++++++++++++- moto/iam/responses.py | 27 +++++++++++++ tests/test_iam/test_iam.py | 77 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 284f4d68a..f4b3cd3c0 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3163,7 +3163,7 @@ - [ ] describe_events ## iam -57% implemented +59% implemented - [ ] add_client_id_to_open_id_connect_provider - [X] add_role_to_instance_profile - [X] add_user_to_group @@ -3184,7 +3184,7 @@ - [ ] create_service_linked_role - [ ] create_service_specific_credential - [X] create_user -- [ ] create_virtual_mfa_device +- [X] create_virtual_mfa_device - [X] deactivate_mfa_device - [X] delete_access_key - [X] delete_account_alias diff --git a/moto/iam/exceptions.py b/moto/iam/exceptions.py index 0511fa144..afd1373a3 100644 --- a/moto/iam/exceptions.py +++ b/moto/iam/exceptions.py @@ -98,9 +98,9 @@ class TooManyTags(RESTError): class EntityAlreadyExists(RESTError): code = 409 - def __init__(self): + def __init__(self, message): super(EntityAlreadyExists, self).__init__( - 'EntityAlreadyExists', "Unknown") + 'EntityAlreadyExists', message) class ValidationError(RESTError): diff --git a/moto/iam/models.py b/moto/iam/models.py index ba7d0ac82..00aff40e5 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals import base64 +import os +import random +import string import sys from datetime import datetime import json @@ -40,6 +43,22 @@ class MFADevice(object): return iso_8601_datetime_without_milliseconds(self.enable_date) +class VirtualMfaDevice(object): + def __init__(self, device_name): + self.serial_number = 'arn:aws:iam::{0}:mfa{1}'.format(ACCOUNT_ID, device_name) + + random_base32_string = ''.join(random.choice(string.ascii_uppercase + '234567') for _ in range(64)) + self.base32_string_seed = base64.b64encode(random_base32_string.encode('ascii')).decode('ascii') + self.qr_code_png = base64.b64encode(os.urandom(64)) # this would be a generated PNG + + self.enable_date = None + self.user_attribute = None + + @property + def enabled_iso_8601(self): + return iso_8601_datetime_without_milliseconds(self.enable_date) + + class Policy(BaseModel): is_attachable = False @@ -596,6 +615,7 @@ class IAMBackend(BaseBackend): self.open_id_providers = {} self.policy_arn_regex = re.compile( r'^arn:aws:iam::[0-9]*:policy/.*$') + self.virtual_mfa_devices = {} super(IAMBackend, self).__init__() def _init_managed_policies(self): @@ -1250,6 +1270,31 @@ class IAMBackend(BaseBackend): user = self.get_user(user_name) return user.mfa_devices.values() + def create_virtual_mfa_device(self, device_name, path): + if not path: + path = '/' + + if not path.startswith('/') and not path.endswith('/'): + raise ValidationError('The specified value for path is invalid. ' + 'It must begin and end with / and contain only alphanumeric characters and/or / characters.') + + if any(not len(part) for part in path.split('/')[1:-1]): + raise ValidationError('The specified value for path is invalid. ' + 'It must begin and end with / and contain only alphanumeric characters and/or / characters.') + + if len(path) > 512: + raise ValidationError('1 validation error detected: ' + 'Value "{}" at "path" failed to satisfy constraint: ' + 'Member must have length less than or equal to 512') + + device = VirtualMfaDevice(path + device_name) + + if device.serial_number in self.virtual_mfa_devices: + raise EntityAlreadyExists('MFADevice entity at the same path and name already exists.') + + self.virtual_mfa_devices[device.serial_number] = device + return device + def delete_user(self, user_name): try: del self.users[user_name] @@ -1347,7 +1392,7 @@ class IAMBackend(BaseBackend): open_id_provider = OpenIDConnectProvider(url, thumbprint_list, client_id_list) if open_id_provider.arn in self.open_id_providers: - raise EntityAlreadyExists + raise EntityAlreadyExists('Unknown') self.open_id_providers[open_id_provider.arn] = open_id_provider return open_id_provider diff --git a/moto/iam/responses.py b/moto/iam/responses.py index 1c00d211c..c2df50137 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -598,6 +598,15 @@ class IamResponse(BaseResponse): template = self.response_template(LIST_MFA_DEVICES_TEMPLATE) return template.render(user_name=user_name, devices=devices) + def create_virtual_mfa_device(self): + path = self._get_param('Path') + virtual_mfa_device_name = self._get_param('VirtualMFADeviceName') + + virtual_mfa_device = iam_backend.create_virtual_mfa_device(virtual_mfa_device_name, path) + + template = self.response_template(CREATE_VIRTUAL_MFA_DEVICE_TEMPLATE) + return template.render(device=virtual_mfa_device) + def delete_user(self): user_name = self._get_param('UserName') iam_backend.delete_user(user_name) @@ -1600,6 +1609,7 @@ CREDENTIAL_REPORT_GENERATING = """ """ + CREDENTIAL_REPORT_GENERATED = """ COMPLETE @@ -1609,6 +1619,7 @@ CREDENTIAL_REPORT_GENERATED = """ """ + CREDENTIAL_REPORT = """ {{ report }} @@ -1620,6 +1631,7 @@ CREDENTIAL_REPORT = """ """ + LIST_INSTANCE_PROFILES_FOR_ROLE_TEMPLATE = """ false @@ -1652,6 +1664,7 @@ LIST_INSTANCE_PROFILES_FOR_ROLE_TEMPLATE = """ """ + LIST_MFA_DEVICES_TEMPLATE = """ @@ -1670,6 +1683,20 @@ LIST_MFA_DEVICES_TEMPLATE = """ """ +CREATE_VIRTUAL_MFA_DEVICE_TEMPLATE = """ + + + {{ device.serial_number }} + {{ device.base32_string_seed }} + {{ device.qr_code_png }} + + + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + +""" + + LIST_ACCOUNT_ALIASES_TEMPLATE = """ false diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py index 2374fb599..2a1e65d29 100644 --- a/tests/test_iam/test_iam.py +++ b/tests/test_iam/test_iam.py @@ -723,6 +723,83 @@ def test_mfa_devices(): len(response['MFADevices']).should.equal(0) +@mock_iam +def test_create_virtual_mfa_device(): + client = boto3.client('iam', region_name='us-east-1') + response = client.create_virtual_mfa_device( + VirtualMFADeviceName='test-device' + ) + device = response['VirtualMFADevice'] + + device['SerialNumber'].should.equal('arn:aws:iam::123456789012:mfa/test-device') + device['Base32StringSeed'].decode('ascii').should.match('[A-Z234567]') + device['QRCodePNG'].should_not.be.empty + + response = client.create_virtual_mfa_device( + Path='/', + VirtualMFADeviceName='test-device-2' + ) + device = response['VirtualMFADevice'] + + device['SerialNumber'].should.equal('arn:aws:iam::123456789012:mfa/test-device-2') + device['Base32StringSeed'].decode('ascii').should.match('[A-Z234567]') + device['QRCodePNG'].should_not.be.empty + + response = client.create_virtual_mfa_device( + Path='/test/', + VirtualMFADeviceName='test-device' + ) + device = response['VirtualMFADevice'] + + device['SerialNumber'].should.equal('arn:aws:iam::123456789012:mfa/test/test-device') + device['Base32StringSeed'].decode('ascii').should.match('[A-Z234567]') + device['QRCodePNG'].should_not.be.empty + + +@mock_iam +def test_create_virtual_mfa_device_errors(): + client = boto3.client('iam', region_name='us-east-1') + client.create_virtual_mfa_device( + VirtualMFADeviceName='test-device' + ) + + client.create_virtual_mfa_device.when.called_with( + VirtualMFADeviceName='test-device' + ).should.throw( + ClientError, + 'MFADevice entity at the same path and name already exists.' + ) + + client.create_virtual_mfa_device.when.called_with( + Path='test', + VirtualMFADeviceName='test-device' + ).should.throw( + ClientError, + 'The specified value for path is invalid. ' + 'It must begin and end with / and contain only alphanumeric characters and/or / characters.' + ) + + client.create_virtual_mfa_device.when.called_with( + Path='/test//test/', + VirtualMFADeviceName='test-device' + ).should.throw( + ClientError, + 'The specified value for path is invalid. ' + 'It must begin and end with / and contain only alphanumeric characters and/or / characters.' + ) + + too_long_path = '/{}/'.format('b' * 511) + client.create_virtual_mfa_device.when.called_with( + Path=too_long_path, + VirtualMFADeviceName='test-device' + ).should.throw( + ClientError, + '1 validation error detected: ' + 'Value "{}" at "path" failed to satisfy constraint: ' + 'Member must have length less than or equal to 512' + ) + + @mock_iam_deprecated() def test_delete_user_deprecated(): conn = boto.connect_iam() From c80135a6db2eca1cc55b3fdf3020af0d499fad43 Mon Sep 17 00:00:00 2001 From: gruebel Date: Sun, 20 Oct 2019 23:03:20 +0200 Subject: [PATCH 2/4] Add iam.delete_virtual_mfa_device --- IMPLEMENTATION_COVERAGE.md | 4 ++-- moto/iam/models.py | 6 ++++++ moto/iam/responses.py | 15 +++++++++++++++ tests/test_iam/test_iam.py | 26 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index f4b3cd3c0..e1ed8b2cb 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3163,7 +3163,7 @@ - [ ] describe_events ## iam -59% implemented +60% implemented - [ ] add_client_id_to_open_id_connect_provider - [X] add_role_to_instance_profile - [X] add_user_to_group @@ -3208,7 +3208,7 @@ - [X] delete_user - [ ] delete_user_permissions_boundary - [X] delete_user_policy -- [ ] delete_virtual_mfa_device +- [X] delete_virtual_mfa_device - [X] detach_group_policy - [X] detach_role_policy - [X] detach_user_policy diff --git a/moto/iam/models.py b/moto/iam/models.py index 00aff40e5..5ac1ea3b5 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -1295,6 +1295,12 @@ class IAMBackend(BaseBackend): self.virtual_mfa_devices[device.serial_number] = device return device + def delete_virtual_mfa_device(self, serial_number): + device = self.virtual_mfa_devices.pop(serial_number, None) + + if not device: + raise IAMNotFoundException('VirtualMFADevice with serial number {0} doesn\'t exist.'.format(serial_number)) + def delete_user(self, user_name): try: del self.users[user_name] diff --git a/moto/iam/responses.py b/moto/iam/responses.py index c2df50137..8afe85e49 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -607,6 +607,14 @@ class IamResponse(BaseResponse): template = self.response_template(CREATE_VIRTUAL_MFA_DEVICE_TEMPLATE) return template.render(device=virtual_mfa_device) + def delete_virtual_mfa_device(self): + serial_number = self._get_param('SerialNumber') + + iam_backend.delete_virtual_mfa_device(serial_number) + + template = self.response_template(DELETE_VIRTUAL_MFA_DEVICE_TEMPLATE) + return template.render() + def delete_user(self): user_name = self._get_param('UserName') iam_backend.delete_user(user_name) @@ -1697,6 +1705,13 @@ CREATE_VIRTUAL_MFA_DEVICE_TEMPLATE = """ + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + +""" + + LIST_ACCOUNT_ALIASES_TEMPLATE = """ false diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py index 2a1e65d29..d765e4b87 100644 --- a/tests/test_iam/test_iam.py +++ b/tests/test_iam/test_iam.py @@ -800,6 +800,32 @@ def test_create_virtual_mfa_device_errors(): ) +@mock_iam +def test_delete_virtual_mfa_device(): + client = boto3.client('iam', region_name='us-east-1') + response = client.create_virtual_mfa_device( + VirtualMFADeviceName='test-device' + ) + serial_number = response['VirtualMFADevice']['SerialNumber'] + + client.delete_virtual_mfa_device( + SerialNumber=serial_number + ) + + +@mock_iam +def test_delete_virtual_mfa_device_errors(): + client = boto3.client('iam', region_name='us-east-1') + + serial_number = 'arn:aws:iam::123456789012:mfa/not-existing' + client.delete_virtual_mfa_device.when.called_with( + SerialNumber=serial_number + ).should.throw( + ClientError, + 'VirtualMFADevice with serial number {0} doesn\'t exist.'.format(serial_number) + ) + + @mock_iam_deprecated() def test_delete_user_deprecated(): conn = boto.connect_iam() From 1d9382b5e5b177beacd4a143eed5bb37a0d4f15d Mon Sep 17 00:00:00 2001 From: gruebel Date: Mon, 21 Oct 2019 21:48:50 +0200 Subject: [PATCH 3/4] Add iam.list_virtual_mfa_devices --- IMPLEMENTATION_COVERAGE.md | 4 +- moto/iam/models.py | 26 +++++++++++ moto/iam/responses.py | 44 ++++++++++++++++++ tests/test_iam/test_iam.py | 93 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index e1ed8b2cb..d965e489a 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3163,7 +3163,7 @@ - [ ] describe_events ## iam -60% implemented +61% implemented - [ ] add_client_id_to_open_id_connect_provider - [X] add_role_to_instance_profile - [X] add_user_to_group @@ -3268,7 +3268,7 @@ - [X] list_user_policies - [ ] list_user_tags - [X] list_users -- [ ] list_virtual_mfa_devices +- [X] list_virtual_mfa_devices - [X] put_group_policy - [ ] put_role_permissions_boundary - [X] put_role_policy diff --git a/moto/iam/models.py b/moto/iam/models.py index 5ac1ea3b5..8921244cd 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -53,6 +53,7 @@ class VirtualMfaDevice(object): self.enable_date = None self.user_attribute = None + self.user = None @property def enabled_iso_8601(self): @@ -1301,6 +1302,31 @@ class IAMBackend(BaseBackend): if not device: raise IAMNotFoundException('VirtualMFADevice with serial number {0} doesn\'t exist.'.format(serial_number)) + def list_virtual_mfa_devices(self, assignment_status, marker, max_items): + devices = list(self.virtual_mfa_devices.values()) + + if assignment_status == 'Assigned': + devices = [device for device in devices if device.enable_date] + + if assignment_status == 'Unassigned': + devices = [device for device in devices if not device.enable_date] + + sorted(devices, key=lambda device: device.serial_number) + max_items = int(max_items) + start_idx = int(marker) if marker else 0 + + if start_idx > len(devices): + raise ValidationError('Invalid Marker.') + + devices = devices[start_idx:start_idx + max_items] + + if len(devices) < max_items: + marker = None + else: + marker = str(start_idx + max_items) + + return devices, marker + def delete_user(self, user_name): try: del self.users[user_name] diff --git a/moto/iam/responses.py b/moto/iam/responses.py index 8afe85e49..8814a25a1 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -615,6 +615,16 @@ class IamResponse(BaseResponse): template = self.response_template(DELETE_VIRTUAL_MFA_DEVICE_TEMPLATE) return template.render() + def list_virtual_mfa_devices(self): + assignment_status = self._get_param('AssignmentStatus', 'Any') + marker = self._get_param('Marker') + max_items = self._get_param('MaxItems', 100) + + devices, marker = iam_backend.list_virtual_mfa_devices(assignment_status, marker, max_items) + + template = self.response_template(LIST_VIRTUAL_MFA_DEVICES_TEMPLATE) + return template.render(devices=devices, marker=marker) + def delete_user(self): user_name = self._get_param('UserName') iam_backend.delete_user(user_name) @@ -1712,6 +1722,40 @@ DELETE_VIRTUAL_MFA_DEVICE_TEMPLATE = """ + + {% if marker is none %} + false + {% else %} + true + {{ marker }} + {% endif %} + + {% for device in devices %} + + {{ device.serial_number }} + {% if device.enable_date %} + {{ device.enabled_iso_8601 }} + {% endif %} + {% if device.user %} + + {{ user.path }} + {{ user.name }} + {{ user.id }} + {{ user.created_iso_8601 }} + {{ user.arn }} + + {% endif %} + + {% endfor %} + + + + b61ce1b1-0401-11e1-b2f8-2dEXAMPLEbfc + +""" + + LIST_ACCOUNT_ALIASES_TEMPLATE = """ false diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py index d765e4b87..ff02646c4 100644 --- a/tests/test_iam/test_iam.py +++ b/tests/test_iam/test_iam.py @@ -812,6 +812,11 @@ def test_delete_virtual_mfa_device(): SerialNumber=serial_number ) + response = client.list_virtual_mfa_devices() + + response['VirtualMFADevices'].should.have.length_of(0) + response['IsTruncated'].should_not.be.ok + @mock_iam def test_delete_virtual_mfa_device_errors(): @@ -826,6 +831,94 @@ def test_delete_virtual_mfa_device_errors(): ) +@mock_iam +def test_list_virtual_mfa_devices(): + client = boto3.client('iam', region_name='us-east-1') + response = client.create_virtual_mfa_device( + VirtualMFADeviceName='test-device' + ) + serial_number_1 = response['VirtualMFADevice']['SerialNumber'] + + response = client.create_virtual_mfa_device( + Path='/test/', + VirtualMFADeviceName='test-device' + ) + serial_number_2 = response['VirtualMFADevice']['SerialNumber'] + + response = client.list_virtual_mfa_devices() + + response['VirtualMFADevices'].should.equal([ + { + 'SerialNumber': serial_number_1 + }, + { + 'SerialNumber': serial_number_2 + } + ]) + response['IsTruncated'].should_not.be.ok + + response = client.list_virtual_mfa_devices( + AssignmentStatus='Assigned' + ) + + response['VirtualMFADevices'].should.have.length_of(0) + response['IsTruncated'].should_not.be.ok + + response = client.list_virtual_mfa_devices( + AssignmentStatus='Unassigned' + ) + + response['VirtualMFADevices'].should.equal([ + { + 'SerialNumber': serial_number_1 + }, + { + 'SerialNumber': serial_number_2 + } + ]) + response['IsTruncated'].should_not.be.ok + + response = client.list_virtual_mfa_devices( + AssignmentStatus='Any', + MaxItems=1 + ) + + response['VirtualMFADevices'].should.equal([ + { + 'SerialNumber': serial_number_1 + } + ]) + response['IsTruncated'].should.be.ok + response['Marker'].should.equal('1') + + response = client.list_virtual_mfa_devices( + AssignmentStatus='Any', + Marker=response['Marker'] + ) + + response['VirtualMFADevices'].should.equal([ + { + 'SerialNumber': serial_number_2 + } + ]) + response['IsTruncated'].should_not.be.ok + + +@mock_iam +def test_list_virtual_mfa_devices_errors(): + client = boto3.client('iam', region_name='us-east-1') + client.create_virtual_mfa_device( + VirtualMFADeviceName='test-device' + ) + + client.list_virtual_mfa_devices.when.called_with( + Marker='100' + ).should.throw( + ClientError, + 'Invalid Marker.' + ) + + @mock_iam_deprecated() def test_delete_user_deprecated(): conn = boto.connect_iam() From 802fb3baad8aed1c0f9dcfb040470947dfceb225 Mon Sep 17 00:00:00 2001 From: gruebel Date: Mon, 21 Oct 2019 22:51:00 +0200 Subject: [PATCH 4/4] Connect user with virtual mfa device --- moto/iam/models.py | 21 +++++++++++++ moto/iam/responses.py | 10 +++---- tests/test_iam/test_iam.py | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/moto/iam/models.py b/moto/iam/models.py index 8921244cd..d7e8f8657 100644 --- a/moto/iam/models.py +++ b/moto/iam/models.py @@ -1251,6 +1251,21 @@ class IAMBackend(BaseBackend): "Device {0} already exists".format(serial_number) ) + device = self.virtual_mfa_devices.get(serial_number, None) + if device: + device.enable_date = datetime.utcnow() + device.user = user + device.user_attribute = { + 'Path': user.path, + 'UserName': user.name, + 'UserId': user.id, + 'Arn': user.arn, + 'CreateDate': user.created_iso_8601, + 'PasswordLastUsed': None, # not supported + 'PermissionsBoundary': {}, # ToDo: add put_user_permissions_boundary() functionality + 'Tags': {} # ToDo: add tag_user() functionality + } + user.enable_mfa_device( serial_number, authentication_code_1, @@ -1265,6 +1280,12 @@ class IAMBackend(BaseBackend): "Device {0} not found".format(serial_number) ) + device = self.virtual_mfa_devices.get(serial_number, None) + if device: + device.enable_date = None + device.user = None + device.user_attribute = None + user.deactivate_mfa_device(serial_number) def list_mfa_devices(self, user_name): diff --git a/moto/iam/responses.py b/moto/iam/responses.py index 8814a25a1..01cbeb712 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -1739,11 +1739,11 @@ LIST_VIRTUAL_MFA_DEVICES_TEMPLATE = """