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()