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
|
# allow_no_owner = false
|
||||||
#
|
#
|
||||||
# Set a region name of your Swift cluster. Note that Swift3 doesn't choose a
|
# 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
|
# region of the newly created bucket actually. This value is used for the
|
||||||
# GET Bucket location API.
|
# GET Bucket location API and v4 signatures calculation.
|
||||||
# location = US
|
# location = US
|
||||||
#
|
#
|
||||||
# Set whether to enforce DNS-compliant bucket names. Note that S3 enforces
|
# 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.utils import json
|
||||||
from swift.common.db import utf8encode
|
from swift.common.db import utf8encode
|
||||||
|
|
||||||
|
from six.moves.urllib.parse import urlparse # pylint: disable=F0401
|
||||||
|
|
||||||
from swift3.controllers.base import Controller, bucket_operation, \
|
from swift3.controllers.base import Controller, bucket_operation, \
|
||||||
object_operation, check_container_existence
|
object_operation, check_container_existence
|
||||||
from swift3.response import InvalidArgument, ErrorResponse, MalformedXML, \
|
from swift3.response import InvalidArgument, ErrorResponse, MalformedXML, \
|
||||||
@ -604,7 +606,20 @@ class UploadController(Controller):
|
|||||||
req.get_response(self.app, 'DELETE', container, obj)
|
req.get_response(self.app, 'DELETE', container, obj)
|
||||||
|
|
||||||
result_elem = Element('CompleteMultipartUploadResult')
|
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, 'Bucket').text = req.container_name
|
||||||
SubElement(result_elem, 'Key').text = req.object_name
|
SubElement(result_elem, 'Key').text = req.object_name
|
||||||
SubElement(result_elem, 'ETag').text = resp.etag
|
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 import __version__ as swift3_version
|
||||||
from swift3.exception import NotS3Request
|
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, \
|
from swift3.response import ErrorResponse, InternalError, MethodNotAllowed, \
|
||||||
ResponseBase
|
ResponseBase
|
||||||
from swift3.cfg import CONF
|
from swift3.cfg import CONF
|
||||||
@ -75,10 +75,8 @@ class Swift3Middleware(object):
|
|||||||
|
|
||||||
def __call__(self, env, start_response):
|
def __call__(self, env, start_response):
|
||||||
try:
|
try:
|
||||||
if CONF.s3_acl:
|
req_class = get_request_class(env)
|
||||||
req = S3AclRequest(env, self.app, self.slo_enabled)
|
req = req_class(env, self.app, self.slo_enabled)
|
||||||
else:
|
|
||||||
req = Request(env, self.slo_enabled)
|
|
||||||
resp = self.handle_request(req)
|
resp = self.handle_request(req)
|
||||||
except NotS3Request:
|
except NotS3Request:
|
||||||
resp = self.app
|
resp = self.app
|
||||||
|
@ -13,14 +13,14 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import re
|
|
||||||
import md5
|
|
||||||
from urllib import quote, unquote
|
|
||||||
import base64
|
import base64
|
||||||
import email.utils
|
|
||||||
from email.header import Header
|
from email.header import Header
|
||||||
import datetime
|
from hashlib import sha256
|
||||||
|
import md5
|
||||||
|
import re
|
||||||
|
import six
|
||||||
import string
|
import string
|
||||||
|
from urllib import quote, unquote
|
||||||
|
|
||||||
from swift.common.utils import split_path
|
from swift.common.utils import split_path
|
||||||
from swift.common import swob
|
from swift.common import swob
|
||||||
@ -45,15 +45,18 @@ from swift3.response import AccessDenied, InvalidArgument, InvalidDigest, \
|
|||||||
BucketAlreadyExists, BucketNotEmpty, EntityTooLarge, \
|
BucketAlreadyExists, BucketNotEmpty, EntityTooLarge, \
|
||||||
InternalError, NoSuchBucket, NoSuchKey, PreconditionFailed, InvalidRange, \
|
InternalError, NoSuchBucket, NoSuchKey, PreconditionFailed, InvalidRange, \
|
||||||
MissingContentLength, InvalidStorageClass, S3NotImplemented, InvalidURI, \
|
MissingContentLength, InvalidStorageClass, S3NotImplemented, InvalidURI, \
|
||||||
MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, BadDigest
|
MalformedXML, InvalidRequest, RequestTimeout, InvalidBucketName, \
|
||||||
|
BadDigest, AuthorizationHeaderMalformed
|
||||||
from swift3.exception import NotS3Request, BadSwiftRequest
|
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.cfg import CONF
|
||||||
from swift3.subresource import decode_acl, encode_acl
|
from swift3.subresource import decode_acl, encode_acl
|
||||||
from swift3.utils import sysmeta_header, validate_bucket_name
|
from swift3.utils import sysmeta_header, validate_bucket_name
|
||||||
from swift3.acl_utils import handle_acl_header
|
from swift3.acl_utils import handle_acl_header
|
||||||
from swift3.acl_handlers import get_acl_handler
|
from swift3.acl_handlers import get_acl_handler
|
||||||
|
|
||||||
|
|
||||||
# List of sub-resources that must be maintained as part of the HMAC
|
# List of sub-resources that must be maintained as part of the HMAC
|
||||||
# signature string.
|
# signature string.
|
||||||
ALLOWED_SUB_RESOURCES = sorted([
|
ALLOWED_SUB_RESOURCES = sorted([
|
||||||
@ -65,7 +68,10 @@ ALLOWED_SUB_RESOURCES = sorted([
|
|||||||
'response-content-type', 'response-expires', 'cors', 'tagging', 'restore'
|
'response-content-type', 'response-expires', 'cors', 'tagging', 'restore'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
MAX_32BIT_INT = 2147483647
|
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):
|
def _header_acl_property(resource):
|
||||||
@ -86,6 +92,243 @@ def _header_acl_property(resource):
|
|||||||
doc='Get and set the %s 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):
|
class Request(swob.Request):
|
||||||
"""
|
"""
|
||||||
S3 request object.
|
S3 request object.
|
||||||
@ -94,22 +337,70 @@ class Request(swob.Request):
|
|||||||
bucket_acl = _header_acl_property('container')
|
bucket_acl = _header_acl_property('container')
|
||||||
object_acl = _header_acl_property('object')
|
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)
|
swob.Request.__init__(self, env)
|
||||||
|
self._timestamp = None
|
||||||
self.access_key = self._parse_authorization()
|
self.access_key, signature = self._parse_auth_info()
|
||||||
self.bucket_in_host = self._parse_host()
|
self.bucket_in_host = self._parse_host()
|
||||||
self.container_name, self.object_name = self._parse_uri()
|
self.container_name, self.object_name = self._parse_uri()
|
||||||
self._validate_headers()
|
self._validate_headers()
|
||||||
self.token = base64.urlsafe_b64encode(self._canonical_string())
|
self.token = base64.urlsafe_b64encode(self._string_to_sign())
|
||||||
self.account = None
|
self.account = None
|
||||||
self.user_id = None
|
self.user_id = None
|
||||||
self.slo_enabled = slo_enabled
|
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
|
# Avoids that swift.swob.Response replaces Location header value
|
||||||
# by full URL when absolute path given. See swift.swob for more detail.
|
# by full URL when absolute path given. See swift.swob for more detail.
|
||||||
self.environ['swift.leave_relative_location'] = True
|
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):
|
def _parse_host(self):
|
||||||
storage_domain = CONF.storage_domain
|
storage_domain = CONF.storage_domain
|
||||||
if not storage_domain:
|
if not storage_domain:
|
||||||
@ -148,35 +439,94 @@ class Request(swob.Request):
|
|||||||
raise InvalidBucketName(bucket)
|
raise InvalidBucketName(bucket)
|
||||||
return (bucket, obj)
|
return (bucket, obj)
|
||||||
|
|
||||||
def _parse_authorization(self):
|
def _parse_query_authentication(self):
|
||||||
if 'AWSAccessKeyId' in self.params:
|
"""
|
||||||
try:
|
Parse v2 authentication query args
|
||||||
self.headers['Date'] = self.params['Expires']
|
TODO: make sure if 0, 1, 3 is supported?
|
||||||
self.headers['Authorization'] = \
|
- version 0, 1, 2, 3:
|
||||||
'AWS %(AWSAccessKeyId)s:%(Signature)s' % self.params
|
'AWSAccessKeyId' and 'Signature' should be in param
|
||||||
except KeyError:
|
|
||||||
raise AccessDenied()
|
|
||||||
|
|
||||||
if 'Authorization' not in self.headers:
|
|
||||||
raise NotS3Request()
|
|
||||||
|
|
||||||
|
:return: a tuple of access_key and signature
|
||||||
|
:raises: AccessDenied
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
keyword, info = self.headers['Authorization'].split(' ', 1)
|
access = self.params['AWSAccessKeyId']
|
||||||
except Exception:
|
expires = self.params['Expires']
|
||||||
|
# TODO: can we remove this logic here?
|
||||||
|
# self.headers['Date'] = expires
|
||||||
|
sig = self.params['Signature']
|
||||||
|
except KeyError:
|
||||||
raise AccessDenied()
|
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()
|
raise NotS3Request()
|
||||||
|
|
||||||
|
def _validate_expire_param(self):
|
||||||
|
"""
|
||||||
|
Validate Expire param
|
||||||
|
"""
|
||||||
|
# Expires header is a float since epoch
|
||||||
try:
|
try:
|
||||||
access_key = info.rsplit(':', 1)[0]
|
ex = S3Timestamp(float(self.params['Expires']))
|
||||||
except Exception:
|
except ValueError:
|
||||||
err_msg = 'AWS authorization header is invalid. ' \
|
raise AccessDenied()
|
||||||
'Expected AwsAccessKeyId:signature'
|
|
||||||
raise InvalidArgument('Authorization',
|
|
||||||
self.headers['Authorization'], err_msg)
|
|
||||||
|
|
||||||
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):
|
def _validate_headers(self):
|
||||||
if 'CONTENT_LENGTH' in self.environ:
|
if 'CONTENT_LENGTH' in self.environ:
|
||||||
@ -188,40 +538,7 @@ class Request(swob.Request):
|
|||||||
raise InvalidArgument('Content-Length',
|
raise InvalidArgument('Content-Length',
|
||||||
self.environ['CONTENT_LENGTH'])
|
self.environ['CONTENT_LENGTH'])
|
||||||
|
|
||||||
date_header = self.headers.get('x-amz-date',
|
self._validate_dates()
|
||||||
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')
|
|
||||||
|
|
||||||
if 'Content-MD5' in self.headers:
|
if 'Content-MD5' in self.headers:
|
||||||
value = self.headers['Content-MD5']
|
value = self.headers['Content-MD5']
|
||||||
@ -368,9 +685,9 @@ class Request(swob.Request):
|
|||||||
raw_path_info = '/' + self.bucket_in_host + raw_path_info
|
raw_path_info = '/' + self.bucket_in_host + raw_path_info
|
||||||
return 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 = {}
|
amz_headers = {}
|
||||||
|
|
||||||
@ -863,3 +1180,11 @@ class S3AclRequest(Request):
|
|||||||
return resp
|
return resp
|
||||||
return self.get_acl_response(app, method, container, obj,
|
return self.get_acl_response(app, method, container, obj,
|
||||||
headers, body, query)
|
headers, body, query)
|
||||||
|
|
||||||
|
|
||||||
|
class SigV4Request(SigV4Mixin, Request):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SigV4S3AclRequest(SigV4Mixin, S3AclRequest):
|
||||||
|
pass
|
||||||
|
@ -223,6 +223,13 @@ class AmbiguousGrantByEmailAddress(ErrorResponse):
|
|||||||
'one account.'
|
'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):
|
class BadDigest(ErrorResponse):
|
||||||
_status = '400 Bad Request'
|
_status = '400 Bad Request'
|
||||||
_msg = 'The Content-MD5 you specified did not match what we received.'
|
_msg = 'The Content-MD5 you specified did not match what we received.'
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
|
debug=true
|
||||||
|
verbose=true
|
||||||
|
policy_file=%CONF_DIR%/policy.json
|
||||||
admin_token = ADMIN
|
admin_token = ADMIN
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
|
@ -23,6 +23,7 @@ user_test_tester2 = testing2
|
|||||||
[filter:swift3]
|
[filter:swift3]
|
||||||
use = egg:swift3#swift3
|
use = egg:swift3#swift3
|
||||||
s3_acl = %S3ACL%
|
s3_acl = %S3ACL%
|
||||||
|
location = US
|
||||||
|
|
||||||
[filter:catch_errors]
|
[filter:catch_errors]
|
||||||
use = egg:swift#catch_errors
|
use = egg:swift#catch_errors
|
||||||
|
@ -112,7 +112,8 @@ nosetests -v ./
|
|||||||
rvalue=$?
|
rvalue=$?
|
||||||
|
|
||||||
# cleanup
|
# 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
|
# show report
|
||||||
sleep 3
|
sleep 3
|
||||||
|
@ -51,6 +51,7 @@ class Connection(object):
|
|||||||
S3Connection(aws_access_key, aws_secret_key, is_secure=False,
|
S3Connection(aws_access_key, aws_secret_key, is_secure=False,
|
||||||
host=self.host, port=self.port,
|
host=self.host, port=self.port,
|
||||||
calling_format=OrdinaryCallingFormat())
|
calling_format=OrdinaryCallingFormat())
|
||||||
|
self.conn.auth_region_name = 'US'
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""
|
"""
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
from swift3.test.functional import Swift3FunctionalTestCase
|
from swift3.test.functional import Swift3FunctionalTestCase
|
||||||
from swift3.test.functional.s3_test_client import Connection, \
|
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.conn2.make_request('GET', self.bucket, self.obj, query='acl')
|
||||||
self.assertEquals(get_error_code(body), 'AccessDenied')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
from swift3.test.functional.s3_test_client import Connection
|
from swift3.test.functional.s3_test_client import Connection
|
||||||
from swift3.test.functional.utils import get_error_code
|
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')
|
status, headers, body = self.conn.make_request('DELETE', 'bucket')
|
||||||
self.assertEquals(get_error_code(body), 'NoSuchBucket')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -13,6 +13,9 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
from swift3.test.functional.utils import calculate_md5, get_error_code
|
from swift3.test.functional.utils import calculate_md5, get_error_code
|
||||||
from swift3.etree import fromstring, tostring, Element, SubElement
|
from swift3.etree import fromstring, tostring, Element, SubElement
|
||||||
from swift3.controllers.multi_delete import MAX_MULTI_DELETE_BODY_SIZE
|
from swift3.controllers.multi_delete import MAX_MULTI_DELETE_BODY_SIZE
|
||||||
@ -215,3 +218,19 @@ class TestSwift3MultiDelete(Swift3FunctionalTestCase):
|
|||||||
elem = fromstring(body, 'DeleteResult')
|
elem = fromstring(body, 'DeleteResult')
|
||||||
resp_objects = elem.findall('Deleted')
|
resp_objects = elem.findall('Deleted')
|
||||||
self.assertEquals(len(resp_objects), 1)
|
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.
|
# limitations under the License.
|
||||||
|
|
||||||
import unittest
|
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 hashlib import md5
|
||||||
from itertools import izip
|
from itertools import izip
|
||||||
|
|
||||||
from swift3.test.functional.utils import get_error_code, get_error_msg
|
from swift3.test.functional.utils import get_error_code, get_error_msg
|
||||||
from swift3.etree import fromstring, tostring, Element, SubElement
|
from swift3.etree import fromstring, tostring, Element, SubElement
|
||||||
from swift3.test.functional import Swift3FunctionalTestCase
|
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
|
from swift3.test.functional.s3_test_client import Connection
|
||||||
|
|
||||||
MIN_SEGMENT_SIZE = 5242880
|
MIN_SEGMENT_SIZE = 5242880
|
||||||
@ -182,39 +189,8 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
|||||||
self.assertTrue('etag' not in headers)
|
self.assertTrue('etag' not in headers)
|
||||||
elem = fromstring(body, 'CopyPartResult')
|
elem = fromstring(body, 'CopyPartResult')
|
||||||
|
|
||||||
last_modified_1 = elem.find('LastModified').text
|
last_modified = elem.find('LastModified').text
|
||||||
self.assertTrue(last_modified_1 is not None)
|
self.assertTrue(last_modified 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)
|
|
||||||
|
|
||||||
self.assertEquals(resp_etag, etag)
|
self.assertEquals(resp_etag, etag)
|
||||||
|
|
||||||
@ -231,14 +207,10 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
|||||||
for p in elem.iterfind('Part')]
|
for p in elem.iterfind('Part')]
|
||||||
self.assertEquals(
|
self.assertEquals(
|
||||||
last_modified_gets[0].rsplit('.', 1)[0],
|
last_modified_gets[0].rsplit('.', 1)[0],
|
||||||
last_modified_1.rsplit('.', 1)[0],
|
last_modified.rsplit('.', 1)[0],
|
||||||
'%r != %r' % (last_modified_gets[0], last_modified_1))
|
'%r != %r' % (last_modified_gets[0], last_modified))
|
||||||
self.assertEquals(
|
|
||||||
last_modified_gets[1].rsplit('.', 1)[0],
|
|
||||||
last_modified_2.rsplit('.', 1)[0],
|
|
||||||
'%r != %r' % (last_modified_gets[1], last_modified_2))
|
|
||||||
# There should be *exactly* two parts in the result
|
# There should be *exactly* two parts in the result
|
||||||
self.assertEqual([], last_modified_gets[2:])
|
self.assertEqual(1, len(last_modified_gets))
|
||||||
|
|
||||||
# List Parts
|
# List Parts
|
||||||
key, upload_id = uploads[0]
|
key, upload_id = uploads[0]
|
||||||
@ -573,6 +545,114 @@ class TestSwift3MultiUpload(Swift3FunctionalTestCase):
|
|||||||
query=query)
|
query=query)
|
||||||
self.assertEquals(status, 200)
|
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):
|
def test_delete_bucket_multi_upload_object_exisiting(self):
|
||||||
bucket = 'bucket'
|
bucket = 'bucket'
|
||||||
keys = ['obj1']
|
keys = ['obj1']
|
||||||
|
@ -14,6 +14,12 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import unittest
|
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 email.utils import formatdate, parsedate
|
||||||
from time import mktime
|
from time import mktime
|
||||||
@ -768,5 +774,47 @@ class TestSwift3Object(Swift3FunctionalTestCase):
|
|||||||
self.assertEquals(status, 200)
|
self.assertEquals(status, 200)
|
||||||
self.assertCommonResponseHeaders(headers)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
import os
|
||||||
|
|
||||||
from swift3.test.functional.s3_test_client import Connection
|
from swift3.test.functional.s3_test_client import Connection
|
||||||
from swift3.test.functional.utils import get_error_code
|
from swift3.test.functional.utils import get_error_code
|
||||||
@ -71,5 +72,18 @@ class TestSwift3Service(Swift3FunctionalTestCase):
|
|||||||
self.assertIn('AWS authentication requires a valid Date '
|
self.assertIn('AWS authentication requires a valid Date '
|
||||||
'or x-amz-date header', body)
|
'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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -16,9 +16,6 @@
|
|||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from swift3.etree import fromstring
|
from swift3.etree import fromstring
|
||||||
|
|
||||||
import time
|
|
||||||
from email.utils import parsedate
|
|
||||||
|
|
||||||
|
|
||||||
def get_error_code(body):
|
def get_error_code(body):
|
||||||
elem = fromstring(body, 'Error')
|
elem = fromstring(body, 'Error')
|
||||||
@ -32,25 +29,3 @@ def get_error_msg(body):
|
|||||||
|
|
||||||
def calculate_md5(body):
|
def calculate_md5(body):
|
||||||
return md5(body).digest().encode('base64').strip()
|
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
|
For S3 requests, Swift auth middleware replaces a user name in
|
||||||
env['PATH_INFO'] with a valid tenant id.
|
env['PATH_INFO'] with a valid tenant id.
|
||||||
E.g. '/v1/test:tester/bucket/object' will become
|
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(' ')
|
_, authorization = env['HTTP_AUTHORIZATION'].split(' ')
|
||||||
tenant_user, sign = authorization.rsplit(':', 1)
|
tenant_user, sign = authorization.rsplit(':', 1)
|
||||||
tenant, user = tenant_user.rsplit(':', 1)
|
tenant, user = tenant_user.rsplit(':', 1)
|
||||||
|
|
||||||
path = env['PATH_INFO']
|
path = env['PATH_INFO']
|
||||||
|
|
||||||
env['PATH_INFO'] = path.replace(tenant_user, 'AUTH_' + tenant)
|
env['PATH_INFO'] = path.replace(tenant_user, 'AUTH_' + tenant)
|
||||||
|
|
||||||
def __call__(self, env, start_response):
|
def __call__(self, env, start_response):
|
||||||
@ -84,6 +83,10 @@ class Swift3TestCase(unittest.TestCase):
|
|||||||
elem = fromstring(body, 'Error')
|
elem = fromstring(body, 'Error')
|
||||||
return elem.find('./Code').text
|
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={}):
|
def _test_method_error(self, method, path, response_class, headers={}):
|
||||||
if not path.startswith('/'):
|
if not path.startswith('/'):
|
||||||
path = '/' + path # add a missing slash before the path
|
path = '/' + path # add a missing slash before the path
|
||||||
@ -101,7 +104,11 @@ class Swift3TestCase(unittest.TestCase):
|
|||||||
return self._get_error_code(body)
|
return self._get_error_code(body)
|
||||||
|
|
||||||
def get_date_header(self):
|
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):
|
def call_app(self, req, app=None, expect_exception=False):
|
||||||
if app is None:
|
if app is None:
|
||||||
|
@ -27,7 +27,7 @@ from swift.common.swob import Request
|
|||||||
|
|
||||||
import swift3
|
import swift3
|
||||||
from swift3.test.unit import Swift3TestCase
|
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.etree import fromstring
|
||||||
from swift3.middleware import filter_factory
|
from swift3.middleware import filter_factory
|
||||||
from swift3.cfg import CONF
|
from swift3.cfg import CONF
|
||||||
@ -74,7 +74,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
self.assertEquals(path_info, unquote(raw_path_info))
|
self.assertEquals(path_info, unquote(raw_path_info))
|
||||||
self.assertEquals(req.path, quote(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
|
The hashes here were generated by running the same requests against
|
||||||
boto.utils.canonical_string
|
boto.utils.canonical_string
|
||||||
@ -93,7 +93,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
'HTTP_AUTHORIZATION': 'AWS X:Y:Z',
|
'HTTP_AUTHORIZATION': 'AWS X:Y:Z',
|
||||||
})
|
})
|
||||||
req.headers.update(headers)
|
req.headers.update(headers)
|
||||||
return req._canonical_string()
|
return req._string_to_sign()
|
||||||
|
|
||||||
def verify(hash, path, headers):
|
def verify(hash, path, headers):
|
||||||
s = canonical_string(path, headers)
|
s = canonical_string(path, headers)
|
||||||
@ -170,17 +170,30 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
# Set expire to last 32b timestamp value
|
# Set expire to last 32b timestamp value
|
||||||
# This number can't be higher, because it breaks tests on 32b systems
|
# This number can't be higher, because it breaks tests on 32b systems
|
||||||
expire = '2147483647' # 19 Jan 2038 03:14:07
|
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||||
|
utc_date = datetime.utcnow()
|
||||||
req = Request.blank('/bucket/object?Signature=X&Expires=%s&'
|
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'},
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
headers={'Date': self.get_date_header()})
|
headers={'Date': self.get_date_header()})
|
||||||
req.headers['Date'] = datetime.utcnow()
|
|
||||||
req.content_type = 'text/plain'
|
req.content_type = 'text/plain'
|
||||||
status, headers, body = self.call_swift3(req)
|
status, headers, body = self.call_swift3(req)
|
||||||
self.assertEquals(status.split()[0], '200')
|
self.assertEquals(status.split()[0], '200')
|
||||||
for _, _, headers in self.swift.calls_with_headers:
|
for _, _, headers in self.swift.calls_with_headers:
|
||||||
self.assertEquals(headers['Authorization'], 'AWS test:tester:X')
|
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):
|
def test_signed_urls_invalid_expire(self):
|
||||||
expire = 'invalid'
|
expire = 'invalid'
|
||||||
@ -194,7 +207,7 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
||||||
|
|
||||||
def test_signed_urls_no_sign(self):
|
def test_signed_urls_no_sign(self):
|
||||||
expire = 'invalid'
|
expire = '2147483647' # 19 Jan 2038 03:14:07
|
||||||
req = Request.blank('/bucket/object?Expires=%s&'
|
req = Request.blank('/bucket/object?Expires=%s&'
|
||||||
'AWSAccessKeyId=test:tester' % expire,
|
'AWSAccessKeyId=test:tester' % expire,
|
||||||
environ={'REQUEST_METHOD': 'GET'},
|
environ={'REQUEST_METHOD': 'GET'},
|
||||||
@ -204,6 +217,102 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
status, headers, body = self.call_swift3(req)
|
status, headers, body = self.call_swift3(req)
|
||||||
self.assertEquals(self._get_error_code(body), 'AccessDenied')
|
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):
|
def test_bucket_virtual_hosted_style(self):
|
||||||
req = Request.blank('/',
|
req = Request.blank('/',
|
||||||
environ={'HTTP_HOST': 'bucket.localhost:80',
|
environ={'HTTP_HOST': 'bucket.localhost:80',
|
||||||
@ -447,6 +556,266 @@ class TestSwift3Middleware(Swift3TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.swift3.check_pipeline(conf)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -29,7 +29,7 @@ from swift3.subresource import Owner, Grant, User, ACL, encode_acl, \
|
|||||||
decode_acl, ACLPublicRead
|
decode_acl, ACLPublicRead
|
||||||
from swift3.test.unit.test_s3_acl import s3acl
|
from swift3.test.unit.test_s3_acl import s3acl
|
||||||
from swift3.cfg import CONF
|
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
|
from swift3.request import MAX_32BIT_INT
|
||||||
|
|
||||||
xml = '<CompleteMultipartUpload>' \
|
xml = '<CompleteMultipartUpload>' \
|
||||||
@ -634,6 +634,21 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
|||||||
self.assertEquals(headers.get('X-Object-Meta-Foo'), 'bar')
|
self.assertEquals(headers.get('X-Object-Meta-Foo'), 'bar')
|
||||||
self.assertEquals(headers.get('Content-Type'), 'baz/quux')
|
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):
|
def test_object_multipart_upload_complete_segment_too_small(self):
|
||||||
msgs = [
|
msgs = [
|
||||||
# pre-2.6.0 swift
|
# pre-2.6.0 swift
|
||||||
@ -1230,7 +1245,8 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
|||||||
|
|
||||||
def _test_copy_for_s3acl(self, account, src_permission=None,
|
def _test_copy_for_s3acl(self, account, src_permission=None,
|
||||||
src_path='/src_bucket/src_obj', src_headers=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'
|
owner = 'test:tester'
|
||||||
grants = [Grant(User(account), src_permission)] \
|
grants = [Grant(User(account), src_permission)] \
|
||||||
if src_permission else [Grant(User(owner), 'FULL_CONTROL')]
|
if src_permission else [Grant(User(owner), 'FULL_CONTROL')]
|
||||||
@ -1248,14 +1264,18 @@ class TestSwift3MultiUpload(Swift3TestCase):
|
|||||||
'/bucket/object?partNumber=1&uploadId=X',
|
'/bucket/object?partNumber=1&uploadId=X',
|
||||||
environ={'REQUEST_METHOD': 'PUT'},
|
environ={'REQUEST_METHOD': 'PUT'},
|
||||||
headers=put_headers)
|
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)
|
return self.call_swift3(req)
|
||||||
|
|
||||||
@s3acl
|
@s3acl
|
||||||
def test_upload_part_copy(self):
|
def test_upload_part_copy(self):
|
||||||
last_modified = '2014-04-01T12:00:00.000Z'
|
date_header = self.get_date_header()
|
||||||
status, headers, body = \
|
timestamp = mktime(date_header)
|
||||||
self._test_copy_for_s3acl('test:tester')
|
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(status.split()[0], '200')
|
||||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||||
self.assertTrue(headers.get('etag') is None)
|
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.test.unit.test_s3_acl import s3acl
|
||||||
from swift3.subresource import ACL, User, encode_acl, Owner, Grant
|
from swift3.subresource import ACL, User, encode_acl, Owner, Grant
|
||||||
from swift3.etree import fromstring
|
from swift3.etree import fromstring
|
||||||
|
from swift3.utils import mktime, S3Timestamp
|
||||||
from swift3.test.unit.helpers import FakeSwift
|
from swift3.test.unit.helpers import FakeSwift
|
||||||
|
|
||||||
|
|
||||||
@ -288,9 +289,9 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
|
|
||||||
self._test_object_GETorHEAD('GET')
|
self._test_object_GETorHEAD('GET')
|
||||||
_, _, headers = self.swift.calls_with_headers[-1]
|
_, _, headers = self.swift.calls_with_headers[-1]
|
||||||
self.assertTrue('Authorization' not in headers)
|
self.assertNotIn('Authorization', headers)
|
||||||
_, _, headers = self.swift.calls_with_headers[0]
|
_, _, headers = self.swift.calls_with_headers[0]
|
||||||
self.assertTrue('Authorization' not in headers)
|
self.assertNotIn('Authorization', headers)
|
||||||
|
|
||||||
@s3acl
|
@s3acl
|
||||||
def test_object_GET_Range(self):
|
def test_object_GET_Range(self):
|
||||||
@ -466,7 +467,7 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
self.assertEquals(headers['Content-Length'], '0')
|
self.assertEquals(headers['Content-Length'], '0')
|
||||||
|
|
||||||
def _test_object_PUT_copy(self, head_resp, put_header=None,
|
def _test_object_PUT_copy(self, head_resp, put_header=None,
|
||||||
src_path='/some/source'):
|
src_path='/some/source', timestamp=None):
|
||||||
account = 'test:tester'
|
account = 'test:tester'
|
||||||
grants = [Grant(User(account), 'FULL_CONTROL')]
|
grants = [Grant(User(account), 'FULL_CONTROL')]
|
||||||
head_headers = \
|
head_headers = \
|
||||||
@ -476,9 +477,10 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
self.swift.register('HEAD', '/v1/AUTH_test/some/source',
|
self.swift.register('HEAD', '/v1/AUTH_test/some/source',
|
||||||
head_resp, head_headers, None)
|
head_resp, head_headers, None)
|
||||||
put_header = put_header or {}
|
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'
|
account = 'test:tester'
|
||||||
grants = [Grant(User(account), 'FULL_CONTROL')]
|
grants = [Grant(User(account), 'FULL_CONTROL')]
|
||||||
head_headers = \
|
head_headers = \
|
||||||
@ -488,9 +490,9 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
self.swift.register('HEAD', '/v1/AUTH_test/bucket/object',
|
self.swift.register('HEAD', '/v1/AUTH_test/bucket/object',
|
||||||
head_resp, head_headers, None)
|
head_resp, head_headers, None)
|
||||||
put_header = put_header or {}
|
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',
|
put_headers = {'Authorization': 'AWS test:tester:hmac',
|
||||||
'X-Amz-Copy-Source': src_path,
|
'X-Amz-Copy-Source': src_path,
|
||||||
'Date': self.get_date_header()}
|
'Date': self.get_date_header()}
|
||||||
@ -502,13 +504,18 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
|
|
||||||
req.date = datetime.now()
|
req.date = datetime.now()
|
||||||
req.content_type = 'text/plain'
|
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)
|
return self.call_swift3(req)
|
||||||
|
|
||||||
@s3acl
|
@s3acl
|
||||||
def test_object_PUT_copy(self):
|
def test_object_PUT_copy(self):
|
||||||
last_modified = '2014-04-01T12:00:00.000Z'
|
date_header = self.get_date_header()
|
||||||
status, headers, body = self._test_object_PUT_copy(swob.HTTPOk)
|
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(status.split()[0], '200')
|
||||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||||
self.assertTrue(headers.get('etag') is None)
|
self.assertTrue(headers.get('etag') is None)
|
||||||
@ -523,11 +530,14 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
|
|
||||||
@s3acl
|
@s3acl
|
||||||
def test_object_PUT_copy_no_slash(self):
|
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;
|
# Some clients (like Boto) don't include the leading slash;
|
||||||
# AWS seems to tolerate this so we should, too
|
# AWS seems to tolerate this so we should, too
|
||||||
status, headers, body = self._test_object_PUT_copy(
|
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(status.split()[0], '200')
|
||||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||||
self.assertTrue(headers.get('etag') is None)
|
self.assertTrue(headers.get('etag') is None)
|
||||||
@ -569,10 +579,13 @@ class TestSwift3Obj(Swift3TestCase):
|
|||||||
|
|
||||||
@s3acl
|
@s3acl
|
||||||
def test_object_PUT_copy_self_metadata_replace(self):
|
def test_object_PUT_copy_self_metadata_replace(self):
|
||||||
last_modified = '2014-04-01T12:00:00.000Z'
|
date_header = self.get_date_header()
|
||||||
header = {'x-amz-metadata-directive': 'REPLACE'}
|
timestamp = mktime(date_header)
|
||||||
status, headers, body = \
|
last_modified = S3Timestamp(timestamp).s3xmlformat
|
||||||
self._test_object_PUT_copy_self(swob.HTTPOk, header)
|
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(status.split()[0], '200')
|
||||||
self.assertEquals(headers['Content-Type'], 'application/xml')
|
self.assertEquals(headers['Content-Type'], 'application/xml')
|
||||||
self.assertTrue(headers.get('etag') is None)
|
self.assertTrue(headers.get('etag') is None)
|
||||||
|
@ -20,12 +20,14 @@ import unittest
|
|||||||
from swift.common import swob
|
from swift.common import swob
|
||||||
from swift.common.swob import Request, HTTPNoContent
|
from swift.common.swob import Request, HTTPNoContent
|
||||||
|
|
||||||
|
from swift3.utils import mktime
|
||||||
from swift3.subresource import ACL, User, Owner, Grant, encode_acl
|
from swift3.subresource import ACL, User, Owner, Grant, encode_acl
|
||||||
from swift3.test.unit.test_middleware import Swift3TestCase
|
from swift3.test.unit.test_middleware import Swift3TestCase
|
||||||
from swift3.cfg import CONF
|
from swift3.cfg import CONF
|
||||||
from swift3.request import Request as S3_Request
|
from swift3.request import Request as S3_Request
|
||||||
from swift3.request import S3AclRequest
|
from swift3.request import S3AclRequest, SigV4Request, SIGV4_X_AMZ_DATE_FORMAT
|
||||||
from swift3.response import InvalidArgument, NoSuchBucket, InternalError
|
from swift3.response import InvalidArgument, NoSuchBucket, InternalError, \
|
||||||
|
AccessDenied, SignatureDoesNotMatch
|
||||||
|
|
||||||
|
|
||||||
Fake_ACL_MAP = {
|
Fake_ACL_MAP = {
|
||||||
@ -249,8 +251,8 @@ class TestRequest(Swift3TestCase):
|
|||||||
m_swift_resp.return_value = FakeSwiftResponse()
|
m_swift_resp.return_value = FakeSwiftResponse()
|
||||||
s3_req = S3AclRequest(req.environ, MagicMock())
|
s3_req = S3AclRequest(req.environ, MagicMock())
|
||||||
sw_req = s3_req.to_swift_req(method, container, obj)
|
sw_req = s3_req.to_swift_req(method, container, obj)
|
||||||
self.assertTrue('HTTP_AUTHORIZATION' not in sw_req.environ)
|
self.assertNotIn('HTTP_AUTHORIZATION', sw_req.environ)
|
||||||
self.assertTrue('Authorization' not in sw_req.headers)
|
self.assertNotIn('Authorization', sw_req.headers)
|
||||||
self.assertEquals(sw_req.headers['X-Auth-Token'], 'token')
|
self.assertEquals(sw_req.headers['X-Auth-Token'], 'token')
|
||||||
|
|
||||||
def test_to_swift_req_subrequest_proxy_access_log(self):
|
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(status.split()[0], '403')
|
||||||
self.assertEquals(body, '')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -18,8 +18,8 @@ import uuid
|
|||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
from swift.common.utils import get_logger
|
from swift.common.utils import get_logger
|
||||||
|
import email.utils
|
||||||
|
|
||||||
# Need for check_path_header
|
# Need for check_path_header
|
||||||
from swift.common import utils
|
from swift.common import utils
|
||||||
@ -143,6 +143,41 @@ class S3Timestamp(utils.Timestamp):
|
|||||||
def s3xmlformat(self):
|
def s3xmlformat(self):
|
||||||
return self.isoformat[:-7] + '.000Z'
|
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
|
@classmethod
|
||||||
def now(cls):
|
def now(cls):
|
||||||
return cls(time.time())
|
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