signers.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. # Copyright 2014 ksyun.com, Inc. or its affiliates. All Rights Reserved.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"). You
  4. # may not use this file except in compliance with the License. A copy of
  5. # the License is located at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # or in the "license" file accompanying this file. This file is
  10. # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
  11. # ANY KIND, either express or implied. See the License for the specific
  12. # language governing permissions and limitations under the License.
  13. import datetime
  14. import weakref
  15. import json
  16. import base64
  17. import kscore
  18. import kscore.auth
  19. from kscore.compat import six, OrderedDict
  20. from kscore.ksrequest import create_request_object, prepare_request_dict
  21. from kscore.exceptions import UnknownSignatureVersionError
  22. from kscore.exceptions import UnknownClientMethodError
  23. from kscore.exceptions import UnsupportedSignatureVersionError
  24. from kscore.utils import fix_s3_host, datetime2timestamp
  25. class RequestSigner(object):
  26. """
  27. An object to sign requests before they go out over the wire using
  28. one of the authentication mechanisms defined in ``auth.py``. This
  29. class fires two events scoped to a service and operation name:
  30. * choose-signer: Allows overriding the auth signer name.
  31. * before-sign: Allows mutating the request before signing.
  32. Together these events allow for customization of the request
  33. signing pipeline, including overrides, request path manipulation,
  34. and disabling signing per operation.
  35. :type service_name: string
  36. :param service_name: Name of the service, e.g. ``S3``
  37. :type region_name: string
  38. :param region_name: Name of the service region, e.g. ``us-east-1``
  39. :type signing_name: string
  40. :param signing_name: Service signing name. This is usually the
  41. same as the service name, but can differ. E.g.
  42. ``emr`` vs. ``elasticmapreduce``.
  43. :type signature_version: string
  44. :param signature_version: Signature name like ``v4``.
  45. :type credentials: :py:class:`~kscore.credentials.Credentials`
  46. :param credentials: User credentials with which to sign requests.
  47. :type event_emitter: :py:class:`~kscore.hooks.BaseEventHooks`
  48. :param event_emitter: Extension mechanism to fire events.
  49. """
  50. def __init__(self, service_name, region_name, signing_name,
  51. signature_version, credentials, event_emitter):
  52. self._service_name = service_name
  53. self._region_name = region_name
  54. self._signing_name = signing_name
  55. self._signature_version = signature_version
  56. self._credentials = credentials
  57. # We need weakref to prevent leaking memory in Python 2.6 on Linux 2.6
  58. self._event_emitter = weakref.proxy(event_emitter)
  59. @property
  60. def region_name(self):
  61. return self._region_name
  62. @property
  63. def signature_version(self):
  64. return self._signature_version
  65. @property
  66. def signing_name(self):
  67. return self._signing_name
  68. def handler(self, operation_name=None, request=None, **kwargs):
  69. # This is typically hooked up to the "request-created" event
  70. # from a client's event emitter. When a new request is created
  71. # this method is invoked to sign the request.
  72. # Don't call this method directly.
  73. return self.sign(operation_name, request)
  74. def sign(self, operation_name, request):
  75. """Sign a request before it goes out over the wire.
  76. :type operation_name: string
  77. :param operation_name: The name of the current operation, e.g.
  78. ``ListBuckets``.
  79. :type request: KSRequest
  80. :param request: The request object to be sent over the wire.
  81. """
  82. signature_version = self._signature_version
  83. # Allow overriding signature version. A response of a blank
  84. # string means no signing is performed. A response of ``None``
  85. # means that the default signing method is used.
  86. handler, response = self._event_emitter.emit_until_response(
  87. 'choose-signer.{0}.{1}'.format(self._service_name, operation_name),
  88. signing_name=self._signing_name, region_name=self._region_name,
  89. signature_version=signature_version)
  90. if response is not None:
  91. signature_version = response
  92. # Allow mutating request before signing
  93. self._event_emitter.emit(
  94. 'before-sign.{0}.{1}'.format(self._service_name, operation_name),
  95. request=request, signing_name=self._signing_name,
  96. region_name=self._region_name,
  97. signature_version=signature_version, request_signer=self)
  98. if signature_version != kscore.UNSIGNED:
  99. signer = self.get_auth_instance(self._signing_name,
  100. self._region_name,
  101. signature_version)
  102. signer.add_auth(request=request)
  103. def get_auth_instance(self, signing_name, region_name,
  104. signature_version=None, **kwargs):
  105. """
  106. Get an auth instance which can be used to sign a request
  107. using the given signature version.
  108. :type signing_name: string
  109. :param signing_name: Service signing name. This is usually the
  110. same as the service name, but can differ. E.g.
  111. ``emr`` vs. ``elasticmapreduce``.
  112. :type region_name: string
  113. :param region_name: Name of the service region, e.g. ``us-east-1``
  114. :type signature_version: string
  115. :param signature_version: Signature name like ``v4``.
  116. :rtype: :py:class:`~kscore.auth.BaseSigner`
  117. :return: Auth instance to sign a request.
  118. """
  119. if signature_version is None:
  120. signature_version = self._signature_version
  121. cls = kscore.auth.AUTH_TYPE_MAPS.get(signature_version)
  122. if cls is None:
  123. raise UnknownSignatureVersionError(
  124. signature_version=signature_version)
  125. # If there's no credentials provided (i.e credentials is None),
  126. # then we'll pass a value of "None" over to the auth classes,
  127. # which already handle the cases where no credentials have
  128. # been provided.
  129. frozen_credentials = None
  130. if self._credentials is not None:
  131. frozen_credentials = self._credentials.get_frozen_credentials()
  132. kwargs['credentials'] = frozen_credentials
  133. if cls.REQUIRES_REGION:
  134. if self._region_name is None:
  135. raise kscore.exceptions.NoRegionError()
  136. kwargs['region_name'] = region_name
  137. kwargs['service_name'] = signing_name
  138. auth = cls(**kwargs)
  139. return auth
  140. # Alias get_auth for backwards compatibility.
  141. get_auth = get_auth_instance
  142. def generate_presigned_url(self, request_dict, expires_in=3600,
  143. region_name=None):
  144. """Generates a presigned url
  145. :type request_dict: dict
  146. :param request_dict: The prepared request dictionary returned by
  147. ``kscore.ksrequest.prepare_request_dict()``
  148. :type expires_in: int
  149. :param expires_in: The number of seconds the presigned url is valid
  150. for. By default it expires in an hour (3600 seconds)
  151. :type region_name: string
  152. :param region_name: The region name to sign the presigned url.
  153. :returns: The presigned url
  154. """
  155. if region_name is None:
  156. region_name = self._region_name
  157. query_prefix = '-query'
  158. signature_version = self._signature_version
  159. if not signature_version.endswith(query_prefix):
  160. signature_version += query_prefix
  161. kwargs = {'signing_name': self._signing_name,
  162. 'region_name': region_name,
  163. 'signature_version': signature_version,
  164. 'expires': expires_in}
  165. signature_type = signature_version.split('-', 1)[0]
  166. try:
  167. auth = self.get_auth_instance(**kwargs)
  168. except UnknownSignatureVersionError:
  169. raise UnsupportedSignatureVersionError(
  170. signature_version=signature_type)
  171. request = create_request_object(request_dict)
  172. # Fix s3 host for s3 sigv2 bucket names
  173. fix_s3_host(request, signature_type, region_name)
  174. auth.add_auth(request)
  175. request.prepare()
  176. return request.url
  177. class CloudFrontSigner(object):
  178. '''A signer to create a signed CloudFront URL.
  179. First you create a cloudfront signer based on a normalized RSA signer::
  180. import rsa
  181. def rsa_signer(message):
  182. private_key = open('private_key.pem', 'r').read()
  183. return rsa.sign(
  184. message,
  185. rsa.PrivateKey.load_pkcs1(private_key.encode('utf8')),
  186. 'SHA-1') # CloudFront requires SHA-1 hash
  187. cf_signer = CloudFrontSigner(key_id, rsa_signer)
  188. To sign with a canned policy::
  189. signed_url = cf_signer.generate_signed_url(
  190. url, date_less_than=datetime(2015, 12, 1))
  191. To sign with a custom policy::
  192. signed_url = cf_signer.generate_signed_url(url, policy=my_policy)
  193. '''
  194. def __init__(self, key_id, rsa_signer):
  195. """Create a CloudFrontSigner.
  196. :type key_id: str
  197. :param key_id: The CloudFront Key Pair ID
  198. :type rsa_signer: callable
  199. :param rsa_signer: An RSA signer.
  200. Its only input parameter will be the message to be signed,
  201. and its output will be the signed content as a binary string.
  202. The hash algorithm needed by CloudFront is SHA-1.
  203. """
  204. self.key_id = key_id
  205. self.rsa_signer = rsa_signer
  206. def generate_presigned_url(self, url, date_less_than=None, policy=None):
  207. """Creates a signed CloudFront URL based on given parameters.
  208. :type url: str
  209. :param url: The URL of the protected object
  210. :type date_less_than: datetime
  211. :param date_less_than: The URL will expire after that date and time
  212. :type policy: str
  213. :param policy: The custom policy, possibly built by self.build_policy()
  214. :rtype: str
  215. :return: The signed URL.
  216. """
  217. if (date_less_than is not None and policy is not None
  218. or date_less_than is None and policy is None):
  219. e = 'Need to provide either date_less_than or policy, but not both'
  220. raise ValueError(e)
  221. if date_less_than is not None:
  222. # We still need to build a canned policy for signing purpose
  223. policy = self.build_policy(url, date_less_than)
  224. if isinstance(policy, six.text_type):
  225. policy = policy.encode('utf8')
  226. if date_less_than is not None:
  227. params = ['Expires=%s' % int(datetime2timestamp(date_less_than))]
  228. else:
  229. params = ['Policy=%s' % self._url_b64encode(policy).decode('utf8')]
  230. signature = self.rsa_signer(policy)
  231. params.extend([
  232. 'Signature=%s' % self._url_b64encode(signature).decode('utf8'),
  233. 'Key-Pair-Id=%s' % self.key_id,
  234. ])
  235. return self._build_url(url, params)
  236. def _build_url(self, base_url, extra_params):
  237. separator = '&' if '?' in base_url else '?'
  238. return base_url + separator + '&'.join(extra_params)
  239. def build_policy(self, resource, date_less_than,
  240. date_greater_than=None, ip_address=None):
  241. """A helper to build policy.
  242. :type resource: str
  243. :param resource: The URL or the stream filename of the protected object
  244. :type date_less_than: datetime
  245. :param date_less_than: The URL will expire after the time has passed
  246. :type date_greater_than: datetime
  247. :param date_greater_than: The URL will not be valid until this time
  248. :type ip_address: str
  249. :param ip_address: Use 'x.x.x.x' for an IP, or 'x.x.x.x/x' for a subnet
  250. :rtype: str
  251. :return: The policy in a compact string.
  252. """
  253. # Note:
  254. # 1. Order in canned policy is significant. Special care has been taken
  255. # to ensure the output will match the order defined by the document.
  256. # There is also a test case to ensure that order. https://github.com/liuyichen/
  257. # 2. Albeit the order in custom policy is not required by CloudFront,
  258. # we still use OrderedDict internally to ensure the result is stable
  259. # and also matches canned policy requirement. https://github.com/liuyichen/
  260. moment = int(datetime2timestamp(date_less_than))
  261. condition = OrderedDict({"DateLessThan": {"AWS:EpochTime": moment}})
  262. if ip_address:
  263. if '/' not in ip_address:
  264. ip_address += '/32'
  265. condition["IpAddress"] = {"AWS:SourceIp": ip_address}
  266. if date_greater_than:
  267. moment = int(datetime2timestamp(date_greater_than))
  268. condition["DateGreaterThan"] = {"AWS:EpochTime": moment}
  269. ordered_payload = [('Resource', resource), ('Condition', condition)]
  270. custom_policy = {"Statement": [OrderedDict(ordered_payload)]}
  271. return json.dumps(custom_policy, separators=(',', ':'))
  272. def _url_b64encode(self, data):
  273. # Required by CloudFront. See also: https://github.com/liuyichen/
  274. return base64.b64encode(
  275. data).replace(b'+', b'-').replace(b'=', b'_').replace(b'/', b'~')
  276. class S3PostPresigner(object):
  277. def __init__(self, request_signer):
  278. self._request_signer = request_signer
  279. def generate_presigned_post(self, request_dict, fields=None,
  280. conditions=None, expires_in=3600,
  281. region_name=None):
  282. """Generates the url and the form fields used for a presigned s3 post
  283. :type request_dict: dict
  284. :param request_dict: The prepared request dictionary returned by
  285. ``kscore.ksrequest.prepare_request_dict()``
  286. :type fields: dict
  287. :param fields: A dictionary of prefilled form fields to build on top
  288. of.
  289. :type conditions: list
  290. :param conditions: A list of conditions to include in the policy. Each
  291. element can be either a list or a structure. For example:
  292. [
  293. {"acl": "public-read"},
  294. {"bucket": "mybucket"},
  295. ["starts-with", "$key", "mykey"]
  296. ]
  297. :type expires_in: int
  298. :param expires_in: The number of seconds the presigned post is valid
  299. for.
  300. :type region_name: string
  301. :param region_name: The region name to sign the presigned post to.
  302. :rtype: dict
  303. :returns: A dictionary with two elements: ``url`` and ``fields``.
  304. Url is the url to post to. Fields is a dictionary filled with
  305. the form fields and respective values to use when submitting the
  306. post. For example:
  307. {'url': 'https://github.com/liuyichen/',
  308. 'fields': {'acl': 'public-read',
  309. 'key': 'mykey',
  310. 'signature': 'mysignature',
  311. 'policy': 'mybase64 encoded policy'}
  312. }
  313. """
  314. if fields is None:
  315. fields = {}
  316. if conditions is None:
  317. conditions = []
  318. if region_name is None:
  319. region_name = self._request_signer.region_name
  320. # Create the policy for the post.
  321. policy = {}
  322. # Create an expiration date for the policy
  323. datetime_now = datetime.datetime.utcnow()
  324. expire_date = datetime_now + datetime.timedelta(seconds=expires_in)
  325. policy['expiration'] = expire_date.strftime(kscore.auth.ISO8601)
  326. # Append all of the conditions that the user supplied.
  327. policy['conditions'] = []
  328. for condition in conditions:
  329. policy['conditions'].append(condition)
  330. # Obtain the appropriate signer.
  331. query_prefix = '-presign-post'
  332. signature_version = self._request_signer.signature_version
  333. if not signature_version.endswith(query_prefix):
  334. signature_version += query_prefix
  335. kwargs = {'signing_name': self._request_signer.signing_name,
  336. 'region_name': region_name,
  337. 'signature_version': signature_version}
  338. signature_type = signature_version.split('-', 1)[0]
  339. try:
  340. auth = self._request_signer.get_auth_instance(**kwargs)
  341. except UnknownSignatureVersionError:
  342. raise UnsupportedSignatureVersionError(
  343. signature_version=signature_type)
  344. # Store the policy and the fields in the request for signing
  345. request = create_request_object(request_dict)
  346. request.context['s3-presign-post-fields'] = fields
  347. request.context['s3-presign-post-policy'] = policy
  348. auth.add_auth(request)
  349. # Fix s3 host for s3 sigv2 bucket names
  350. fix_s3_host(request, signature_type, region_name)
  351. # Return the url and the fields for th form to post.
  352. return {'url': request.url, 'fields': fields}
  353. def add_generate_presigned_url(class_attributes, **kwargs):
  354. class_attributes['generate_presigned_url'] = generate_presigned_url
  355. def generate_presigned_url(self, ClientMethod, Params=None, ExpiresIn=3600,
  356. HttpMethod=None):
  357. """Generate a presigned url given a client, its method, and arguments
  358. :type ClientMethod: string
  359. :param ClientMethod: The client method to presign for
  360. :type Params: dict
  361. :param Params: The parameters normally passed to
  362. ``ClientMethod``.
  363. :type ExpiresIn: int
  364. :param ExpiresIn: The number of seconds the presigned url is valid
  365. for. By default it expires in an hour (3600 seconds)
  366. :type HttpMethod: string
  367. :param HttpMethod: The http method to use on the generated url. By
  368. default, the http method is whatever is used in the method's model.
  369. :returns: The presigned url
  370. """
  371. client_method = ClientMethod
  372. params = Params
  373. expires_in = ExpiresIn
  374. http_method = HttpMethod
  375. request_signer = self._request_signer
  376. serializer = self._serializer
  377. try:
  378. operation_name = self._PY_TO_OP_NAME[client_method]
  379. except KeyError:
  380. raise UnknownClientMethodError(method_name=client_method)
  381. operation_model = self.meta.service_model.operation_model(
  382. operation_name)
  383. # Create a request dict based on the params to serialize.
  384. request_dict = serializer.serialize_to_request(
  385. params, operation_model)
  386. # Switch out the http method if user specified it.
  387. if http_method is not None:
  388. request_dict['method'] = http_method
  389. # Prepare the request dict by including the client's endpoint url.
  390. prepare_request_dict(
  391. request_dict, endpoint_url=self.meta.endpoint_url)
  392. # Generate the presigned url.
  393. return request_signer.generate_presigned_url(
  394. request_dict=request_dict, expires_in=expires_in)
  395. def add_generate_presigned_post(class_attributes, **kwargs):
  396. class_attributes['generate_presigned_post'] = generate_presigned_post
  397. def generate_presigned_post(self, Bucket, Key, Fields=None, Conditions=None,
  398. ExpiresIn=3600):
  399. """Builds the url and the form fields used for a presigned s3 post
  400. :type Bucket: string
  401. :param Bucket: The name of the bucket to presign the post to. Note that
  402. bucket related conditions should not be included in the
  403. ``conditions`` parameter.
  404. :type Key: string
  405. :param Key: Key name, optionally add ${filename} to the end to
  406. attach the submitted filename. Note that key related condtions and
  407. fields are filled out for you and should not be included in the
  408. ``fields`` or ``condtions`` parmater.
  409. :type Fields: dict
  410. :param Fields: A dictionary of prefilled form fields to build on top
  411. of. Elements that may be included are acl, Cache-Control,
  412. Content-Type, Content-Disposition, Content-Encoding, Expires,
  413. success_action_redirect, redirect, success_action_status,
  414. and x-amz-meta-.
  415. Note that if a particular element is included in the fields
  416. dictionary it will not be automatically added to the conditions
  417. list. You must specify a condition for the element as well.
  418. :type Conditions: list
  419. :param Conditions: A list of conditions to include in the policy. Each
  420. element can be either a list or a structure. For example:
  421. [
  422. {"acl": "public-read"},
  423. ["content-length-range", 2, 5],
  424. ["starts-with", "$success_action_redirect", ""]
  425. ]
  426. Conditions that are included may pertain to acl,
  427. content-length-range, Cache-Control, Content-Type,
  428. Content-Disposition, Content-Encoding, Expires,
  429. success_action_redirect, redirect, success_action_status,
  430. and/or x-amz-meta-.
  431. Note that if you include a condition, you must specify
  432. the a valid value in the fields dictionary as well. A value will
  433. not be added automatically to the fields dictionary based on the
  434. conditions.
  435. :type ExpiresIn: int
  436. :param ExpiresIn: The number of seconds the presigned post
  437. is valid for.
  438. :rtype: dict
  439. :returns: A dictionary with two elements: ``url`` and ``fields``.
  440. Url is the url to post to. Fields is a dictionary filled with
  441. the form fields and respective values to use when submitting the
  442. post. For example:
  443. {'url': 'https://github.com/liuyichen/',
  444. 'fields': {'acl': 'public-read',
  445. 'key': 'mykey',
  446. 'signature': 'mysignature',
  447. 'policy': 'mybase64 encoded policy'}
  448. }
  449. """
  450. bucket = Bucket
  451. key = Key
  452. fields = Fields
  453. conditions = Conditions
  454. expires_in = ExpiresIn
  455. if fields is None:
  456. fields = {}
  457. if conditions is None:
  458. conditions = []
  459. post_presigner = S3PostPresigner(self._request_signer)
  460. serializer = self._serializer
  461. # We choose the CreateBucket operation model because its url gets
  462. # serialized to what a presign post requires.
  463. operation_model = self.meta.service_model.operation_model(
  464. 'CreateBucket')
  465. # Create a request dict based on the params to serialize.
  466. request_dict = serializer.serialize_to_request(
  467. {'Bucket': bucket}, operation_model)
  468. # Prepare the request dict by including the client's endpoint url.
  469. prepare_request_dict(
  470. request_dict, endpoint_url=self.meta.endpoint_url)
  471. # Append that the bucket name to the list of conditions.
  472. conditions.append({'bucket': bucket})
  473. # If the key ends with filename, the only constraint that can be
  474. # imposed is if it starts with the specified prefix.
  475. if key.endswith('${filename}'):
  476. conditions.append(["starts-with", '$key', key[:-len('${filename}')]])
  477. else:
  478. conditions.append({'key': key})
  479. # Add the key to the fields.
  480. fields['key'] = key
  481. return post_presigner.generate_presigned_post(
  482. request_dict=request_dict, fields=fields, conditions=conditions,
  483. expires_in=expires_in)