diff --git a/moto/core/authentication.py b/moto/core/authentication.py index 048cd9a61..e8eb50626 100644 --- a/moto/core/authentication.py +++ b/moto/core/authentication.py @@ -1,8 +1,9 @@ import json import re +from abc import ABC, abstractmethod from enum import Enum -from botocore.auth import SigV4Auth +from botocore.auth import SigV4Auth, S3SigV4Auth from botocore.awsrequest import AWSRequest from botocore.credentials import Credentials from moto.iam.models import ACCOUNT_ID, Policy @@ -10,6 +11,7 @@ from moto.iam.models import ACCOUNT_ID, Policy from moto.iam import iam_backend from moto.core.exceptions import SignatureDoesNotMatchError, AccessDeniedError, InvalidClientTokenIdError +from moto.s3.exceptions import BucketAccessDeniedError, S3AccessDeniedError ACCESS_KEY_STORE = { "AKIAJDULPKHCC4KGTYVA": { @@ -19,7 +21,7 @@ ACCESS_KEY_STORE = { } -class IAMRequest: +class IAMRequestBase(ABC): def __init__(self, method, path, data, headers): print(f"Creating {IAMRequest.__name__} with method={method}, path={path}, data={data}, headers={headers}") @@ -56,12 +58,9 @@ class IAMRequest: if not permitted: self._raise_access_denied(iam_user_name) + @abstractmethod def _raise_access_denied(self, iam_user_name): - raise AccessDeniedError( - account_id=ACCOUNT_ID, - iam_user_name=iam_user_name, - action=self._action - ) + pass @staticmethod def _collect_policies_for_iam_user(iam_user_name): @@ -87,13 +86,9 @@ class IAMRequest: 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) + @abstractmethod + def _create_auth(self, credentials): + pass @staticmethod def _create_headers_for_aws_request(signed_headers, original_headers): @@ -112,7 +107,12 @@ class IAMRequest: return request def _calculate_signature(self): - auth = self._create_auth() + 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) + auth = self._create_auth(credentials) request = self._create_aws_request() canonical_request = auth.canonical_request(request) string_to_sign = auth.string_to_sign(request, canonical_request) @@ -123,6 +123,31 @@ class IAMRequest: return string.partition(first_separator)[2].partition(second_separator)[0] +class IAMRequest(IAMRequestBase): + + def _create_auth(self, credentials): + return SigV4Auth(credentials, self._service, self._region) + + def _raise_access_denied(self, iam_user_name): + raise AccessDeniedError( + account_id=ACCOUNT_ID, + iam_user_name=iam_user_name, + action=self._action + ) + + +class S3IAMRequest(IAMRequestBase): + + def _create_auth(self, credentials): + return S3SigV4Auth(credentials, self._service, self._region) + + def _raise_access_denied(self, _): + if "BucketName" in self._data: + raise BucketAccessDeniedError(bucket=self._data["BucketName"]) + else: + raise S3AccessDeniedError() + + class IAMPolicy: def __init__(self, policy): diff --git a/moto/core/responses.py b/moto/core/responses.py index 1cc1511e7..5c59a26e7 100644 --- a/moto/core/responses.py +++ b/moto/core/responses.py @@ -10,7 +10,7 @@ import io import pytz -from moto.core.authentication import IAMRequest +from moto.core.authentication import IAMRequest, S3IAMRequest from moto.core.exceptions import DryRunClientError from jinja2 import Environment, DictLoader, TemplateNotFound @@ -111,8 +111,7 @@ class ActionAuthenticatorMixin(object): INITIAL_NO_AUTH_ACTION_COUNT = int(os.environ.get("INITIAL_NO_AUTH_ACTION_COUNT", 999999999)) request_count = 0 - def _authenticate_action(self): - iam_request = IAMRequest(method=self.method, path=self.path, data=self.querystring, headers=self.headers) + def _authenticate_action(self, iam_request): iam_request.check_signature() if ActionAuthenticatorMixin.request_count >= ActionAuthenticatorMixin.INITIAL_NO_AUTH_ACTION_COUNT: @@ -120,6 +119,14 @@ class ActionAuthenticatorMixin(object): else: ActionAuthenticatorMixin.request_count += 1 + def _authenticate_normal_action(self): + iam_request = IAMRequest(method=self.method, path=self.path, data=self.data, headers=self.headers) + self._authenticate_action(iam_request) + + def _authenticate_s3_action(self): + iam_request = S3IAMRequest(method=self.method, path=self.path, data=self.data, headers=self.headers) + self._authenticate_action(iam_request) + class BaseResponse(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): @@ -185,6 +192,7 @@ class BaseResponse(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): self.uri = full_url self.path = urlparse(full_url).path self.querystring = querystring + self.data = querystring self.method = request.method self.region = self.get_region_from_url(request, full_url) self.uri_match = None @@ -293,7 +301,7 @@ class BaseResponse(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): headers = self.response_headers try: - self._authenticate_action() + self._authenticate_normal_action() except HTTPException as http_error: response = http_error.description, dict(status=http_error.code) return self._send_response(headers, response) diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py index 27c842111..6f4c9c996 100644 --- a/moto/s3/exceptions.py +++ b/moto/s3/exceptions.py @@ -199,3 +199,17 @@ class DuplicateTagKeys(S3ClientError): "InvalidTag", "Cannot provide multiple Tags with the same key", *args, **kwargs) + + +class S3AccessDeniedError(S3ClientError): + code = 403 + + def __init__(self, *args, **kwargs): + super(S3AccessDeniedError, self).__init__('AccessDenied', 'Access Denied', *args, **kwargs) + + +class BucketAccessDeniedError(BucketError): + code = 403 + + def __init__(self, *args, **kwargs): + super(BucketAccessDeniedError, self).__init__('AccessDenied', 'Access Denied', *args, **kwargs) diff --git a/moto/s3/responses.py b/moto/s3/responses.py index e03666666..245f6db4e 100644 --- a/moto/s3/responses.py +++ b/moto/s3/responses.py @@ -3,13 +3,15 @@ from __future__ import unicode_literals import re import six +from werkzeug.exceptions import HTTPException + from moto.core.utils import str_to_rfc_1123_datetime from six.moves.urllib.parse import parse_qs, urlparse, unquote import xmltodict from moto.packages.httpretty.core import HTTPrettyRequest -from moto.core.responses import _TemplateEnvironmentMixin +from moto.core.responses import _TemplateEnvironmentMixin, ActionAuthenticatorMixin from moto.core.utils import path_url from moto.s3bucket_path.utils import bucket_name_from_url as bucketpath_bucket_name_from_url, \ @@ -25,6 +27,72 @@ from xml.dom import minidom DEFAULT_REGION_NAME = 'us-east-1' +ACTION_MAP = { + "BUCKET": { + "GET": { + "uploads": "ListBucketMultipartUploads", + "location": "GetBucketLocation", + "lifecycle": "GetLifecycleConfiguration", + "versioning": "GetBucketVersioning", + "policy": "GetBucketPolicy", + "website": "GetBucketWebsite", + "acl": "GetBucketAcl", + "tagging": "GetBucketTagging", + "logging": "GetBucketLogging", + "cors": "GetBucketCORS", + "notification": "GetBucketNotification", + "accelerate": "GetAccelerateConfiguration", + "versions": "ListBucketVersions", + "DEFAULT": "ListBucket" + }, + "PUT": { + "lifecycle": "PutLifecycleConfiguration", + "versioning": "PutBucketVersioning", + "policy": "PutBucketPolicy", + "website": "PutBucketWebsite", + "acl": "PutBucketAcl", + "tagging": "PutBucketTagging", + "logging": "PutBucketLogging", + "cors": "PutBucketCORS", + "notification": "PutBucketNotification", + "accelerate": "PutAccelerateConfiguration", + "DEFAULT": "CreateBucket" + }, + "DELETE": { + "lifecycle": "PutLifecycleConfiguration", + "policy": "DeleteBucketPolicy", + "tagging": "PutBucketTagging", + "cors": "PutBucketCORS", + "DEFAULT": "DeleteBucket" + } + }, + "KEY": { + "GET": { + "uploadId": "ListMultipartUploadParts", + "acl": "GetObjectAcl", + "tagging": "GetObjectTagging", + "versionId": "GetObjectVersion", + "DEFAULT": "GetObject" + }, + "PUT": { + "acl": "PutObjectAcl", + "tagging": "PutObjectTagging", + "DEFAULT": "PutObject" + }, + "DELETE": { + "uploadId": "AbortMultipartUpload", + "versionId": "DeleteObjectVersion", + "DEFAULT": " DeleteObject" + }, + "POST": { + "uploads": "PutObject", + "restore": "RestoreObject", + "uploadId": "PutObject" + } + } + +} + def parse_key_name(pth): return pth.lstrip("/") @@ -37,17 +105,27 @@ def is_delete_keys(request, path, bucket_name): ) -class ResponseObject(_TemplateEnvironmentMixin): +class ResponseObject(_TemplateEnvironmentMixin, ActionAuthenticatorMixin): def __init__(self, backend): super(ResponseObject, self).__init__() self.backend = backend + self.method = "" + self.path = "" + self.data = {} + self.headers = {} @property def should_autoescape(self): return True - def all_buckets(self): + def all_buckets(self, headers): + try: + self.data["Action"] = "ListAllMyBuckets" + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) # No bucket specified. Listing all buckets all_buckets = self.backend.get_all_buckets() template = self.response_template(S3_ALL_BUCKETS) @@ -112,11 +190,18 @@ class ResponseObject(_TemplateEnvironmentMixin): return self.bucket_response(request, full_url, headers) def bucket_response(self, request, full_url, headers): + self.method = request.method + self.path = self._get_path(request) + self.headers = request.headers try: response = self._bucket_response(request, full_url, headers) except S3ClientError as s3error: response = s3error.code, {}, s3error.description + return self._send_response(response) + + @staticmethod + def _send_response(response): if isinstance(response, six.string_types): return 200, {}, response.encode("utf-8") else: @@ -124,18 +209,21 @@ class ResponseObject(_TemplateEnvironmentMixin): if not isinstance(response_content, six.binary_type): response_content = response_content.encode("utf-8") + print(f"response_content: {response_content}") + return status_code, headers, response_content def _bucket_response(self, request, full_url, headers): - parsed_url = urlparse(full_url) - querystring = parse_qs(parsed_url.query, keep_blank_values=True) + querystring = self._get_querystring(full_url) method = request.method region_name = parse_region_from_url(full_url) bucket_name = self.parse_bucket_name_from_url(request, full_url) if not bucket_name: # If no bucket specified, list all buckets - return self.all_buckets() + return self.all_buckets(headers) + + self.data["BucketName"] = bucket_name if hasattr(request, 'body'): # Boto @@ -163,6 +251,12 @@ class ResponseObject(_TemplateEnvironmentMixin): raise NotImplementedError( "Method {0} has not been impelemented in the S3 backend yet".format(method)) + @staticmethod + def _get_querystring(full_url): + parsed_url = urlparse(full_url) + querystring = parse_qs(parsed_url.query, keep_blank_values=True) + return querystring + def _bucket_response_head(self, bucket_name, headers): try: self.backend.get_bucket(bucket_name) @@ -175,6 +269,14 @@ class ResponseObject(_TemplateEnvironmentMixin): return 200, {}, "" def _bucket_response_get(self, bucket_name, querystring, headers): + self._set_action("BUCKET", "GET", querystring) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + if 'uploads' in querystring: for unsup in ('delimiter', 'max-uploads'): if unsup in querystring: @@ -333,6 +435,15 @@ class ResponseObject(_TemplateEnvironmentMixin): max_keys=max_keys ) + def _set_action(self, action_resource_type, method, querystring): + action_set = False + for action_in_querystring, action in ACTION_MAP[action_resource_type][method].items(): + if action_in_querystring in querystring: + self.data["Action"] = action + action_set = True + if not action_set: + self.data["Action"] = ACTION_MAP[action_resource_type][method]["DEFAULT"] + def _handle_list_objects_v2(self, bucket_name, querystring): template = self.response_template(S3_BUCKET_GET_RESPONSE_V2) bucket = self.backend.get_bucket(bucket_name) @@ -396,6 +507,15 @@ class ResponseObject(_TemplateEnvironmentMixin): def _bucket_response_put(self, request, body, region_name, bucket_name, querystring, headers): if not request.headers.get('Content-Length'): return 411, {}, "Content-Length required" + + self._set_action("BUCKET", "PUT", querystring) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + if 'versioning' in querystring: ver = re.search('([A-Za-z]+)', body.decode()) if ver: @@ -495,6 +615,14 @@ class ResponseObject(_TemplateEnvironmentMixin): return 200, {}, template.render(bucket=new_bucket) def _bucket_response_delete(self, body, bucket_name, querystring, headers): + self._set_action("BUCKET", "DELETE", querystring) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + if 'policy' in querystring: self.backend.delete_bucket_policy(bucket_name, body) return 204, {}, "" @@ -525,14 +653,27 @@ class ResponseObject(_TemplateEnvironmentMixin): if not request.headers.get('Content-Length'): return 411, {}, "Content-Length required" - if isinstance(request, HTTPrettyRequest): - path = request.path - else: - path = request.full_path if hasattr(request, 'full_path') else path_url(request.url) + path = self._get_path(request) if self.is_delete_keys(request, path, bucket_name): + self.data["Action"] = "DeleteObject" + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + return self._bucket_response_delete_keys(request, body, bucket_name, headers) + self.data["Action"] = "PutObject" + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + # POST to bucket-url should create file from form if hasattr(request, 'form'): # Not HTTPretty @@ -560,6 +701,14 @@ class ResponseObject(_TemplateEnvironmentMixin): return 200, {}, "" + @staticmethod + def _get_path(request): + if isinstance(request, HTTPrettyRequest): + path = request.path + else: + path = request.full_path if hasattr(request, 'full_path') else path_url(request.url) + return path + def _bucket_response_delete_keys(self, request, body, bucket_name, headers): template = self.response_template(S3_DELETE_KEYS_RESPONSE) @@ -604,6 +753,9 @@ class ResponseObject(_TemplateEnvironmentMixin): return 206, response_headers, response_content[begin:end + 1] def key_response(self, request, full_url, headers): + self.method = request.method + self.path = self._get_path(request) + self.headers = request.headers response_headers = {} try: response = self._key_response(request, full_url, headers) @@ -671,6 +823,14 @@ class ResponseObject(_TemplateEnvironmentMixin): "Method {0} has not been implemented in the S3 backend yet".format(method)) def _key_response_get(self, bucket_name, query, key_name, headers): + self._set_action("KEY", "GET", query) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + response_headers = {} if query.get('uploadId'): upload_id = query['uploadId'][0] @@ -700,6 +860,14 @@ class ResponseObject(_TemplateEnvironmentMixin): return 200, response_headers, key.value def _key_response_put(self, request, body, bucket_name, query, key_name, headers): + self._set_action("KEY", "PUT", query) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + response_headers = {} if query.get('uploadId') and query.get('partNumber'): upload_id = query['uploadId'][0] @@ -1067,6 +1235,14 @@ class ResponseObject(_TemplateEnvironmentMixin): return config['Status'] def _key_response_delete(self, bucket_name, query, key_name, headers): + self._set_action("KEY", "DELETE", query) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + if query.get('uploadId'): upload_id = query['uploadId'][0] self.backend.cancel_multipart(bucket_name, upload_id) @@ -1087,6 +1263,14 @@ class ResponseObject(_TemplateEnvironmentMixin): yield (pn, p.getElementsByTagName('ETag')[0].firstChild.wholeText) def _key_response_post(self, request, body, bucket_name, query, key_name, headers): + self._set_action("KEY", "POST", query) + + try: + self._authenticate_s3_action() + except HTTPException as http_error: + response = http_error.code, headers, http_error.description + return self._send_response(response) + if body == b'' and 'uploads' in query: metadata = metadata_from_headers(request.headers) multipart = self.backend.initiate_multipart( diff --git a/moto/s3/urls.py b/moto/s3/urls.py index 1d439a549..fa81568a4 100644 --- a/moto/s3/urls.py +++ b/moto/s3/urls.py @@ -7,15 +7,6 @@ url_bases = [ r"https?://(?P[a-zA-Z0-9\-_.]*)\.?s3(.*).amazonaws.com" ] - -def ambiguous_response1(*args, **kwargs): - return S3ResponseInstance.ambiguous_response(*args, **kwargs) - - -def ambiguous_response2(*args, **kwargs): - return S3ResponseInstance.ambiguous_response(*args, **kwargs) - - url_paths = { # subdomain bucket '{0}/$': S3ResponseInstance.bucket_response,