diff --git a/moto/core/authentication.py b/moto/core/authentication.py new file mode 100644 index 000000000..85b553d44 --- /dev/null +++ b/moto/core/authentication.py @@ -0,0 +1,189 @@ +import json +from enum import Enum + +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials +from moto.iam.models import ACCOUNT_ID, Policy + +from moto.iam import iam_backend + +from moto.core.exceptions import SignatureDoesNotMatchError, AccessDeniedError, InvalidClientTokenIdError + +ACCESS_KEY_STORE = { + "AKIAJDULPKHCC4KGTYVA": { + "owner": "avatao-user", + "secret_access_key": "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" + } +} + + +class IAMRequest: + + def __init__(self, method, path, data, headers): + print(f"Creating {IAMRequest.__name__} with method={method}, path={path}, data={data}, headers={headers}") + self._method = method + self._path = path + self._data = data + self._headers = headers + credential_scope = self._get_string_between('Credential=', ',', self._headers['Authorization']) + credential_data = credential_scope.split('/') + self._access_key = credential_data[0] + self._region = credential_data[2] + self._service = credential_data[3] + self._action = self._service + ":" + self._data["Action"][0] + + def check_signature(self): + original_signature = self._get_string_between('Signature=', ',', self._headers['Authorization']) + calculated_signature = self._calculate_signature() + if original_signature != calculated_signature: + raise SignatureDoesNotMatchError() + + def check_action_permitted(self): + iam_user_name = ACCESS_KEY_STORE[self._access_key]["owner"] + user_policies = self._collect_policies_for_iam_user(iam_user_name) + + permitted = False + for policy in user_policies: + iam_policy = IAMPolicy(policy) + permission_result = iam_policy.is_action_permitted(self._action) + if permission_result == PermissionResult.DENIED: + self._raise_access_denied(iam_user_name) + elif permission_result == PermissionResult.PERMITTED: + permitted = True + + if not permitted: + self._raise_access_denied(iam_user_name) + + def _raise_access_denied(self, iam_user_name): + raise AccessDeniedError( + account_id=ACCOUNT_ID, + iam_user_name=iam_user_name, + action=self._action + ) + + @staticmethod + def _collect_policies_for_iam_user(iam_user_name): + user_policies = [] + + inline_policy_names = iam_backend.list_user_policies(iam_user_name) + for inline_policy_name in inline_policy_names: + inline_policy = iam_backend.get_user_policy(iam_user_name, inline_policy_name) + user_policies.append(inline_policy) + + attached_policies, _ = iam_backend.list_attached_user_policies(iam_user_name) + user_policies += attached_policies + + user_groups = iam_backend.get_groups_for_user(iam_user_name) + for user_group in user_groups: + inline_group_policy_names = iam_backend.list_group_policies(user_group) + for inline_group_policy_name in inline_group_policy_names: + inline_user_group_policy = iam_backend.get_group_policy(user_group.name, inline_group_policy_name) + user_policies.append(inline_user_group_policy) + + attached_group_policies = iam_backend.list_attached_group_policies(user_group.name) + user_policies += attached_group_policies + + return user_policies + + def _create_auth(self): + if self._access_key not in ACCESS_KEY_STORE: + raise InvalidClientTokenIdError() + secret_key = ACCESS_KEY_STORE[self._access_key]["secret_access_key"] + + credentials = Credentials(self._access_key, secret_key) + return SigV4Auth(credentials, self._service, self._region) + + @staticmethod + def _create_headers_for_aws_request(signed_headers, original_headers): + headers = {} + for key, value in original_headers.items(): + if key.lower() in signed_headers: + headers[key] = value + return headers + + def _create_aws_request(self): + signed_headers = self._get_string_between('SignedHeaders=', ',', self._headers['Authorization']).split(';') + headers = self._create_headers_for_aws_request(signed_headers, self._headers) + request = AWSRequest(method=self._method, url=self._path, data=self._data, headers=headers) + request.context['timestamp'] = headers['X-Amz-Date'] + + return request + + def _calculate_signature(self): + auth = self._create_auth() + request = self._create_aws_request() + canonical_request = auth.canonical_request(request) + string_to_sign = auth.string_to_sign(request, canonical_request) + return auth.signature(string_to_sign, request) + + @staticmethod + def _get_string_between(first_separator, second_separator, string): + return string.partition(first_separator)[2].partition(second_separator)[0] + + +class IAMPolicy: + + def __init__(self, policy): + self._policy = policy + + def is_action_permitted(self, action): + if isinstance(self._policy, Policy): + default_version = next(policy_version for policy_version in self._policy.versions if policy_version.is_default) + policy_document = default_version.document + else: + policy_document = self._policy["policy_document"] + + policy_json = json.loads(policy_document) + + permitted = False + for policy_statement in policy_json["Statement"]: + iam_policy_statement = IAMPolicyStatement(policy_statement) + permission_result = iam_policy_statement.is_action_permitted(action) + if permission_result == PermissionResult.DENIED: + return permission_result + elif permission_result == PermissionResult.PERMITTED: + permitted = True + + if permitted: + return PermissionResult.PERMITTED + else: + return PermissionResult.NEUTRAL + + +class IAMPolicyStatement: + + def __init__(self, statement): + self._statement = statement + + def is_action_permitted(self, action): + is_action_concerned = False + + if "NotAction" in self._statement: + if not self._check_element_contains("NotAction", action): + is_action_concerned = True + else: # Action is present + if self._check_element_contains("Action", action): + is_action_concerned = True + + # TODO: check Resource/NotResource and Condition + + if is_action_concerned: + if self._statement["Effect"] == "Allow": + return PermissionResult.PERMITTED + else: # Deny + return PermissionResult.DENIED + else: + return PermissionResult.NEUTRAL + + def _check_element_contains(self, statement_element, value): + if isinstance(self._statement[statement_element], list): + return value in self._statement[statement_element] + else: # string + return value == self._statement[statement_element] + + +class PermissionResult(Enum): + PERMITTED = 1 + DENIED = 2 + NEUTRAL = 3 diff --git a/moto/core/exceptions.py b/moto/core/exceptions.py index 40202f7bd..ddcce91d5 100644 --- a/moto/core/exceptions.py +++ b/moto/core/exceptions.py @@ -65,3 +65,34 @@ class JsonRESTError(RESTError): def get_body(self, *args, **kwargs): return self.description + + +class SignatureDoesNotMatchError(RESTError): + code = 400 + + def __init__(self): + super(SignatureDoesNotMatchError, self).__init__( + 'SignatureDoesNotMatch', + "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.") + + +class InvalidClientTokenIdError(RESTError): + code = 400 + + def __init__(self): + super(InvalidClientTokenIdError, self).__init__( + 'InvalidClientTokenId', + "The security token included in the request is invalid.") + + +class AccessDeniedError(RESTError): + code = 403 + + def __init__(self, account_id, iam_user_name, action): + super(AccessDeniedError, self).__init__( + 'AccessDenied', + "User: arn:aws:iam::{account_id}:user/{iam_user_name} is not authorized to perform: {operation}".format( + account_id=account_id, + iam_user_name=iam_user_name, + operation=action + )) diff --git a/moto/core/responses.py b/moto/core/responses.py index 9da36b865..4a3b3a1b9 100644 --- a/moto/core/responses.py +++ b/moto/core/responses.py @@ -8,6 +8,8 @@ import re import io import pytz + +from moto.core.authentication import IAMRequest from moto.core.exceptions import DryRunClientError from jinja2 import Environment, DictLoader, TemplateNotFound @@ -103,7 +105,22 @@ class _TemplateEnvironmentMixin(object): return self.environment.get_template(template_id) -class BaseResponse(_TemplateEnvironmentMixin): +class ActionAuthenticatorMixin(object): + + INITIALIZATION_STEP_COUNT = 5 + request_count = 0 + + def _authenticate_action(self): + iam_request = IAMRequest(method=self.method, path=self.path, data=self.querystring, headers=self.headers) + iam_request.check_signature() + + if ActionAuthenticatorMixin.request_count >= ActionAuthenticatorMixin.INITIALIZATION_STEP_COUNT: + iam_request.check_action_permitted() + else: + ActionAuthenticatorMixin.request_count += 1 + + +class BaseResponse(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): default_region = 'us-east-1' # to extract region, use [^.] @@ -273,6 +290,13 @@ class BaseResponse(_TemplateEnvironmentMixin): def call_action(self): headers = self.response_headers + + try: + self._authenticate_action() + except HTTPException as http_error: + response = http_error.description, dict(status=http_error.code) + return self._send_response(headers, response) + action = camelcase_to_underscores(self._get_action()) method_names = method_names_from_class(self.__class__) if action in method_names: @@ -285,16 +309,7 @@ class BaseResponse(_TemplateEnvironmentMixin): if isinstance(response, six.string_types): return 200, headers, response else: - if len(response) == 2: - body, new_headers = response - else: - status, new_headers, body = response - status = new_headers.get('status', 200) - headers.update(new_headers) - # Cast status to string - if "status" in headers: - headers['status'] = str(headers['status']) - return status, headers, body + return self._send_response(headers, response) if not action: return 404, headers, '' @@ -302,6 +317,19 @@ class BaseResponse(_TemplateEnvironmentMixin): raise NotImplementedError( "The {0} action has not been implemented".format(action)) + @staticmethod + def _send_response(headers, response): + if len(response) == 2: + body, new_headers = response + else: + status, new_headers, body = response + status = new_headers.get('status', 200) + headers.update(new_headers) + # Cast status to string + if "status" in headers: + headers['status'] = str(headers['status']) + return status, headers, body + def _get_param(self, param_name, if_none=None): val = self.querystring.get(param_name) if val is not None: diff --git a/moto/iam/responses.py b/moto/iam/responses.py index a0bcb0b56..62d696ba6 100644 --- a/moto/iam/responses.py +++ b/moto/iam/responses.py @@ -1,7 +1,9 @@ from __future__ import unicode_literals from moto.core.responses import BaseResponse -from .models import iam_backend, User +from .models import iam_backend + +AVATAO_USER_NAME = "avatao-user" class IamResponse(BaseResponse): @@ -425,11 +427,10 @@ class IamResponse(BaseResponse): def get_user(self): user_name = self._get_param('UserName') - if user_name: - user = iam_backend.get_user(user_name) - else: - user = User(name='default_user') - # If no user is specific, IAM returns the current user + if not user_name: + user_name = AVATAO_USER_NAME + # If no user is specified, IAM returns the current user + user = iam_backend.get_user(user_name) template = self.response_template(USER_TEMPLATE) return template.render(action='Get', user=user) @@ -457,7 +458,6 @@ class IamResponse(BaseResponse): def create_login_profile(self): user_name = self._get_param('UserName') password = self._get_param('Password') - password = self._get_param('Password') user = iam_backend.create_login_profile(user_name, password) template = self.response_template(CREATE_LOGIN_PROFILE_TEMPLATE)