|
| 1 | +#!/usr/bin/env python |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +# |
| 4 | +# Copyright 2020-2021 Mastercard |
| 5 | +# All rights reserved. |
| 6 | +# |
| 7 | +# Redistribution and use in source and binary forms, with or without modification, are |
| 8 | +# permitted provided that the following conditions are met: |
| 9 | +# |
| 10 | +# Redistributions of source code must retain the above copyright notice, this list of |
| 11 | +# conditions and the following disclaimer. |
| 12 | +# Redistributions in binary form must reproduce the above copyright notice, this list of |
| 13 | +# conditions and the following disclaimer in the documentation and/or other materials |
| 14 | +# provided with the distribution. |
| 15 | +# Neither the name of the MasterCard International Incorporated nor the names of its |
| 16 | +# contributors may be used to endorse or promote products derived from this software |
| 17 | +# without specific prior written permission. |
| 18 | +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY |
| 19 | +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
| 20 | +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT |
| 21 | +# SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, |
| 22 | +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED |
| 23 | +# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; |
| 24 | +# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER |
| 25 | +# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING |
| 26 | +# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF |
| 27 | +# SUCH DAMAGE. |
| 28 | +# |
| 29 | +import time |
| 30 | +from uuid import uuid4 |
| 31 | + |
| 32 | +from requests.auth import AuthBase |
| 33 | +from OpenSSL.crypto import PKey |
| 34 | +from OpenSSL import crypto |
| 35 | +from requests import PreparedRequest |
| 36 | +import hashlib |
| 37 | +from . import coreutils as util |
| 38 | + |
| 39 | +HASH_SHA256 = 'SHA256' |
| 40 | + |
| 41 | + |
| 42 | +def hash_func(hash_alg): |
| 43 | + return { |
| 44 | + HASH_SHA256: hashlib.sha256 |
| 45 | + }[hash_alg] |
| 46 | + |
| 47 | + |
| 48 | +class OAuth1RSA(AuthBase): |
| 49 | + """OAuth1 RSA-SHA256 requests's auth helper |
| 50 | + Usage: |
| 51 | + >>> from oauth1 import authenticationutils |
| 52 | + >>> from oauth1.auth_ext import OAuth1RSA |
| 53 | + >>> import requests |
| 54 | + >>> CONSUMER_KEY = 'secret-consumer-key' |
| 55 | + >>> pk = authenticationutils.load_signing_key('instance/masterpass.pfx', 'a3fa02536a') |
| 56 | + >>> oauth = OAuth1RSA(CONSUMER_KEY, pk) |
| 57 | + >>> requests.post('https://endpoint.com/the/route', data={'foo': 'bar'}, auth=oauth) |
| 58 | + """ |
| 59 | + |
| 60 | + def __init__(self, consumer_key: str, signing_key: PKey): |
| 61 | + self.consumer_key = consumer_key |
| 62 | + self.signing_key = signing_key |
| 63 | + self.hash_alg = HASH_SHA256 |
| 64 | + self.hash_f = hash_func(HASH_SHA256) |
| 65 | + |
| 66 | + def __call__(self, r: PreparedRequest): |
| 67 | + payload = { |
| 68 | + 'oauth_version': '1.0', |
| 69 | + 'oauth_nonce': self.nonce(), |
| 70 | + 'oauth_timestamp': str(self.timestamp()), |
| 71 | + 'oauth_signature_method': f'RSA-{self.hash_alg}', |
| 72 | + 'oauth_consumer_key': self.consumer_key |
| 73 | + } |
| 74 | + |
| 75 | + # Google's body hash extension |
| 76 | + payload = self.oauth_body_hash(r, payload) |
| 77 | + |
| 78 | + signable_message = self.signable_message(r, payload) |
| 79 | + signature = self.signature(signable_message) |
| 80 | + payload['oauth_signature'] = signature |
| 81 | + |
| 82 | + h = self._generate_header(payload) |
| 83 | + |
| 84 | + r.headers['Authorization'] = h |
| 85 | + return r |
| 86 | + |
| 87 | + @staticmethod |
| 88 | + def nonce(): |
| 89 | + return str(uuid4()) |
| 90 | + |
| 91 | + @staticmethod |
| 92 | + def timestamp(): |
| 93 | + return int(time.time()) |
| 94 | + |
| 95 | + def _hash(self, message: str) -> str: |
| 96 | + if type(message) is str: |
| 97 | + return self.hash_f(message.encode('utf8')).digest() |
| 98 | + elif type(message) is bytes: |
| 99 | + return self.hash_f(message).digest() |
| 100 | + else: |
| 101 | + # Generally for calls where the payload is empty. Eg: get calls |
| 102 | + # Fix for AttributeError: 'NoneType' object has no attribute 'encode' |
| 103 | + return self.hash_f(str(message).encode('utf8')).digest() |
| 104 | + |
| 105 | + @staticmethod |
| 106 | + def signable_message(r: PreparedRequest, payload: dict): |
| 107 | + params = [ |
| 108 | + r.method.upper(), |
| 109 | + util.normalize_url(r.url), |
| 110 | + util.normalize_params(r.url, payload) |
| 111 | + ] |
| 112 | + params = map(util.uri_rfc3986_encode, params) |
| 113 | + return '&'.join(params) |
| 114 | + |
| 115 | + def signature(self, message: str): |
| 116 | + signature = crypto.sign(self.signing_key, message, self.hash_alg) |
| 117 | + return util.base64_encode(signature) |
| 118 | + |
| 119 | + @staticmethod |
| 120 | + def _generate_header(payload: dict): |
| 121 | + _ = util.uri_rfc3986_encode |
| 122 | + pts = [f'{_(k)}="{_(v)}"' for k, v in sorted(payload.items())] |
| 123 | + msg = ','.join(pts) |
| 124 | + return f'OAuth {msg}' |
| 125 | + |
| 126 | + def oauth_body_hash(self, r: PreparedRequest, payload: dict): |
| 127 | + if r.headers and r.headers.get('content-type') == 'multipart/form-data': |
| 128 | + return payload |
| 129 | + |
| 130 | + body = r.body |
| 131 | + payload['oauth_body_hash'] = util.uri_rfc3986_encode(util.base64_encode(self._hash(body))) |
| 132 | + return payload |
0 commit comments