Re:implement AWS signature v4
New algorithm that supports s3v4 was added. What I did in this patch in detail: - Implements v4 related code into mix-in class to provide some methods for authentication algorithms (e.g. string_to_sign) - S3Timestamp everywhere. Old code take a lot of complicated timestamp translation from/to datetime, time, date header format (str). This patch gathers the translation into "timestamp" property method which should be actually handled in the validatation. - Run functional tests for both v2/v4 authentication in the same environment at the same time which shows evidence that we have complete backword compatibilities and we can adopt v4 w/o anything broken. *Bonus* - Fix some minger bugs for singed urls (almostly expired timestamp), for header/query mixture and for unit test case mistake. The reason I implemented this from Andrey's original patch is the signature v4 stuff is too complicated if we mixes the process/routine into same class because of a bunch of if/elif/else statements for header handling. (e.g. if 'X-Amz-Date' in req.headers) Note that it is not his issue, just AWS is getting complicated algorithms. However, for maintainansibility, we need more clear code to find easily which statement is supported on v2/v4 to prevent merge buggy code into master. That is why I tried to do this. Hopefully this code fits the original author's intention. NOTE for operators: - Signature V4 is supported only for keystone auth. - Set the same value of "region" configuration in keystone to "location" in swift3 conf file to enable SigV4. - Sigv2 and SigV4 can be used at the same cluster configuration. - This stuff has been supported since Keystone 9.0.0.0b1. (We probably need to bump the minimum version for keystone in requirements) Change-Id: I386abd4ead40f55855657e354fd8ef3fd0d13aa7 Co-Authored-By: Andrey Pavlov <andrey-mp@yandex.ru> Closes-Bug: #1411078
This commit is contained in:
parent
02d1e2c7cb
commit
a1cc181bd8
@ -37,8 +37,8 @@ use = egg:swift3#swift3
|
||||
# allow_no_owner = false
|
||||
#
|
||||
# Set a region name of your Swift cluster. Note that Swift3 doesn't choose a
|
||||
# region of the newly created bucket actually. This value is used only for the
|
||||
# GET Bucket location API.
|
||||
# region of the newly created bucket actually. This value is used for the
|
||||
# GET Bucket location API and v4 signatures calculation.
|
||||
# location = US
|
||||
#
|
||||
# Set whether to enforce DNS-compliant bucket names. Note that S3 enforces
|
||||
|
@ -50,6 +50,8 @@ from swift.common.swob import Range
|
||||
from swift.common.utils import json
|
||||
from swift.common.db import utf8encode
|
||||
|
||||
from six.moves.urllib.parse import urlparse # pylint: disable=F0401
|
||||
|
||||
from swift3.controllers.base import Controller, bucket_operation, \
|
||||
object_operation, check_container_existence
|
||||
from swift3.response import InvalidArgument, ErrorResponse, MalformedXML, \
|
||||
@ -604,7 +606,20 @@ class UploadController(Controller):
|
||||
req.get_response(self.app, 'DELETE', container, obj)
|
||||
|
||||
result_elem = Element('CompleteMultipartUploadResult')
|
||||
SubElement(result_elem, 'Location').text = req.host_url + req.path
|
||||
|
||||
# NOTE: boto with sig v4 appends port to HTTP_HOST value at the
|
||||
# request header when the port is non default value and it makes
|
||||
# req.host_url like as http://localhost:8080:8080/path
|
||||
# that obviously invalid. Probably it should be resolved at
|
||||
# swift.common.swob though, tentatively we are parsing and
|
||||
# reconstructing the correct host_url info here.
|
||||
# in detail, https://github.com/boto/boto/pull/3513
|
||||
parsed_url = urlparse(req.host_url)
|
||||
host_url = '%s://%s' % (parsed_url.scheme, parsed_url.hostname)
|
||||
if parsed_url.port:
|
||||
host_url += ':%s' % parsed_url.port
|
||||
|
||||
SubElement(result_elem, 'Location').text = host_url + req.path
|
||||
SubElement(result_elem, 'Bucket').text = req.container_name
|
||||
SubElement(result_elem, 'Key').text = req.object_name
|
||||
SubElement(result_elem, 'ETag').text = resp.etag
|
||||
|
@ -58,7 +58,7 @@ from swift.common.wsgi import PipelineWrapper, loadcontext
|
||||
|
||||
from swift3 import __version__ as swift3_version
|
||||
from swift3.exception import NotS3Request
|
||||
from swift3.request import Request, S3AclRequest
|
||||
from swift3.request import get_request_class
|
||||
from swift3.response import ErrorResponse, InternalError, MethodNotAllowed, \
|
||||
ResponseBase
|
||||
from swift3.cfg import CONF
|
||||
@ -75,10 +75,8 @@ class Swift3Middleware(object):
|
||||
|
||||
def __call__(self, env, start_response):
|
||||
try:
|
||||
if CONF.s3_acl:
|
||||
req = S3AclRequest(env, self.app, self.slo_enabled)
|
||||
else:
|
||||
req = Request(env, self.slo_enabled)
|
||||
req_class = get_request_class(env)
|
||||
req = req_class(env, self.app, self.slo_enabled)
|
||||
resp = self.handle_request(req)
|
||||
except NotS3Request:
|
||||
resp = self.app
|
||||
|
@ -13,14 +13,14 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import re
|
||||
import md5
|
||||
from urllib import quote, unquote
|
||||
import base64
|
||||
import email.utils
|
||||
from email.header import Header
|
||||
import datetime
|
||||
from hashlib import sha256
|
||||
import md5
|
||||
import re
|
||||
import six
|
||||
import string
|
||||
from urllib import quote, unquote
|
||||
|
||||
from swift.common.utils import split_path
|
||||
from swift.common import swob
|
||||
@ -45,15 +45,18 @@ from swift3.response import AccessDenied, InvalidArgument, InvalidDigest, \
|
||||
BucketAlreadyExists, BucketNotEmpty, EntityTooLarge, \
|
||||
InternalError, NoSuchBucket, NoSuchKey, PreconditionFailed, InvalidRange, \
|
||||
MissingContentLength, InvalidStorageClass, S3NotImplemented, InvalidURI, \
|
||||
MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, BadDigest
|
||||
MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \
|
||||
BadDigest, AuthorizationHeaderMalformed
|
||||
from swift3.exception import NotS3Request, BadSwiftRequest
|
||||
from swift3.utils import utf8encode, LOGGER, check_path_header
|
||||
from swift3.utils import utf8encode, LOGGER, check_path_header, S3Timestamp, \
|
||||
mktime
|
||||
from swift3.cfg import CONF
|
||||
from swift3.subresource import decode_acl, encode_acl
|
||||
from swift3.utils import sysmeta_header, validate_bucket_name
|
||||
from swift3.acl_utils import handle_acl_header
|
||||
from swift3.acl_handlers import get_acl_handler
|
||||
|
||||
|
||||
# List of sub-resources that must be maintained as part of the HMAC
|
||||
# signature string.
|
||||
ALLOWED_SUB_RESOURCES = sorted([
|
||||
@ -65,7 +68,10 @@ ALLOWED_SUB_RESOURCES = sorted([
|
||||
'response-content-type', 'response-expires', 'cors', 'tagging', 'restore'
|
||||
])
|
||||
|
||||
|
||||
MAX_32BIT_INT = 2147483647
|
||||
SIGV2_TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S'
|
||||
SIGV4_X_AMZ_DATE_FORMAT = '%Y%m%dT%H%M%SZ'
|
||||
|
||||
|
||||
def _header_acl_property(resource):
|
||||
@ -86,6 +92,243 @@ def _header_acl_property(resource):
|
||||
doc='Get and set the %s acl property' % resource)
|
||||
|
||||
|
||||
class SigV4Mixin(object):
|
||||
"""
|
||||
A request class mixin to provide S3 signature v4 functionality
|
||||
|
||||
:param req_cls: a Request class (Request or S3AclRequest or child classes)
|
||||
"""
|
||||
|
||||
@property
|
||||
def _is_query_auth(self):
|
||||
return 'X-Amz-Credential' in self.params
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""
|
||||
Return timestamp string according to the auth type
|
||||
The difference from v2 is v4 have to see 'X-Amz-Date' even though
|
||||
it's query auth type.
|
||||
"""
|
||||
if not self._timestamp:
|
||||
try:
|
||||
if self._is_query_auth and 'X-Amz-Date' in self.params:
|
||||
# NOTE(andrey-mp): Date in Signature V4 has different
|
||||
# format
|
||||
timestamp = mktime(
|
||||
self.params['X-Amz-Date'], SIGV4_X_AMZ_DATE_FORMAT)
|
||||
else:
|
||||
if self.headers.get('X-Amz-Date'):
|
||||
timestamp = mktime(
|
||||
self.headers.get('X-Amz-Date'),
|
||||
SIGV4_X_AMZ_DATE_FORMAT)
|
||||
else:
|
||||
timestamp = mktime(self.headers.get('Date'))
|
||||
except (ValueError, TypeError):
|
||||
raise AccessDenied('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header')
|
||||
|
||||
try:
|
||||
self._timestamp = S3Timestamp(timestamp)
|
||||
except ValueError:
|
||||
raise AccessDenied()
|
||||
|
||||
return self._timestamp
|
||||
|
||||
def _validate_expire_param(self):
|
||||
"""
|
||||
:param now: a S3Timestamp instance
|
||||
"""
|
||||
expires = self.params['X-Amz-Expires']
|
||||
if int(self.timestamp) + int(expires) < S3Timestamp.now():
|
||||
raise AccessDenied('Request has expired')
|
||||
|
||||
def _parse_query_authentication(self):
|
||||
"""
|
||||
Parse v4 query authentication
|
||||
- version 4:
|
||||
'X-Amz-Credential' and 'X-Amz-Signature' should be in param
|
||||
:raises: AccessDenied
|
||||
:raises: AuthorizationHeaderMalformed
|
||||
"""
|
||||
if self.params.get('X-Amz-Algorithm') != 'AWS4-HMAC-SHA256':
|
||||
raise InvalidArgument('X-Amz-Algorithm',
|
||||
self.params.get('X-Amz-Algorithm'))
|
||||
try:
|
||||
cred_param = self.params['X-Amz-Credential'].split("/")
|
||||
access = cred_param[0]
|
||||
sig = self.params['X-Amz-Signature']
|
||||
expires = self.params['X-Amz-Expires']
|
||||
except KeyError:
|
||||
raise AccessDenied()
|
||||
|
||||
try:
|
||||
signed_headers = self.params['X-Amz-SignedHeaders']
|
||||
except KeyError:
|
||||
# TODO: make sure if is it malformed request?
|
||||
raise AuthorizationHeaderMalformed()
|
||||
|
||||
self._signed_headers = set(signed_headers.split(';'))
|
||||
|
||||
# credential must be in following format:
|
||||
# <access-key-id>/<date>/<AWS-region>/<AWS-service>/aws4_request
|
||||
if not all([access, sig, len(cred_param) == 5, expires]):
|
||||
raise AccessDenied()
|
||||
|
||||
return access, sig
|
||||
|
||||
def _parse_header_authentication(self):
|
||||
"""
|
||||
Parse v4 header authentication
|
||||
- version 4:
|
||||
'X-Amz-Credential' and 'X-Amz-Signature' should be in param
|
||||
:raises: AccessDenied
|
||||
:raises: AuthorizationHeaderMalformed
|
||||
"""
|
||||
|
||||
auth_str = self.headers['Authorization']
|
||||
cred_param = auth_str.partition(
|
||||
"Credential=")[2].split(',')[0].split("/")
|
||||
access = cred_param[0]
|
||||
sig = auth_str.partition("Signature=")[2].split(',')[0]
|
||||
signed_headers = auth_str.partition(
|
||||
"SignedHeaders=")[2].split(',', 1)[0]
|
||||
# credential must be in following format:
|
||||
# <access-key-id>/<date>/<AWS-region>/<AWS-service>/aws4_request
|
||||
if not all([access, sig, len(cred_param) == 5]):
|
||||
raise AccessDenied()
|
||||
if not signed_headers:
|
||||
# TODO: make sure if is it Malformed?
|
||||
raise AuthorizationHeaderMalformed()
|
||||
|
||||
self._signed_headers = set(signed_headers.split(';'))
|
||||
|
||||
return access, sig
|
||||
|
||||
def _canonical_query_string(self):
|
||||
return '&'.join(
|
||||
'%s=%s' % (quote(key, safe='-_.~'),
|
||||
quote(value, safe='-_.~'))
|
||||
for key, value in sorted(self.params.items())
|
||||
if key not in ('Signature', 'X-Amz-Signature'))
|
||||
|
||||
def _headers_to_sign(self):
|
||||
"""
|
||||
Select the headers from the request that need to be included
|
||||
in the StringToSign.
|
||||
|
||||
:return : dict of headers to sign, the keys are all lower case
|
||||
"""
|
||||
headers_lower_dict = dict(
|
||||
(k.lower().strip(), ' '.join((v or '').strip().split()))
|
||||
for (k, v) in six.iteritems(self.headers))
|
||||
|
||||
if 'host' in headers_lower_dict and re.match(
|
||||
'Boto/2.[0-9].[0-2]',
|
||||
headers_lower_dict.get('user-agent', '')):
|
||||
# Boto versions < 2.9.3 strip the port component of the host:port
|
||||
# header, so detect the user-agent via the header and strip the
|
||||
# port if we detect an old boto version.
|
||||
headers_lower_dict['host'] = \
|
||||
headers_lower_dict['host'].split(':')[0]
|
||||
|
||||
headers_to_sign = [
|
||||
(key, value) for key, value in headers_lower_dict.items()
|
||||
if key in self._signed_headers]
|
||||
|
||||
if len(headers_to_sign) != len(self._signed_headers):
|
||||
# NOTE: if we are missing the header suggested via
|
||||
# signed_header in actual header, it results in
|
||||
# SignatureDoesNotMatch in actual S3 so we can raise
|
||||
# the error immediately here to save redundant check
|
||||
# process.
|
||||
raise SignatureDoesNotMatch()
|
||||
|
||||
return dict(headers_to_sign)
|
||||
|
||||
def _string_to_sign(self):
|
||||
"""
|
||||
Create 'StringToSign' value in Amazon terminology for v4.
|
||||
"""
|
||||
scope = (self.timestamp.amz_date_format.split('T')[0] +
|
||||
'/' + CONF.location + '/s3/aws4_request')
|
||||
|
||||
# prepare 'canonical_request'
|
||||
# Example requests are like following:
|
||||
#
|
||||
# GET
|
||||
# /
|
||||
# Action=ListUsers&Version=2010-05-08
|
||||
# content-type:application/x-www-form-urlencoded; charset=utf-8
|
||||
# host:iam.amazonaws.com
|
||||
# x-amz-date:20150830T123600Z
|
||||
#
|
||||
# content-type;host;x-amz-date
|
||||
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
#
|
||||
|
||||
# 1. Add verb like: GET
|
||||
cr = [self.method.upper()]
|
||||
|
||||
# 2. Add path like: /
|
||||
path = self._canonical_uri()
|
||||
cr.append(path)
|
||||
|
||||
# 3. Add query like: Action=ListUsers&Version=2010-05-08
|
||||
cr.append(self._canonical_query_string())
|
||||
|
||||
# 4. Add headers like:
|
||||
# content-type:application/x-www-form-urlencoded; charset=utf-8
|
||||
# host:iam.amazonaws.com
|
||||
# x-amz-date:20150830T123600Z
|
||||
headers_to_sign = self._headers_to_sign()
|
||||
cr.append('\n'.join(
|
||||
['%s:%s' % (key, value) for key, value in
|
||||
sorted(headers_to_sign.items())]) + '\n')
|
||||
|
||||
# 5. Add signed headers into canonical request like
|
||||
# content-type;host;x-amz-date
|
||||
cr.append(';'.join(sorted(n for n in headers_to_sign)))
|
||||
|
||||
# 6. Add payload string at the tail
|
||||
if 'X-Amz-Credential' in self.params:
|
||||
# V4 with query parameters only
|
||||
hashed_payload = 'UNSIGNED-PAYLOAD'
|
||||
elif 'X-Amz-Content-SHA256' not in self.headers:
|
||||
msg = 'Missing required header for this request: ' \
|
||||
'x-amz-content-sha256'
|
||||
raise InvalidRequest(msg)
|
||||
else:
|
||||
hashed_payload = self.headers['X-Amz-Content-SHA256']
|
||||
cr.append(hashed_payload)
|
||||
canonical_request = '\n'.join(cr)
|
||||
|
||||
return ('AWS4-HMAC-SHA256' + '\n'
|
||||
+ self.timestamp.amz_date_format + '\n'
|
||||
+ scope + '\n'
|
||||
+ sha256(canonical_request.encode('utf-8')).hexdigest())
|
||||
|
||||
|
||||
def get_request_class(env):
|
||||
"""
|
||||
Helper function to find a request class to use from Map
|
||||
"""
|
||||
if CONF.s3_acl:
|
||||
request_classes = (S3AclRequest, SigV4S3AclRequest)
|
||||
else:
|
||||
request_classes = (Request, SigV4Request)
|
||||
|
||||
req = swob.Request(env)
|
||||
if 'X-Amz-Credential' in req.params or \
|
||||
req.headers.get('Authorization', '').startswith(
|
||||
'AWS4-HMAC-SHA256 '):
|
||||
# This is an Amazon SigV4 request
|
||||
return request_classes[1]
|
||||
else:
|
||||
# The others using Amazon SigV2 class
|
||||
return request_classes[0]
|
||||
|
||||
|
||||
class Request(swob.Request):
|
||||
"""
|
||||
S3 request object.
|
||||
@ -94,22 +337,70 @@ class Request(swob.Request):
|
||||
bucket_acl = _header_acl_property('container')
|
||||
object_acl = _header_acl_property('object')
|
||||
|
||||
def __init__(self, env, slo_enabled=True):
|
||||
def __init__(self, env, app=None, slo_enabled=True):
|
||||
# NOTE: app is not used by this class, need for compatibility of S3acl
|
||||
swob.Request.__init__(self, env)
|
||||
|
||||
self.access_key = self._parse_authorization()
|
||||
self._timestamp = None
|
||||
self.access_key, signature = self._parse_auth_info()
|
||||
self.bucket_in_host = self._parse_host()
|
||||
self.container_name, self.object_name = self._parse_uri()
|
||||
self._validate_headers()
|
||||
self.token = base64.urlsafe_b64encode(self._canonical_string())
|
||||
self.token = base64.urlsafe_b64encode(self._string_to_sign())
|
||||
self.account = None
|
||||
self.user_id = None
|
||||
self.slo_enabled = slo_enabled
|
||||
|
||||
# NOTE(andrey-mp): substitute authorization header for next modules
|
||||
# in pipeline (s3token). it uses this and X-Auth-Token in specific
|
||||
# format.
|
||||
# (kota_): yeah, the reason we need this is s3token only supports
|
||||
# v2 like header consists of AWS access:signature. Since the commit
|
||||
# b626a3ca86e467fc7564eac236b9ee2efd49bdcc, the s3token is in swift3
|
||||
# repo so probably we need to change s3token to support v4 format.
|
||||
self.headers['Authorization'] = 'AWS %s:%s' % (
|
||||
self.access_key, signature)
|
||||
# Avoids that swift.swob.Response replaces Location header value
|
||||
# by full URL when absolute path given. See swift.swob for more detail.
|
||||
self.environ['swift.leave_relative_location'] = True
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""
|
||||
S3Timestamp from Date header. If X-Amz-Date header specified, it
|
||||
will be prior to Date header.
|
||||
|
||||
:return : S3Timestamp instance
|
||||
"""
|
||||
if not self._timestamp:
|
||||
try:
|
||||
if self._is_query_auth and 'Timestamp' in self.params:
|
||||
# If Timestamp speciied in query, it should be prior
|
||||
# to any Date header (is this right?)
|
||||
timestamp = mktime(
|
||||
self.params['Timestamp'], SIGV2_TIMESTAMP_FORMAT)
|
||||
else:
|
||||
timestamp = mktime(
|
||||
self.headers.get('X-Amz-Date',
|
||||
self.headers.get('Date')))
|
||||
except ValueError:
|
||||
raise AccessDenied('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header')
|
||||
|
||||
try:
|
||||
self._timestamp = S3Timestamp(timestamp)
|
||||
except ValueError:
|
||||
raise AccessDenied()
|
||||
|
||||
return self._timestamp
|
||||
|
||||
@property
|
||||
def _is_header_auth(self):
|
||||
return 'Authorization' in self.headers
|
||||
|
||||
@property
|
||||
def _is_query_auth(self):
|
||||
return 'AWSAccessKeyId' in self.params
|
||||
|
||||
def _parse_host(self):
|
||||
storage_domain = CONF.storage_domain
|
||||
if not storage_domain:
|
||||
@ -148,35 +439,94 @@ class Request(swob.Request):
|
||||
raise InvalidBucketName(bucket)
|
||||
return (bucket, obj)
|
||||
|
||||
def _parse_authorization(self):
|
||||
if 'AWSAccessKeyId' in self.params:
|
||||
try:
|
||||
self.headers['Date'] = self.params['Expires']
|
||||
self.headers['Authorization'] = \
|
||||
'AWS %(AWSAccessKeyId)s:%(Signature)s' % self.params
|
||||
except KeyError:
|
||||
raise AccessDenied()
|
||||
|
||||
if 'Authorization' not in self.headers:
|
||||
raise NotS3Request()
|
||||
def _parse_query_authentication(self):
|
||||
"""
|
||||
Parse v2 authentication query args
|
||||
TODO: make sure if 0, 1, 3 is supported?
|
||||
- version 0, 1, 2, 3:
|
||||
'AWSAccessKeyId' and 'Signature' should be in param
|
||||
|
||||
:return: a tuple of access_key and signature
|
||||
:raises: AccessDenied
|
||||
"""
|
||||
try:
|
||||
keyword, info = self.headers['Authorization'].split(' ', 1)
|
||||
except Exception:
|
||||
access = self.params['AWSAccessKeyId']
|
||||
expires = self.params['Expires']
|
||||
# TODO: can we remove this logic here?
|
||||
# self.headers['Date'] = expires
|
||||
sig = self.params['Signature']
|
||||
except KeyError:
|
||||
raise AccessDenied()
|
||||
|
||||
if keyword != 'AWS':
|
||||
if not all([access, sig, expires]):
|
||||
raise AccessDenied()
|
||||
|
||||
return access, sig
|
||||
|
||||
def _parse_header_authentication(self):
|
||||
"""
|
||||
Parse v2 header authentication info
|
||||
|
||||
:returns: a tuple of access_key and signature
|
||||
:raises: AccessDenied
|
||||
"""
|
||||
auth_str = self.headers['Authorization']
|
||||
if not auth_str.startswith('AWS ') or ':' not in auth_str:
|
||||
raise AccessDenied()
|
||||
# This means signature format V2
|
||||
access, sig = auth_str.split(' ', 1)[1].rsplit(':', 1)
|
||||
return access, sig
|
||||
|
||||
def _parse_auth_info(self):
|
||||
"""Extract the access key identifier and signature.
|
||||
|
||||
:returns: a tuple of access_key and signature
|
||||
:raises: NotS3Request
|
||||
"""
|
||||
if self._is_query_auth:
|
||||
return self._parse_query_authentication()
|
||||
elif self._is_header_auth:
|
||||
return self._parse_header_authentication()
|
||||
else:
|
||||
# if this request is neither query auth nor header auth
|
||||
# swift3 regard this as not s3 request
|
||||
raise NotS3Request()
|
||||
|
||||
def _validate_expire_param(self):
|
||||
"""
|
||||
Validate Expire param
|
||||
"""
|
||||
# Expires header is a float since epoch
|
||||
try:
|
||||
access_key = info.rsplit(':', 1)[0]
|
||||
except Exception:
|
||||
err_msg = 'AWS authorization header is invalid. ' \
|
||||
'Expected AwsAccessKeyId:signature'
|
||||
raise InvalidArgument('Authorization',
|
||||
self.headers['Authorization'], err_msg)
|
||||
ex = S3Timestamp(float(self.params['Expires']))
|
||||
except ValueError:
|
||||
raise AccessDenied()
|
||||
|
||||
return access_key
|
||||
if S3Timestamp.now() > ex:
|
||||
raise AccessDenied('Request has expired')
|
||||
|
||||
def _validate_dates(self):
|
||||
if self._is_query_auth:
|
||||
self._validate_expire_param()
|
||||
# TODO: make sure the case if timestamp param in query
|
||||
return
|
||||
|
||||
date_header = self.headers.get('Date')
|
||||
amz_date_header = self.headers.get('X-Amz-Date')
|
||||
if not date_header and not amz_date_header:
|
||||
raise AccessDenied('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header')
|
||||
|
||||
# Anyways, request timestamp should be validated
|
||||
epoch = S3Timestamp(0)
|
||||
if self.timestamp < epoch:
|
||||
raise AccessDenied()
|
||||
|
||||
# If the standard date is too far ahead or behind, it is an
|
||||
# error
|
||||
delta = 60 * 5
|
||||
if abs(int(self.timestamp) - int(S3Timestamp.now())) > delta:
|
||||
raise RequestTimeTooSkewed()
|
||||
|
||||
def _validate_headers(self):
|
||||
if 'CONTENT_LENGTH' in self.environ:
|
||||
@ -188,40 +538,7 @@ class Request(swob.Request):
|
||||
raise InvalidArgument('Content-Length',
|
||||
self.environ['CONTENT_LENGTH'])
|
||||
|
||||
date_header = self.headers.get('x-amz-date',
|
||||
self.headers.get('Date', None))
|
||||
if date_header:
|
||||
now = datetime.datetime.utcnow()
|
||||
date = email.utils.parsedate(date_header)
|
||||
if 'Expires' in self.params:
|
||||
try:
|
||||
d = email.utils.formatdate(float(self.params['Expires']))
|
||||
except ValueError:
|
||||
raise AccessDenied()
|
||||
|
||||
# check expiration
|
||||
expdate = email.utils.parsedate(d)
|
||||
ex = datetime.datetime(*expdate[0:6])
|
||||
if now > ex:
|
||||
raise AccessDenied('Request has expired')
|
||||
elif date is not None:
|
||||
epoch = datetime.datetime(1970, 1, 1, 0, 0, 0, 0)
|
||||
|
||||
d1 = datetime.datetime(*date[0:6])
|
||||
if d1 < epoch:
|
||||
raise AccessDenied()
|
||||
|
||||
# If the standard date is too far ahead or behind, it is an
|
||||
# error
|
||||
delta = datetime.timedelta(seconds=60 * 5)
|
||||
if abs(d1 - now) > delta:
|
||||
raise RequestTimeTooSkewed()
|
||||
else:
|
||||
raise AccessDenied('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header')
|
||||
else:
|
||||
raise AccessDenied('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header')
|
||||
self._validate_dates()
|
||||
|
||||
if 'Content-MD5' in self.headers:
|
||||
value = self.headers['Content-MD5']
|
||||
@ -368,9 +685,9 @@ class Request(swob.Request):
|
||||
raw_path_info = '/' + self.bucket_in_host + raw_path_info
|
||||
return raw_path_info
|
||||
|
||||
def _canonical_string(self):
|
||||
def _string_to_sign(self):
|
||||
"""
|
||||
Canonicalize a request to a token that can be signed.
|
||||
Create 'StringToSign' value in Amazon terminology for v2.
|
||||
"""
|
||||
amz_headers = {}
|
||||
|
||||
@ -863,3 +1180,11 @@ class S3AclRequest(Request):
|
||||
return resp
|
||||
return self.get_acl_response(app, method, container, obj,
|
||||
headers, body, query)
|
||||
|
||||
|
||||
class SigV4Request(SigV4Mixin, Request):
|
||||
pass
|
||||
|
||||
|
||||
class SigV4S3AclRequest(SigV4Mixin, S3AclRequest):
|
||||
pass
|
||||
|
@ -223,6 +223,13 @@ class AmbiguousGrantByEmailAddress(ErrorResponse):
|
||||
'one account.'
|
||||
|
||||
|
||||
class AuthorizationHeaderMalformed(ErrorResponse):
|
||||
_status = '400 Bad Request'
|
||||
_msg = 'The authorization header is malformed; the authorization ' \
|
||||
'header requires three components: Credential, SignedHeaders, ' \
|
||||
'and Signature.'
|
||||
|
||||
|
||||
class BadDigest(ErrorResponse):
|
||||
_status = '400 Bad Request'
|
||||
_msg = 'The Content-MD5 you specified did not match what we received.'
|
||||
|
@ -1,4 +1,7 @@
|
||||
[DEFAULT]
|
||||
debug=true
|
||||
verbose=true
|
||||
policy_file=%CONF_DIR%/policy.json
|
||||
admin_token = ADMIN
|
||||
|
||||
[database]
|
||||
|
@ -23,6 +23,7 @@ user_test_tester2 = testing2
|
||||
[filter:swift3]
|
||||
use = egg:swift3#swift3
|
||||
s3_acl = %S3ACL%
|
||||
location = US
|
||||
|
||||
[filter:catch_errors]
|
||||
use = egg:swift#catch_errors
|
||||
|
@ -112,7 +112,8 @@ nosetests -v ./
|
||||
rvalue=$?
|
||||
|
||||
# cleanup
|
||||
kill -HUP $proxy_pid $account_pid $container_pid $object_pid $keystone_pid
|
||||
kill -HUP $proxy_pid $account_pid $container_pid $object_pid
|
||||
kill -TERM $keystone_pid
|
||||
|
||||
# show report
|
||||
sleep 3
|
||||
|
@ -51,6 +51,7 @@ class Connection(object):
|
||||
S3Connection(aws_access_key, aws_secret_key, is_secure=False,
|
||||
host=self.host, port=self.port,
|
||||
calling_format=OrdinaryCallingFormat())
|
||||
self.conn.auth_region_name = 'US'
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
|
@ -14,6 +14,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from swift3.test.functional import Swift3FunctionalTestCase
|
||||
from swift3.test.functional.s3_test_client import Connection, \
|
||||
@ -124,5 +125,18 @@ class TestSwift3Acl(Swift3FunctionalTestCase):
|
||||
self.conn2.make_request('GET', self.bucket, self.obj, query='acl')
|
||||
self.assertEquals(get_error_code(body), 'AccessDenied')
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3AclSigV4(TestSwift3Acl):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -14,6 +14,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from swift3.test.functional.s3_test_client import Connection
|
||||
from swift3.test.functional.utils import get_error_code
|
||||
@ -313,5 +314,18 @@ class TestSwift3Bucket(Swift3FunctionalTestCase):
|
||||
status, headers, body = self.conn.make_request('DELETE', 'bucket')
|
||||
self.assertEquals(get_error_code(body), 'NoSuchBucket')
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3BucketSigV4(TestSwift3Bucket):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -13,6 +13,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from swift3.test.functional.utils import calculate_md5, get_error_code
|
||||
from swift3.etree import fromstring, tostring, Element, SubElement
|
||||
from swift3.controllers.multi_delete import MAX_MULTI_DELETE_BODY_SIZE
|
||||
@ -215,3 +218,19 @@ class TestSwift3MultiDelete(Swift3FunctionalTestCase):
|
||||
elem = fromstring(body, 'DeleteResult')
|
||||
resp_objects = elem.findall('Deleted')
|
||||
self.assertEquals(len(resp_objects), 1)
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3MultiDeleteSigV4(TestSwift3MultiDelete):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -14,13 +14,20 @@
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import boto
|
||||
|
||||
# For an issue with venv and distutils, disable pylint message here
|
||||
# pylint: disable-msg=E0611,F0401
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
from hashlib import md5
|
||||
from itertools import izip
|
||||
|
||||
from swift3.test.functional.utils import get_error_code, get_error_msg
|
||||
from swift3.etree import fromstring, tostring, Element, SubElement
|
||||
from swift3.test.functional import Swift3FunctionalTestCase
|
||||
from swift3.test.functional.utils import mktime
|
||||
from swift3.utils import mktime
|
||||
from swift3.test.functional.s3_test_client import Connection
|
||||
|
||||
MIN_SEGMENT_SIZE = 5242880
|
||||
@ -182,39 +189,8 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
||||
self.assertTrue('etag' not in headers)
|
||||
elem = fromstring(body, 'CopyPartResult')
|
||||
|
||||
last_modified_1 = elem.find('LastModified').text
|
||||
self.assertTrue(last_modified_1 is not None)
|
||||
|
||||
self.assertEquals(resp_etag, etag)
|
||||
|
||||
# Upload Part Copy Range
|
||||
key, upload_id = uploads[1]
|
||||
src_bucket = 'bucket2'
|
||||
src_obj = 'obj4'
|
||||
src_content = 'y' * (MIN_SEGMENT_SIZE / 2) + 'z' * MIN_SEGMENT_SIZE
|
||||
src_range = 'bytes=0-%d' % (MIN_SEGMENT_SIZE - 1)
|
||||
etag = md5(src_content[:MIN_SEGMENT_SIZE]).hexdigest()
|
||||
|
||||
# prepare src obj
|
||||
self.conn.make_request('PUT', src_bucket)
|
||||
self.conn.make_request('PUT', src_bucket, src_obj, body=src_content)
|
||||
_, headers, _ = self.conn.make_request('HEAD', src_bucket, src_obj)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
|
||||
status, headers, body, resp_etag = \
|
||||
self._upload_part_copy(src_bucket, src_obj, bucket,
|
||||
key, upload_id, 2, src_range)
|
||||
self.assertEquals(status, 200)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
self.assertTrue('content-type' in headers)
|
||||
self.assertEquals(headers['content-type'], 'application/xml')
|
||||
self.assertTrue('content-length' in headers)
|
||||
self.assertEquals(headers['content-length'], str(len(body)))
|
||||
self.assertTrue('etag' not in headers)
|
||||
elem = fromstring(body, 'CopyPartResult')
|
||||
|
||||
last_modified_2 = elem.find('LastModified').text
|
||||
self.assertTrue(last_modified_2 is not None)
|
||||
last_modified = elem.find('LastModified').text
|
||||
self.assertTrue(last_modified is not None)
|
||||
|
||||
self.assertEquals(resp_etag, etag)
|
||||
|
||||
@ -231,14 +207,10 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
||||
for p in elem.iterfind('Part')]
|
||||
self.assertEquals(
|
||||
last_modified_gets[0].rsplit('.', 1)[0],
|
||||
last_modified_1.rsplit('.', 1)[0],
|
||||
'%r != %r' % (last_modified_gets[0], last_modified_1))
|
||||
self.assertEquals(
|
||||
last_modified_gets[1].rsplit('.', 1)[0],
|
||||
last_modified_2.rsplit('.', 1)[0],
|
||||
'%r != %r' % (last_modified_gets[1], last_modified_2))
|
||||
last_modified.rsplit('.', 1)[0],
|
||||
'%r != %r' % (last_modified_gets[0], last_modified))
|
||||
# There should be *exactly* two parts in the result
|
||||
self.assertEqual([], last_modified_gets[2:])
|
||||
self.assertEqual(1, len(last_modified_gets))
|
||||
|
||||
# List Parts
|
||||
key, upload_id = uploads[0]
|
||||
@ -573,6 +545,114 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
||||
query=query)
|
||||
self.assertEquals(status, 200)
|
||||
|
||||
def test_object_multi_upload_part_copy_range(self):
|
||||
bucket = 'bucket'
|
||||
keys = ['obj1']
|
||||
uploads = []
|
||||
|
||||
results_generator = self._initiate_multi_uploads_result_generator(
|
||||
bucket, keys)
|
||||
|
||||
# Initiate Multipart Upload
|
||||
for expected_key, (status, headers, body) in \
|
||||
izip(keys, results_generator):
|
||||
self.assertEquals(status, 200)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
self.assertTrue('content-type' in headers)
|
||||
self.assertEquals(headers['content-type'], 'application/xml')
|
||||
self.assertTrue('content-length' in headers)
|
||||
self.assertEquals(headers['content-length'], str(len(body)))
|
||||
elem = fromstring(body, 'InitiateMultipartUploadResult')
|
||||
self.assertEquals(elem.find('Bucket').text, bucket)
|
||||
key = elem.find('Key').text
|
||||
self.assertEquals(expected_key, key)
|
||||
upload_id = elem.find('UploadId').text
|
||||
self.assertTrue(upload_id is not None)
|
||||
self.assertTrue((key, upload_id) not in uploads)
|
||||
uploads.append((key, upload_id))
|
||||
|
||||
self.assertEquals(len(uploads), len(keys)) # sanity
|
||||
|
||||
# Upload Part Copy Range
|
||||
key, upload_id = uploads[0]
|
||||
src_bucket = 'bucket2'
|
||||
src_obj = 'obj4'
|
||||
src_content = 'y' * (MIN_SEGMENT_SIZE / 2) + 'z' * MIN_SEGMENT_SIZE
|
||||
src_range = 'bytes=0-%d' % (MIN_SEGMENT_SIZE - 1)
|
||||
etag = md5(src_content[:MIN_SEGMENT_SIZE]).hexdigest()
|
||||
|
||||
# prepare src obj
|
||||
self.conn.make_request('PUT', src_bucket)
|
||||
self.conn.make_request('PUT', src_bucket, src_obj, body=src_content)
|
||||
_, headers, _ = self.conn.make_request('HEAD', src_bucket, src_obj)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
|
||||
status, headers, body, resp_etag = \
|
||||
self._upload_part_copy(src_bucket, src_obj, bucket,
|
||||
key, upload_id, 1, src_range)
|
||||
self.assertEquals(status, 200)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
self.assertTrue('content-type' in headers)
|
||||
self.assertEquals(headers['content-type'], 'application/xml')
|
||||
self.assertTrue('content-length' in headers)
|
||||
self.assertEquals(headers['content-length'], str(len(body)))
|
||||
self.assertTrue('etag' not in headers)
|
||||
elem = fromstring(body, 'CopyPartResult')
|
||||
|
||||
last_modified = elem.find('LastModified').text
|
||||
self.assertTrue(last_modified is not None)
|
||||
|
||||
self.assertEquals(resp_etag, etag)
|
||||
|
||||
# Check last-modified timestamp
|
||||
key, upload_id = uploads[0]
|
||||
query = 'uploadId=%s' % upload_id
|
||||
status, headers, body = \
|
||||
self.conn.make_request('GET', bucket, key, query=query)
|
||||
|
||||
elem = fromstring(body, 'ListPartsResult')
|
||||
|
||||
# FIXME: COPY result drops mili/microseconds but GET doesn't
|
||||
last_modified_gets = [p.find('LastModified').text
|
||||
for p in elem.iterfind('Part')]
|
||||
self.assertEquals(
|
||||
last_modified_gets[0].rsplit('.', 1)[0],
|
||||
last_modified.rsplit('.', 1)[0],
|
||||
'%r != %r' % (last_modified_gets[0], last_modified))
|
||||
|
||||
# There should be *exactly* one parts in the result
|
||||
self.assertEqual(1, len(last_modified_gets))
|
||||
|
||||
# Abort Multipart Upload
|
||||
key, upload_id = uploads[0]
|
||||
query = 'uploadId=%s' % upload_id
|
||||
status, headers, body = \
|
||||
self.conn.make_request('DELETE', bucket, key, query=query)
|
||||
|
||||
# sanities
|
||||
self.assertEquals(status, 204)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
self.assertTrue('content-type' in headers)
|
||||
self.assertEquals(headers['content-type'], 'text/html; charset=UTF-8')
|
||||
self.assertTrue('content-length' in headers)
|
||||
self.assertEquals(headers['content-length'], '0')
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3MultiUploadSigV4(TestSwift3MultiUpload):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
def test_object_multi_upload_part_copy_range(self):
|
||||
if StrictVersion(boto.__version__) < StrictVersion('3.0'):
|
||||
self.skipTest('This stuff got the issue of boto<=2.x')
|
||||
|
||||
def test_delete_bucket_multi_upload_object_exisiting(self):
|
||||
bucket = 'bucket'
|
||||
keys = ['obj1']
|
||||
|
@ -14,6 +14,12 @@
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import boto
|
||||
|
||||
# For an issue with venv and distutils, disable pylint message here
|
||||
# pylint: disable-msg=E0611,F0401
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
from email.utils import formatdate, parsedate
|
||||
from time import mktime
|
||||
@ -768,5 +774,47 @@ class TestSwift3Object(Swift3FunctionalTestCase):
|
||||
self.assertEquals(status, 200)
|
||||
self.assertCommonResponseHeaders(headers)
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3ObjectSigV4(TestSwift3Object):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
@unittest.skipIf(StrictVersion(boto.__version__) < StrictVersion('3.0'),
|
||||
'This stuff got the signing issue of boto<=2.x')
|
||||
def test_put_object_metadata(self):
|
||||
super(TestSwift3ObjectSigV4, self).test_put_object_metadata()
|
||||
|
||||
@unittest.skipIf(StrictVersion(boto.__version__) < StrictVersion('3.0'),
|
||||
'This stuff got the signing issue of boto<=2.x')
|
||||
def test_put_object_copy_source_if_modified_since(self):
|
||||
super(TestSwift3ObjectSigV4, self).\
|
||||
test_put_object_copy_source_if_modified_since()
|
||||
|
||||
@unittest.skipIf(StrictVersion(boto.__version__) < StrictVersion('3.0'),
|
||||
'This stuff got the signing issue of boto<=2.x')
|
||||
def test_put_object_copy_source_if_unmodified_since(self):
|
||||
super(TestSwift3ObjectSigV4, self).\
|
||||
test_put_object_copy_source_if_unmodified_since()
|
||||
|
||||
@unittest.skipIf(StrictVersion(boto.__version__) < StrictVersion('3.0'),
|
||||
'This stuff got the signing issue of boto<=2.x')
|
||||
def test_put_object_copy_source_if_match(self):
|
||||
super(TestSwift3ObjectSigV4,
|
||||
self).test_put_object_copy_source_if_match()
|
||||
|
||||
@unittest.skipIf(StrictVersion(boto.__version__) < StrictVersion('3.0'),
|
||||
'This stuff got the signing issue of boto<=2.x')
|
||||
def test_put_object_copy_source_if_none_match(self):
|
||||
super(TestSwift3ObjectSigV4,
|
||||
self).test_put_object_copy_source_if_none_match()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -14,6 +14,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
import unittest
|
||||
import os
|
||||
|
||||
from swift3.test.functional.s3_test_client import Connection
|
||||
from swift3.test.functional.utils import get_error_code
|
||||
@ -71,5 +72,18 @@ class TestSwift3Service(Swift3FunctionalTestCase):
|
||||
self.assertIn('AWS authentication requires a valid Date '
|
||||
'or x-amz-date header', body)
|
||||
|
||||
|
||||
@unittest.skipIf(os.environ['AUTH'] == 'tempauth',
|
||||
'v4 is supported only in keystone')
|
||||
class TestSwift3ServiceSigV4(TestSwift3Service):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
os.environ['S3_USE_SIGV4'] = "True"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
del os.environ['S3_USE_SIGV4']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -16,9 +16,6 @@
|
||||
from hashlib import md5
|
||||
from swift3.etree import fromstring
|
||||
|
||||
import time
|
||||
from email.utils import parsedate
|
||||
|
||||
|
||||
def get_error_code(body):
|
||||
elem = fromstring(body, 'Error')
|
||||
@ -32,25 +29,3 @@ def get_error_msg(body):
|
||||
|
||||
def calculate_md5(body):
|
||||
return md5(body).digest().encode('base64').strip()
|
||||
|
||||
|
||||
def mktime(timestamp_str):
|
||||
"""
|
||||
mktime creates a float instance in epoch time really like as time.mktime
|
||||
|
||||
the difference from time.mktime is allowing to 2 formats string for the
|
||||
argumtent for the S3 testing usage.
|
||||
TODO: support
|
||||
|
||||
:param timestamp_str: a string of timestamp formatted as
|
||||
(a) RFC2822 (e.g. date header)
|
||||
(b) %Y-%m-%dT%H:%M:%S (e.g. copy result)
|
||||
:return : a float instance in epoch time
|
||||
"""
|
||||
try:
|
||||
epoch_time = time.mktime(parsedate(timestamp_str))
|
||||
except TypeError:
|
||||
epoch_time = time.mktime(
|
||||
time.strptime(timestamp_str, '%Y-%m-%dT%H:%M:%S'))
|
||||
|
||||
return epoch_time
|
||||
|
@ -35,14 +35,13 @@ class FakeApp(object):
|
||||
For S3 requests, Swift auth middleware replaces a user name in
|
||||
env['PATH_INFO'] with a valid tenant id.
|
||||
E.g. '/v1/test:tester/bucket/object' will become
|
||||
'/v1/AUTH_test/bucket/object'. This method emulates the behavior.
|
||||
'/v1/AUTH_test/bucket/object'. This method emulates the behavior.
|
||||
"""
|
||||
_, authorization = env['HTTP_AUTHORIZATION'].split(' ')
|
||||
tenant_user, sign = authorization.rsplit(':', 1)
|
||||
tenant, user = tenant_user.rsplit(':', 1)
|
||||
|
||||
path = env['PATH_INFO']
|
||||
|
||||
env['PATH_INFO'] = path.replace(tenant_user, 'AUTH_' + tenant)
|
||||
|
||||
def __call__(self, env, start_response):
|
||||
@ -84,6 +83,10 @@ class Swift3TestCase(unittest.TestCase):
|
||||
elem = fromstring(body, 'Error')
|
||||
return elem.find('./Code').text
|
||||
|
||||
def _get_error_message(self, body):
|
||||
elem = fromstring(body, 'Error')
|
||||
return elem.find('./Message').text
|
||||
|
||||
def _test_method_error(self, method, path, response_class, headers={}):
|
||||
if not path.startswith('/'):
|
||||
path = '/' + path # add a missing slash before the path
|
||||
@ -101,7 +104,11 @@ class Swift3TestCase(unittest.TestCase):
|
||||
return self._get_error_code(body)
|
||||
|
||||
def get_date_header(self):
|
||||
return email.utils.formatdate(time.mktime(datetime.now().timetuple()))
|
||||
# email.utils.formatdate returns utc timestamp in default
|
||||
return email.utils.formatdate(time.time())
|
||||
|
||||
def get_v4_amz_date_header(self):
|
||||
return datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
def call_app(self, req, app=None, expect_exception=False):
|
||||
if app is None:
|
||||
|
@ -27,7 +27,7 @@ from swift.common.swob import Request
|
||||
|
||||
import swift3
|
||||
from swift3.test.unit import Swift3TestCase
|
||||
from swift3.request import Request as S3Request
|
||||
from swift3.request import SigV4Request, Request as S3Request
|
||||
from swift3.etree import fromstring
|
||||
from swift3.middleware import filter_factory
|
||||
from swift3.cfg import CONF
|
||||
@ -74,7 +74,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
self.assertEquals(path_info, unquote(raw_path_info))
|
||||
self.assertEquals(req.path, quote(path_info))
|
||||
|
||||
def test_canonical_string(self):
|
||||
def test_canonical_string_v2(self):
|
||||
"""
|
||||
The hashes here were generated by running the same requests against
|
||||
boto.utils.canonical_string
|
||||
@ -93,7 +93,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
'HTTP_AUTHORIZATION': 'AWS X:Y:Z',
|
||||
})
|
||||
req.headers.update(headers)
|
||||
return req._canonical_string()
|
||||
return req._string_to_sign()
|
||||
|
||||
def verify(hash, path, headers):
|
||||
s = canonical_string(path, headers)
|
||||
@ -170,17 +170,30 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
# Set expire to last 32b timestamp value
|
||||
# This number can't be higher, because it breaks tests on 32b systems
|
||||
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||
utc_date = datetime.utcnow()
|
||||
req = Request.blank('/bucket/object?Signature=X&Expires=%s&'
|
||||
'AWSAccessKeyId=test:tester' % expire,
|
||||
'AWSAccessKeyId=test:tester&Timestamp=%s' %
|
||||
(expire, utc_date.isoformat().rsplit('.')[0]),
|
||||
environ={'REQUEST_METHOD': 'GET'},
|
||||
headers={'Date': self.get_date_header()})
|
||||
req.headers['Date'] = datetime.utcnow()
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
for _, _, headers in self.swift.calls_with_headers:
|
||||
self.assertEquals(headers['Authorization'], 'AWS test:tester:X')
|
||||
self.assertEquals(headers['Date'], expire)
|
||||
|
||||
def test_signed_urls_no_timestamp(self):
|
||||
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||
req = Request.blank('/bucket/object?Signature=X&Expires=%s&'
|
||||
'AWSAccessKeyId=test:tester' % expire,
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
# Curious! But actually S3 doesn't verify any x-amz-date/date headers
|
||||
# for signed_url access and it also doesn't check timestamp
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
for _, _, headers in self.swift.calls_with_headers:
|
||||
self.assertEquals(headers['Authorization'], 'AWS test:tester:X')
|
||||
|
||||
def test_signed_urls_invalid_expire(self):
|
||||
expire = 'invalid'
|
||||
@ -194,7 +207,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signed_urls_no_sign(self):
|
||||
expire = 'invalid'
|
||||
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||
req = Request.blank('/bucket/object?Expires=%s&'
|
||||
'AWSAccessKeyId=test:tester' % expire,
|
||||
environ={'REQUEST_METHOD': 'GET'},
|
||||
@ -204,6 +217,102 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signed_urls_no_access(self):
|
||||
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||
req = Request.blank('/bucket/object?Expires=%s&'
|
||||
'AWSAccessKeyId=' % expire,
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.headers['Date'] = datetime.utcnow()
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signed_urls_v4(self):
|
||||
req = Request.blank(
|
||||
'/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test:tester/20T20Z/US/s3/aws4_request'
|
||||
'&X-Amz-Date=%s'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-SignedHeaders=host'
|
||||
'&X-Amz-Signature=X' %
|
||||
self.get_v4_amz_date_header(),
|
||||
headers={'Date': self.get_date_header()},
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '200', body)
|
||||
for _, _, headers in self.swift.calls_with_headers:
|
||||
self.assertEquals('AWS test:tester:X', headers['Authorization'])
|
||||
self.assertIn('X-Auth-Token', headers)
|
||||
|
||||
def test_signed_urls_v4_missing_x_amz_date(self):
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_request'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-SignedHeaders=host'
|
||||
'&X-Amz-Signature=X',
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signed_urls_v4_invalid_algorithm(self):
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=FAKE'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_request'
|
||||
'&X-Amz-Date=%s'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-SignedHeaders=host'
|
||||
'&X-Amz-Signature=X' %
|
||||
self.get_v4_amz_date_header(),
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'InvalidArgument')
|
||||
|
||||
def test_signed_urls_v4_missing_signed_headers(self):
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_request'
|
||||
'&X-Amz-Date=%s'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-Signature=X' %
|
||||
self.get_v4_amz_date_header(),
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body),
|
||||
'AuthorizationHeaderMalformed')
|
||||
|
||||
def test_signed_urls_v4_invalid_credentials(self):
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test'
|
||||
'&X-Amz-Date=%s'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-SignedHeaders=host'
|
||||
'&X-Amz-Signature=X' %
|
||||
self.get_v4_amz_date_header(),
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signed_urls_v4_missing_signature(self):
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_request'
|
||||
'&X-Amz-Date=%s'
|
||||
'&X-Amz-Expires=1000'
|
||||
'&X-Amz-SignedHeaders=host' %
|
||||
self.get_v4_amz_date_header(),
|
||||
environ={'REQUEST_METHOD': 'GET'})
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_bucket_virtual_hosted_style(self):
|
||||
req = Request.blank('/',
|
||||
environ={'HTTP_HOST': 'bucket.localhost:80',
|
||||
@ -447,6 +556,266 @@ class TestSwift3Middleware(Swift3TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.swift3.check_pipeline(conf)
|
||||
|
||||
def test_signature_v4(self):
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test:tester/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-date,'
|
||||
'Signature=X',
|
||||
'X-Amz-Date': self.get_v4_amz_date_header(),
|
||||
'X-Amz-Content-SHA256': '0123456789'}
|
||||
req = Request.blank('/bucket/object', environ=environ, headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '200', body)
|
||||
for _, _, headers in self.swift.calls_with_headers:
|
||||
self.assertEquals('AWS test:tester:X', headers['Authorization'])
|
||||
self.assertIn('X-Auth-Token', headers)
|
||||
|
||||
def test_signature_v4_no_date(self):
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test:tester/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;range;x-amz-date,'
|
||||
'Signature=X',
|
||||
'X-Amz-Content-SHA256': '0123456789'}
|
||||
req = Request.blank('/bucket/object', environ=environ, headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '403')
|
||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||
|
||||
def test_signature_v4_no_payload(self):
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test:tester/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-date,'
|
||||
'Signature=X',
|
||||
'X-Amz-Date': self.get_v4_amz_date_header()}
|
||||
req = Request.blank('/bucket/object', environ=environ, headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '400')
|
||||
self.assertEquals(self._get_error_code(body), 'InvalidRequest')
|
||||
self.assertEquals(
|
||||
self._get_error_message(body),
|
||||
'Missing required header for this request: x-amz-content-sha256')
|
||||
|
||||
def test_signature_v4_bad_authorization_string(self):
|
||||
def test(auth_str, error, msg):
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
headers = {
|
||||
'Authorization': auth_str,
|
||||
'X-Amz-Date': self.get_v4_amz_date_header(),
|
||||
'X-Amz-Content-SHA256': '0123456789'}
|
||||
req = Request.blank('/bucket/object', environ=environ,
|
||||
headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(self._get_error_code(body), error)
|
||||
self.assertEquals(self._get_error_message(body), msg)
|
||||
|
||||
auth_str = ('AWS4-HMAC-SHA256 '
|
||||
'SignedHeaders=host;x-amz-date,'
|
||||
'Signature=X')
|
||||
test(auth_str, 'AccessDenied', 'Access Denied.')
|
||||
|
||||
auth_str = ('AWS4-HMAC-SHA256 '
|
||||
'Credential=test:tester/20130524/US/s3/aws4_request, '
|
||||
'Signature=X')
|
||||
test(auth_str, 'AuthorizationHeaderMalformed',
|
||||
'The authorization header is malformed; the authorization '
|
||||
'header requires three components: Credential, SignedHeaders, '
|
||||
'and Signature.')
|
||||
|
||||
auth_str = ('AWS4-HMAC-SHA256 '
|
||||
'Credential=test:tester/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-date')
|
||||
test(auth_str, 'AccessDenied', 'Access Denied.')
|
||||
|
||||
def test_canonical_string_v4(self):
|
||||
def canonical_string(path, environ):
|
||||
if '?' in path:
|
||||
path, query_string = path.split('?', 1)
|
||||
else:
|
||||
query_string = ''
|
||||
|
||||
env = {
|
||||
'REQUEST_METHOD': 'GET',
|
||||
'PATH_INFO': path,
|
||||
'QUERY_STRING': query_string,
|
||||
'HTTP_DATE': 'Mon, 09 Sep 2011 23:36:00 GMT',
|
||||
'HTTP_X_AMZ_CONTENT_SHA256': (
|
||||
'e3b0c44298fc1c149afbf4c8996fb924'
|
||||
'27ae41e4649b934ca495991b7852b855')
|
||||
}
|
||||
env.update(environ)
|
||||
with patch('swift3.request.Request._validate_headers'):
|
||||
req = SigV4Request(env)
|
||||
return req._string_to_sign()
|
||||
|
||||
def verify(hash_val, path, environ):
|
||||
s = canonical_string(path, environ)
|
||||
s = s.split('\n')[3]
|
||||
self.assertEquals(hash_val, s)
|
||||
|
||||
# all next data got from aws4_testsuite from Amazon
|
||||
# http://docs.aws.amazon.com/general/latest/gr/samples
|
||||
# /aws4_testsuite.zip
|
||||
# Each *expected* hash value is the 4th line in <test-name>.sts in the
|
||||
# test suite.
|
||||
|
||||
# get-vanilla
|
||||
env = {
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com'}
|
||||
verify('366b91fb121d72a00f46bbe8d395f53a'
|
||||
'102b06dfb7e79636515208ed3fa606b1',
|
||||
'/', env)
|
||||
|
||||
# get-header-value-trim
|
||||
env = {
|
||||
'REQUEST_METHOD': 'POST',
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host;p, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com',
|
||||
'HTTP_P': 'phfft'}
|
||||
verify('dddd1902add08da1ac94782b05f9278c'
|
||||
'08dc7468db178a84f8950d93b30b1f35',
|
||||
'/', env)
|
||||
|
||||
# get-utf8 (not exact)
|
||||
env = {
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com',
|
||||
'RAW_PATH_INFO': '/%E1%88%B4'}
|
||||
|
||||
# This might look weird because actually S3 doesn't care about utf-8
|
||||
# encoded multi-byte bucket name from bucket-in-host name constraint.
|
||||
# However, aws4_testsuite has only a sample hash with utf-8 *bucket*
|
||||
# name to make sure the correctness (probably it can be used in other
|
||||
# aws resource except s3) so, to test also utf-8, skip the bucket name
|
||||
# validation in the following test.
|
||||
|
||||
# NOTE: eventlet's PATH_INFO is unquoted
|
||||
with patch('swift3.request.validate_bucket_name'):
|
||||
verify('27ba31df5dbc6e063d8f87d62eb07143'
|
||||
'f7f271c5330a917840586ac1c85b6f6b',
|
||||
unquote('/%E1%88%B4'), env)
|
||||
|
||||
# get-vanilla-query-order-key
|
||||
env = {
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com'}
|
||||
verify('2f23d14fe13caebf6dfda346285c6d9c'
|
||||
'14f49eaca8f5ec55c627dd7404f7a727',
|
||||
'/?a=foo&b=foo', env)
|
||||
|
||||
# post-header-value-case
|
||||
env = {
|
||||
'REQUEST_METHOD': 'POST',
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host;zoo, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com',
|
||||
'HTTP_ZOO': 'ZOOBAR'}
|
||||
verify('3aae6d8274b8c03e2cc96fc7d6bda4b9'
|
||||
'bd7a0a184309344470b2c96953e124aa',
|
||||
'/', env)
|
||||
|
||||
# post-x-www-form-urlencoded-parameters
|
||||
env = {
|
||||
'REQUEST_METHOD': 'POST',
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host;content-type, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com',
|
||||
'HTTP_X_AMZ_CONTENT_SHA256':
|
||||
'3ba8907e7a252327488df390ed517c45'
|
||||
'b96dead033600219bdca7107d1d3f88a',
|
||||
'CONTENT_TYPE':
|
||||
'application/x-www-form-urlencoded; charset=utf8'}
|
||||
verify('c4115f9e54b5cecf192b1eaa23b8e88e'
|
||||
'd8dc5391bd4fde7b3fff3d9c9fe0af1f',
|
||||
'/', env)
|
||||
|
||||
# post-x-www-form-urlencoded
|
||||
env = {
|
||||
'REQUEST_METHOD': 'POST',
|
||||
'HTTP_AUTHORIZATION': (
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, '
|
||||
'SignedHeaders=date;host;content-type, Signature=X'),
|
||||
'HTTP_HOST': 'host.foo.com',
|
||||
'HTTP_X_AMZ_CONTENT_SHA256':
|
||||
'3ba8907e7a252327488df390ed517c45'
|
||||
'b96dead033600219bdca7107d1d3f88a',
|
||||
'CONTENT_TYPE':
|
||||
'application/x-www-form-urlencoded'}
|
||||
verify('4c5c6e4b52fb5fb947a8733982a8a5a6'
|
||||
'1b14f04345cbfe6e739236c76dd48f74',
|
||||
'/', env)
|
||||
|
||||
def test_mixture_param_v4(self):
|
||||
# now we have an Authorization header
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test/20130524/US/s3/aws4_request_A, '
|
||||
'SignedHeaders=hostA;rangeA;x-amz-dateA,'
|
||||
'Signature=X',
|
||||
'X-Amz-Date': self.get_v4_amz_date_header(),
|
||||
'X-Amz-Content-SHA256': '0123456789'}
|
||||
|
||||
# and then, different auth info (Credential, SignedHeaders, Signature)
|
||||
# in query
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_requestB'
|
||||
'&X-Amz-SignedHeaders=hostB'
|
||||
'&X-Amz-Signature=Y',
|
||||
environ={'REQUEST_METHOD': 'GET'},
|
||||
headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
# FIXME: should this failed as 400 or pass via query auth?
|
||||
# for now, 403 forbbiden for safety
|
||||
self.assertEquals(status.split()[0], '403', body)
|
||||
|
||||
# But if we are missing Signature in query param
|
||||
req = Request.blank('/bucket/object'
|
||||
'?X-Amz-Algorithm=AWS4-HMAC-SHA256'
|
||||
'&X-Amz-Credential=test/20T20Z/US/s3/aws4_requestB'
|
||||
'&X-Amz-SignedHeaders=hostB',
|
||||
environ={'REQUEST_METHOD': 'GET'},
|
||||
headers=headers)
|
||||
req.content_type = 'text/plain'
|
||||
status, headers, body = self.call_swift3(req)
|
||||
self.assertEquals(status.split()[0], '403', body)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -29,7 +29,7 @@ from swift3.subresource import Owner, Grant, User, ACL, encode_acl, \
|
||||
decode_acl, ACLPublicRead
|
||||
from swift3.test.unit.test_s3_acl import s3acl
|
||||
from swift3.cfg import CONF
|
||||
from swift3.utils import sysmeta_header
|
||||
from swift3.utils import sysmeta_header, mktime, S3Timestamp
|
||||
from swift3.request import MAX_32BIT_INT
|
||||
|
||||
xml = '<CompleteMultipartUpload>' \
|
||||
@ -634,6 +634,21 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
||||
self.assertEquals(headers.get('X-Object-Meta-Foo'), 'bar')
|
||||
self.assertEquals(headers.get('Content-Type'), 'baz/quux')
|
||||
|
||||
def test_object_multipart_upload_complete_weird_host_name(self):
|
||||
# This happens via boto signature v4
|
||||
req = Request.blank('/bucket/object?uploadId=X',
|
||||
environ={'REQUEST_METHOD': 'POST',
|
||||
'HTTP_HOST': 'localhost:8080:8080'},
|
||||
headers={'Authorization': 'AWS test:tester:hmac',
|
||||
'Date': self.get_date_header(), },
|
||||
body=xml)
|
||||
status, headers, body = self.call_swift3(req)
|
||||
fromstring(body, 'CompleteMultipartUploadResult')
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
|
||||
_, _, headers = self.swift.calls_with_headers[-2]
|
||||
self.assertEquals(headers.get('X-Object-Meta-Foo'), 'bar')
|
||||
|
||||
def test_object_multipart_upload_complete_segment_too_small(self):
|
||||
msgs = [
|
||||
# pre-2.6.0 swift
|
||||
@ -1230,7 +1245,8 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
||||
|
||||
def _test_copy_for_s3acl(self, account, src_permission=None,
|
||||
src_path='/src_bucket/src_obj', src_headers=None,
|
||||
head_resp=swob.HTTPOk, put_header=None):
|
||||
head_resp=swob.HTTPOk, put_header=None,
|
||||
timestamp=None):
|
||||
owner = 'test:tester'
|
||||
grants = [Grant(User(account), src_permission)] \
|
||||
if src_permission else [Grant(User(owner), 'FULL_CONTROL')]
|
||||
@ -1248,14 +1264,18 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
||||
'/bucket/object?partNumber=1&uploadId=X',
|
||||
environ={'REQUEST_METHOD': 'PUT'},
|
||||
headers=put_headers)
|
||||
with patch('swift3.utils.time.time', return_value=1396353600.592270):
|
||||
timestamp = timestamp or time.time()
|
||||
with patch('swift3.utils.time.time', return_value=timestamp):
|
||||
return self.call_swift3(req)
|
||||
|
||||
@s3acl
|
||||
def test_upload_part_copy(self):
|
||||
last_modified = '2014-04-01T12:00:00.000Z'
|
||||
status, headers, body = \
|
||||
self._test_copy_for_s3acl('test:tester')
|
||||
date_header = self.get_date_header()
|
||||
timestamp = mktime(date_header)
|
||||
last_modified = S3Timestamp(timestamp).s3xmlformat
|
||||
status, headers, body = self._test_copy_for_s3acl(
|
||||
'test:tester', put_header={'Date': date_header},
|
||||
timestamp=timestamp)
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||
self.assertTrue(headers.get('etag') is None)
|
||||
|
@ -28,6 +28,7 @@ from swift3.test.unit import Swift3TestCase
|
||||
from swift3.test.unit.test_s3_acl import s3acl
|
||||
from swift3.subresource import ACL, User, encode_acl, Owner, Grant
|
||||
from swift3.etree import fromstring
|
||||
from swift3.utils import mktime, S3Timestamp
|
||||
from swift3.test.unit.helpers import FakeSwift
|
||||
|
||||
|
||||
@ -288,9 +289,9 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
|
||||
self._test_object_GETorHEAD('GET')
|
||||
_, _, headers = self.swift.calls_with_headers[-1]
|
||||
self.assertTrue('Authorization' not in headers)
|
||||
self.assertNotIn('Authorization', headers)
|
||||
_, _, headers = self.swift.calls_with_headers[0]
|
||||
self.assertTrue('Authorization' not in headers)
|
||||
self.assertNotIn('Authorization', headers)
|
||||
|
||||
@s3acl
|
||||
def test_object_GET_Range(self):
|
||||
@ -466,7 +467,7 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
self.assertEquals(headers['Content-Length'], '0')
|
||||
|
||||
def _test_object_PUT_copy(self, head_resp, put_header=None,
|
||||
src_path='/some/source'):
|
||||
src_path='/some/source', timestamp=None):
|
||||
account = 'test:tester'
|
||||
grants = [Grant(User(account), 'FULL_CONTROL')]
|
||||
head_headers = \
|
||||
@ -476,9 +477,10 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
self.swift.register('HEAD', '/v1/AUTH_test/some/source',
|
||||
head_resp, head_headers, None)
|
||||
put_header = put_header or {}
|
||||
return self._call_object_copy(src_path, put_header)
|
||||
return self._call_object_copy(src_path, put_header, timestamp)
|
||||
|
||||
def _test_object_PUT_copy_self(self, head_resp, put_header=None):
|
||||
def _test_object_PUT_copy_self(self, head_resp,
|
||||
put_header=None, timestamp=None):
|
||||
account = 'test:tester'
|
||||
grants = [Grant(User(account), 'FULL_CONTROL')]
|
||||
head_headers = \
|
||||
@ -488,9 +490,9 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
self.swift.register('HEAD', '/v1/AUTH_test/bucket/object',
|
||||
head_resp, head_headers, None)
|
||||
put_header = put_header or {}
|
||||
return self._call_object_copy('/bucket/object', put_header)
|
||||
return self._call_object_copy('/bucket/object', put_header, timestamp)
|
||||
|
||||
def _call_object_copy(self, src_path, put_header):
|
||||
def _call_object_copy(self, src_path, put_header, timestamp=None):
|
||||
put_headers = {'Authorization': 'AWS test:tester:hmac',
|
||||
'X-Amz-Copy-Source': src_path,
|
||||
'Date': self.get_date_header()}
|
||||
@ -502,13 +504,18 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
|
||||
req.date = datetime.now()
|
||||
req.content_type = 'text/plain'
|
||||
with patch('swift3.utils.time.time', return_value=1396353600.000000):
|
||||
timestamp = timestamp or time.time()
|
||||
with patch('swift3.utils.time.time', return_value=timestamp):
|
||||
return self.call_swift3(req)
|
||||
|
||||
@s3acl
|
||||
def test_object_PUT_copy(self):
|
||||
last_modified = '2014-04-01T12:00:00.000Z'
|
||||
status, headers, body = self._test_object_PUT_copy(swob.HTTPOk)
|
||||
date_header = self.get_date_header()
|
||||
timestamp = mktime(date_header)
|
||||
last_modified = S3Timestamp(timestamp).s3xmlformat
|
||||
status, headers, body = self._test_object_PUT_copy(
|
||||
swob.HTTPOk, put_header={'Date': date_header},
|
||||
timestamp=timestamp)
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||
self.assertTrue(headers.get('etag') is None)
|
||||
@ -523,11 +530,14 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
|
||||
@s3acl
|
||||
def test_object_PUT_copy_no_slash(self):
|
||||
last_modified = '2014-04-01T12:00:00.000Z'
|
||||
date_header = self.get_date_header()
|
||||
timestamp = mktime(date_header)
|
||||
last_modified = S3Timestamp(timestamp).s3xmlformat
|
||||
# Some clients (like Boto) don't include the leading slash;
|
||||
# AWS seems to tolerate this so we should, too
|
||||
status, headers, body = self._test_object_PUT_copy(
|
||||
swob.HTTPOk, src_path='some/source')
|
||||
swob.HTTPOk, src_path='some/source',
|
||||
put_header={'Date': date_header}, timestamp=timestamp)
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||
self.assertTrue(headers.get('etag') is None)
|
||||
@ -569,10 +579,13 @@ class TestSwift3Obj(Swift3TestCase):
|
||||
|
||||
@s3acl
|
||||
def test_object_PUT_copy_self_metadata_replace(self):
|
||||
last_modified = '2014-04-01T12:00:00.000Z'
|
||||
header = {'x-amz-metadata-directive': 'REPLACE'}
|
||||
status, headers, body = \
|
||||
self._test_object_PUT_copy_self(swob.HTTPOk, header)
|
||||
date_header = self.get_date_header()
|
||||
timestamp = mktime(date_header)
|
||||
last_modified = S3Timestamp(timestamp).s3xmlformat
|
||||
header = {'x-amz-metadata-directive': 'REPLACE',
|
||||
'Date': date_header}
|
||||
status, headers, body = self._test_object_PUT_copy_self(
|
||||
swob.HTTPOk, header, timestamp=timestamp)
|
||||
self.assertEquals(status.split()[0], '200')
|
||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||
self.assertTrue(headers.get('etag') is None)
|
||||
|
@ -20,12 +20,14 @@ import unittest
|
||||
from swift.common import swob
|
||||
from swift.common.swob import Request, HTTPNoContent
|
||||
|
||||
from swift3.utils import mktime
|
||||
from swift3.subresource import ACL, User, Owner, Grant, encode_acl
|
||||
from swift3.test.unit.test_middleware import Swift3TestCase
|
||||
from swift3.cfg import CONF
|
||||
from swift3.request import Request as S3_Request
|
||||
from swift3.request import S3AclRequest
|
||||
from swift3.response import InvalidArgument, NoSuchBucket, InternalError
|
||||
from swift3.request import S3AclRequest, SigV4Request, SIGV4_X_AMZ_DATE_FORMAT
|
||||
from swift3.response import InvalidArgument, NoSuchBucket, InternalError, \
|
||||
AccessDenied, SignatureDoesNotMatch
|
||||
|
||||
|
||||
Fake_ACL_MAP = {
|
||||
@ -249,8 +251,8 @@ class TestRequest(Swift3TestCase):
|
||||
m_swift_resp.return_value = FakeSwiftResponse()
|
||||
s3_req = S3AclRequest(req.environ, MagicMock())
|
||||
sw_req = s3_req.to_swift_req(method, container, obj)
|
||||
self.assertTrue('HTTP_AUTHORIZATION' not in sw_req.environ)
|
||||
self.assertTrue('Authorization' not in sw_req.headers)
|
||||
self.assertNotIn('HTTP_AUTHORIZATION', sw_req.environ)
|
||||
self.assertNotIn('Authorization', sw_req.headers)
|
||||
self.assertEquals(sw_req.headers['X-Auth-Token'], 'token')
|
||||
|
||||
def test_to_swift_req_subrequest_proxy_access_log(self):
|
||||
@ -371,5 +373,180 @@ class TestRequest(Swift3TestCase):
|
||||
self.assertEquals(status.split()[0], '403')
|
||||
self.assertEquals(body, '')
|
||||
|
||||
def _test_request_timestamp_sigv4(self, date_header):
|
||||
# signature v4 here
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
|
||||
if 'X-Amz-Date' in date_header:
|
||||
included_header = 'x-amz-date'
|
||||
elif 'Date' in date_header:
|
||||
included_header = 'date'
|
||||
else:
|
||||
self.fail('Invalid date header specified as test')
|
||||
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;%s,'
|
||||
'Signature=X' % included_header,
|
||||
'X-Amz-Content-SHA256': '0123456789'}
|
||||
|
||||
headers.update(date_header)
|
||||
req = Request.blank('/', environ=environ, headers=headers)
|
||||
sigv4_req = SigV4Request(req.environ)
|
||||
|
||||
if 'X-Amz-Date' in date_header:
|
||||
timestamp = mktime(
|
||||
date_header['X-Amz-Date'], SIGV4_X_AMZ_DATE_FORMAT)
|
||||
elif 'Date' in date_header:
|
||||
timestamp = mktime(date_header['Date'])
|
||||
|
||||
self.assertEqual(timestamp, int(sigv4_req.timestamp))
|
||||
|
||||
def test_request_timestamp_sigv4(self):
|
||||
access_denied_message = \
|
||||
'AWS authentication requires a valid Date or x-amz-date header'
|
||||
|
||||
# normal X-Amz-Date header
|
||||
date_header = {'X-Amz-Date': self.get_v4_amz_date_header()}
|
||||
self._test_request_timestamp_sigv4(date_header)
|
||||
|
||||
# normal Date header
|
||||
date_header = {'Date': self.get_date_header()}
|
||||
self._test_request_timestamp_sigv4(date_header)
|
||||
|
||||
# mangled X-Amz-Date header
|
||||
date_header = {'X-Amz-Date': self.get_v4_amz_date_header()[:-1]}
|
||||
with self.assertRaises(AccessDenied) as cm:
|
||||
self._test_request_timestamp_sigv4(date_header)
|
||||
|
||||
self.assertEqual('403 Forbidden', cm.exception.message)
|
||||
self.assertIn(access_denied_message, cm.exception.body)
|
||||
|
||||
# mangled Date header
|
||||
date_header = {'Date': self.get_date_header()[20:]}
|
||||
with self.assertRaises(AccessDenied) as cm:
|
||||
self._test_request_timestamp_sigv4(date_header)
|
||||
|
||||
self.assertEqual('403 Forbidden', cm.exception.message)
|
||||
self.assertIn(access_denied_message, cm.exception.body)
|
||||
|
||||
# Negative timestamp
|
||||
date_header = {'X-Amz-Date': '00160523T054055Z'}
|
||||
with self.assertRaises(AccessDenied) as cm:
|
||||
self._test_request_timestamp_sigv4(date_header)
|
||||
|
||||
self.assertEqual('403 Forbidden', cm.exception.message)
|
||||
|
||||
def _test_request_timestamp_sigv2(self, date_header):
|
||||
# signature v4 here
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
|
||||
headers = {'Authorization': 'AWS test:tester:hmac'}
|
||||
headers.update(date_header)
|
||||
req = Request.blank('/', environ=environ, headers=headers)
|
||||
sigv2_req = S3_Request(req.environ)
|
||||
|
||||
if 'X-Amz-Date' in date_header:
|
||||
timestamp = mktime(req.headers.get('X-Amz-Date'))
|
||||
elif 'Date' in date_header:
|
||||
timestamp = mktime(req.headers.get('Date'))
|
||||
else:
|
||||
self.fail('Invalid date header specified as test')
|
||||
self.assertEqual(timestamp, int(sigv2_req.timestamp))
|
||||
|
||||
def test_request_timestamp_sigv2(self):
|
||||
access_denied_message = \
|
||||
'AWS authentication requires a valid Date or x-amz-date header'
|
||||
|
||||
# In v2 format, normal X-Amz-Date header is same
|
||||
date_header = {'X-Amz-Date': self.get_date_header()}
|
||||
self._test_request_timestamp_sigv2(date_header)
|
||||
|
||||
# normal Date header
|
||||
date_header = {'Date': self.get_date_header()}
|
||||
self._test_request_timestamp_sigv2(date_header)
|
||||
|
||||
# mangled X-Amz-Date header
|
||||
date_header = {'X-Amz-Date': self.get_date_header()[:-20]}
|
||||
with self.assertRaises(AccessDenied) as cm:
|
||||
self._test_request_timestamp_sigv2(date_header)
|
||||
|
||||
self.assertEqual('403 Forbidden', cm.exception.message)
|
||||
self.assertIn(access_denied_message, cm.exception.body)
|
||||
|
||||
# mangled Date header
|
||||
date_header = {'Date': self.get_date_header()[:-20]}
|
||||
with self.assertRaises(AccessDenied) as cm:
|
||||
self._test_request_timestamp_sigv2(date_header)
|
||||
|
||||
self.assertEqual('403 Forbidden', cm.exception.message)
|
||||
self.assertIn(access_denied_message, cm.exception.body)
|
||||
|
||||
def test_headers_to_sign_sigv4(self):
|
||||
environ = {
|
||||
'REQUEST_METHOD': 'GET'}
|
||||
|
||||
# host and x-amz-date
|
||||
x_amz_date = self.get_v4_amz_date_header()
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
|
||||
'Signature=X',
|
||||
'X-Amz-Content-SHA256': '0123456789',
|
||||
'Date': self.get_date_header(),
|
||||
'X-Amz-Date': x_amz_date}
|
||||
|
||||
req = Request.blank('/', environ=environ, headers=headers)
|
||||
sigv4_req = SigV4Request(req.environ)
|
||||
|
||||
headers_to_sign = sigv4_req._headers_to_sign()
|
||||
self.assertEqual(['host', 'x-amz-content-sha256', 'x-amz-date'],
|
||||
sorted(headers_to_sign.keys()))
|
||||
self.assertEqual(headers_to_sign['host'], 'localhost:80')
|
||||
self.assertEqual(headers_to_sign['x-amz-date'], x_amz_date)
|
||||
self.assertEqual(headers_to_sign['x-amz-content-sha256'], '0123456789')
|
||||
|
||||
# no x-amz-date
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-content-sha256,'
|
||||
'Signature=X',
|
||||
'X-Amz-Content-SHA256': '0123456789',
|
||||
'Date': self.get_date_header()}
|
||||
|
||||
req = Request.blank('/', environ=environ, headers=headers)
|
||||
sigv4_req = SigV4Request(req.environ)
|
||||
|
||||
headers_to_sign = sigv4_req._headers_to_sign()
|
||||
self.assertEqual(['host', 'x-amz-content-sha256'],
|
||||
sorted(headers_to_sign.keys()))
|
||||
self.assertEqual(headers_to_sign['host'], 'localhost:80')
|
||||
self.assertEqual(headers_to_sign['x-amz-content-sha256'], '0123456789')
|
||||
|
||||
# SignedHeaders says, host and x-amz-date included but there is not
|
||||
# X-Amz-Date header
|
||||
headers = {
|
||||
'Authorization':
|
||||
'AWS4-HMAC-SHA256 '
|
||||
'Credential=test/20130524/US/s3/aws4_request, '
|
||||
'SignedHeaders=host;x-amz-content-sha256;x-amz-date,'
|
||||
'Signature=X',
|
||||
'X-Amz-Content-SHA256': '0123456789',
|
||||
'Date': self.get_date_header()}
|
||||
|
||||
req = Request.blank('/', environ=environ, headers=headers)
|
||||
with self.assertRaises(SignatureDoesNotMatch):
|
||||
sigv4_req = SigV4Request(req.environ)
|
||||
sigv4_req._headers_to_sign()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -18,8 +18,8 @@ import uuid
|
||||
import base64
|
||||
import time
|
||||
|
||||
|
||||
from swift.common.utils import get_logger
|
||||
import email.utils
|
||||
|
||||
# Need for check_path_header
|
||||
from swift.common import utils
|
||||
@ -143,6 +143,41 @@ class S3Timestamp(utils.Timestamp):
|
||||
def s3xmlformat(self):
|
||||
return self.isoformat[:-7] + '.000Z'
|
||||
|
||||
@property
|
||||
def amz_date_format(self):
|
||||
"""
|
||||
this format should be like 'YYYYMMDDThhmmssZ'
|
||||
"""
|
||||
return self.isoformat.replace(
|
||||
'-', '').replace(':', '')[:-7] + 'Z'
|
||||
|
||||
@classmethod
|
||||
def now(cls):
|
||||
return cls(time.time())
|
||||
|
||||
|
||||
def mktime(timestamp_str, time_format='%Y-%m-%dT%H:%M:%S'):
|
||||
"""
|
||||
mktime creates a float instance in epoch time really like as time.mktime
|
||||
|
||||
the difference from time.mktime is allowing to 2 formats string for the
|
||||
argumtent for the S3 testing usage.
|
||||
TODO: support
|
||||
|
||||
:param timestamp_str: a string of timestamp formatted as
|
||||
(a) RFC2822 (e.g. date header)
|
||||
(b) %Y-%m-%dT%H:%M:%S (e.g. copy result)
|
||||
:param time_format: a string of format to parase in (b) process
|
||||
:return : a float instance in epoch time
|
||||
"""
|
||||
try:
|
||||
epoch_time = email.utils.mktime_tz(
|
||||
email.utils.parsedate_tz(timestamp_str))
|
||||
except TypeError:
|
||||
time_tuple = time.strptime(timestamp_str, time_format)
|
||||
|
||||
# add timezone info as utc (no time difference)
|
||||
time_tuple += (0, )
|
||||
epoch_time = email.utils.mktime_tz(time_tuple)
|
||||
|
||||
return epoch_time
|
||||
|
Loading…
x
Reference in New Issue
Block a user