diff --git a/README.rst b/README.rst index 58c52cd..59c6641 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,7 @@ httpie-oauth -=========== +============ -OAuth plugin for `HTTPie `_. - -It currently provides support for OAuth 1.0a 2-legged. +OAuth 1.0a two-legged plugin for `HTTPie `_. Installation @@ -14,16 +12,83 @@ Installation $ pip install httpie-oauth -You should now see ``oauth1`` under ``--auth-type`` in ``$ http --help`` output. +You should now see ``oauth1`` under ``--auth-type`` in the +``$ http --help`` output. + +To be able to use the RSA-SHA1 signature type, also install **PyJWT** +and PyCA's **cryptography** package. + +.. code-block:: bash + + $ pip install pyjwt + $ pip install cryptography +On CentOS 7, it might be easier to use *yum* to install "epel-release" +and then the "python2-cryptography" packages, since to *pip install* it +requires C code to be compiled. Usage ----- +HMAC-SHA1 +......... + +To use the HMAC-SHA1 signature method, in the ``--auth`` parameter +provide the client-key, a single colon and the client-secret. + .. code-block:: bash $ http --auth-type=oauth1 --auth='client-key:client-secret' example.org +It will interactively prompt for the client-secret, if there is no colon. +If the password starts with a colon, use this interactive method to enter it +(otherwise the extra colon will cause it to use RSA-SHA1 instead of HMAC-SHA1). + +RSA-SHA1 +........ + +To use the RSA-SHA1 signature method, in the ``--auth`` parameter +provide the client key, two colons and the name of a file containing +the RSA private key. The file must contain a PEM formatted RSA private +key. + +.. code-block:: bash + + $ http --auth-type=oauth1 --auth='client-key::filename' example.org + +It will interactively prompt for the filename, if there is no value +after the two colons. + +The filename can also be a relative or absolute path to the file. + +Passphrase protected private keys are not supported. + +Including the client key in the private key file +++++++++++++++++++++++++++++++++++++++++++++++++ + +If the client key in the ``--auth`` parameter is empty (i.e. the +option argument is just two colons and the filename), the +``oauth_consumer_key`` parameter from the file is used. It must +appear in the file before the private key. + +For example, if the private key file contains something like this: + +:: + + oauth_consumer_key: myconsumerkey + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- + +It can be used with this command: + +.. code-block:: bash + + $ http --auth-type=oauth1 --auth=::filename example.org + + +HTTPie Sessions +............... You can also use `HTTPie sessions `_: @@ -35,3 +100,16 @@ You can also use `HTTPie sessions `_: # Re-use auth $ http --session=logged-in POST example.org hello=world + +Troubleshooting +............... + +ImportError: No module named jwt.algorithms ++++++++++++++++++++++++++++++++++++++++++++ + +The *PyJWT* module is not available. Please install it. + +AttributeError: 'module' object has no attribute 'RSAAlgorithm' ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +The PyCA's *cryptography* module is not available. Please install it. diff --git a/httpie_oauth.py b/httpie_oauth.py index 47cdc45..acec84d 100644 --- a/httpie_oauth.py +++ b/httpie_oauth.py @@ -1,12 +1,26 @@ """ -OAuth plugin for HTTPie. +OAuth 1.0a 2-legged plugin for HTTPie. + +Supports HMAC-SHA1 and RSA-SHA1 signature methods. + +If the authentication parameter is "username:password" then HMAC-SHA1 is used. +If the password is omitted (i.e. --auth username is provided), the user is +prompted for the password. + +If the authentication parameter is "username::filename" (double colon between +the username and the filename) then RSA-SHA1 is used, and the PEM formatted +private key is read from that file. If the filename is omitted +(i.e. --auth username:: is provided), the user is prompted for +the filename. The username is used as the oauth_client_key OAuth parameter. """ +import string +import sys from httpie.plugins import AuthPlugin from requests_oauthlib import OAuth1 +from oauthlib.oauth1 import SIGNATURE_RSA - -__version__ = '1.0.2' +__version__ = '2.0.0' __author__ = 'Jakub Roztocil' __licence__ = 'BSD' @@ -15,7 +29,147 @@ class OAuth1Plugin(AuthPlugin): name = 'OAuth 1.0a 2-legged' auth_type = 'oauth1' - description = '' + description =\ + '--auth user:HMAC-SHA1_secret or --auth user::RSA-SHA1_privateKeyFile' + + def get_auth(self, username=None, password=None): + """ + Generate OAuth 1.0a 2-legged authentication for HTTPie. + + Note: Passpharse protected private keys are not yet supported. + Before support can be implemented, the PyJWT, oauthlib and + requests_oauthlib modules need to be updated. + The passphrase needs to be obtained here and passed + through to PyJWT's jwt/algorithms.py, line 168, where currently it + passes into load_pem_private_key a hardcoded value of None for the + password. + To get it to there, many places in oauthlib's oauth1/rfc5849/__init__.py + and oauth1/rfc5849/signature.py, as well as in requests_oauthlib's + oauth1_auth.py, need to be updated to pass it through. + + :param username: username + :param password: password, or colon followed by a filename + :return: requests_oauthlib.oauth1_auth.OAuth1 object + """ + if not password.startswith(':'): + # HMAC-SHA1 signature method (--auth client-key:client-secret) + return OAuth1(client_key=username, client_secret=password) + + else: + # RSA-SHA1 signature method (--auth oauth_consumer_key::filename) + filename = password[1:] + if len(filename) == 0: + # Prompt for filename of RSA private key + try: + filename = raw_input('http: filename of RSA private key: ') + except EOFError: # if ^D entered + sys.exit(1) + + username, key = OAuth1Plugin.read_private_key(username, filename) + + return OAuth1(client_key=username, + signature_method=SIGNATURE_RSA, + rsa_key=key) + + @staticmethod + def read_private_key(username, filename): + """ + Check if the key is a recognised private key format. + + Prints an error message to stderr and exits if it is not. Uses + crude checks to try and generate more useful error messages. + + :param username: username to use + :param filename: file to read private key from + :return: PEM formatted private key + """ + PEM_PRIVATE_KEY_BEGINNING = '-----BEGIN RSA PRIVATE KEY-----' + PEM_PRIVATE_KEY_ENDING = '-----END RSA PRIVATE KEY-----' + ATTR_NAME = 'oauth_consumer_key' + + try: + data = open(filename).read() + + key_start = data.find(PEM_PRIVATE_KEY_BEGINNING) + key_end = data.find(PEM_PRIVATE_KEY_ENDING) + + if key_start == -1: + # Did not find the start of private key. + + if data.find('-----BEGIN PUBLIC KEY-----') != -1 or \ + data.find('-----BEGIN RSA PUBLIC KEY-----') != -1 or \ + data.find('ssh-rsa ') != -1 or \ + data.find('---- BEGIN SSH2 PUBLIC KEY ----') != -1: + # Appears to contain a PKCS8, PEM, OpenSSH old or + # OpenSSH new format public key. + # The newer OpenSSH format does not follow RFC7468 + # and only has 4 hyphens and spaces around the text! + err = 'wrong key, please provide the PRIVATE key' + elif data.find('-----BEGIN OPENSSH PRIVATE KEY-----') != -1: + # Appears to contain newer OpenSSH private key format + err = 'private key format not supported' + \ + ', PEM format required' + else: + # Generic error message + err = 'does not contain a PEM formatted private key' + + else: + if key_end == -1: + err = 'private key is incomplete' + else: + key_end += len(PEM_PRIVATE_KEY_ENDING) + err = None + + # If the username is blank, try to extract a username from the file + + if len(username) == 0 and err is None: + username, err = OAuth1Plugin.extract_username(data, ATTR_NAME, + 0, key_start) + + if err is not None: + sys.stderr.write("http: " + filename + ': ' + err) + sys.exit(1) + + pem_key = data[key_start:key_end] + + return username, pem_key + + except IOError as e: + sys.stderr.write("http: " + str(e)) + sys.exit(1) + + @staticmethod + def extract_username(data, attr_name, start, end, limit=8096): + """ + Extract a named parameter from the contents. + + :param data: text to search + :param attr_name: name of attribute to look for + :param start: index into contents of where to start looking + :param end: index into contents of where to stop looking + :param limit: upper limit for end position + :return: client-key value or None if not found + """ + stop_pos = end + if limit < end: + stop_pos = limit + i = start + + while i < stop_pos: + eol_pos = data.find('\n', i, stop_pos) + if eol_pos == -1: + break # no more complete lines found + + colon_pos = data.find(':', i, eol_pos) + if colon_pos != -1: + name = data[i:colon_pos].strip() + value = data[colon_pos + 1: eol_pos].strip() + if name == attr_name: + return value, None # successfully found + i = eol_pos + 1 - def get_auth(self, username, password): - return OAuth1(client_key=username, client_secret=password) + if limit < end: + return None, '"{}" not found in first {} characters'.format( + attr_name, limit) + else: + return None, '"{}" not found before private key'.format(attr_name) diff --git a/setup.py b/setup.py index 33f58f8..2d779d2 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ name='httpie-oauth', description='OAuth plugin for HTTPie.', long_description=open('README.rst').read().strip(), - version='1.0.2', + version='2.0.0', author='Jakub Roztocil', author_email='jakub@roztocil.name', license='BSD',