From a49774d3abedd0da3a882728a073bec974c149e5 Mon Sep 17 00:00:00 2001 From: Jakub Roztocil Date: Wed, 23 Nov 2016 22:01:58 +0100 Subject: [PATCH] Extend auth plugin API This extends the `AuthPlugin` API by the following attributes: * `auth_require`: set to `False` to make `--auth, -a` optional * `auth_parse`: set to `False` to disable `username:password` parsing (access the raw value passed to `-a` via `self.raw_auth`). * `prompt_password`: set to`False` to disable password prompt when no password provided (only relevant when `auth_parse == True`) These changes should be 100% backwards-compatible. What needs more testing is auth support in sessions. Close #433 Close #431 Close #378 Ping teracyhq/httpie-jwt-auth#3 --- CHANGELOG.rst | 1 + httpie/cli.py | 38 +++++++++++----- httpie/client.py | 7 +-- httpie/input.py | 73 +++++++++++++++++++++---------- httpie/plugins/base.py | 25 ++++++++++- httpie/plugins/builtin.py | 2 + httpie/plugins/manager.py | 3 ++ httpie/sessions.py | 34 ++++++++++++--- tests/test_auth.py | 4 +- tests/test_auth_plugins.py | 89 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 226 insertions(+), 50 deletions(-) create mode 100644 tests/test_auth_plugins.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 14e1752d..c919a4b7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ This project adheres to `Semantic Versioning `_. `1.0.0-dev`_ (Unreleased) ------------------------- +* Extended auth plugin API. * Added support for ``curses``-less Python installations. * Fixed ``REQUEST_ITEM`` arg incorrectly being reported as required. * Improved ``CTRL-C`` interrupt handling. diff --git a/httpie/cli.py b/httpie/cli.py index e78b59f2..05c4ac0c 100644 --- a/httpie/cli.py +++ b/httpie/cli.py @@ -5,22 +5,25 @@ NOTE: the CLI interface may change before reaching v1.0. """ from textwrap import dedent, wrap # noinspection PyCompatibility -from argparse import (RawDescriptionHelpFormatter, FileType, - OPTIONAL, ZERO_OR_MORE, SUPPRESS) +from argparse import ( + RawDescriptionHelpFormatter, FileType, + OPTIONAL, ZERO_OR_MORE, SUPPRESS +) from httpie import __doc__, __version__ from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.plugins import plugin_manager from httpie.sessions import DEFAULT_SESSIONS_DIR from httpie.output.formatters.colors import AVAILABLE_STYLES, DEFAULT_STYLE -from httpie.input import (HTTPieArgumentParser, - AuthCredentialsArgType, KeyValueArgType, - SEP_PROXY, SEP_CREDENTIALS, SEP_GROUP_ALL_ITEMS, - OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD, - OUT_RESP_BODY, OUTPUT_OPTIONS, - OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP, - PRETTY_STDOUT_TTY_ONLY, SessionNameValidator, - readable_file_arg, SSL_VERSION_ARG_MAPPING) +from httpie.input import ( + HTTPieArgumentParser, KeyValueArgType, + SEP_PROXY, SEP_GROUP_ALL_ITEMS, + OUT_REQ_HEAD, OUT_REQ_BODY, OUT_RESP_HEAD, + OUT_RESP_BODY, OUTPUT_OPTIONS, + OUTPUT_OPTIONS_DEFAULT, PRETTY_MAP, + PRETTY_STDOUT_TTY_ONLY, SessionNameValidator, + readable_file_arg, SSL_VERSION_ARG_MAPPING +) class HTTPieHelpFormatter(RawDescriptionHelpFormatter): @@ -414,8 +417,8 @@ sessions.add_argument( auth = parser.add_argument_group(title='Authentication') auth.add_argument( '--auth', '-a', + default=None, metavar='USER[:PASS]', - type=AuthCredentialsArgType(SEP_CREDENTIALS), help=""" If only the username is provided (-a username), HTTPie will prompt for the password. @@ -423,10 +426,21 @@ auth.add_argument( """, ) + +class _AuthTypeLazyChoices(object): + # Needed for plugin testing + + def __contains__(self, item): + return item in plugin_manager.get_auth_plugin_mapping() + + def __iter__(self): + return iter(plugin_manager.get_auth_plugins()) + + _auth_plugins = plugin_manager.get_auth_plugins() auth.add_argument( '--auth-type', '-A', - choices=[plugin.auth_type for plugin in _auth_plugins], + choices=_AuthTypeLazyChoices(), default=_auth_plugins[0].auth_type, help=""" The authentication mechanism to be used. Defaults to "{default}". diff --git a/httpie/client.py b/httpie/client.py index fb0fc62c..894a40d7 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -145,11 +145,6 @@ def get_requests_kwargs(args, base_headers=None): headers.update(args.headers) headers = finalize_headers(headers) - credentials = None - if args.auth: - auth_plugin = plugin_manager.get_auth_plugin(args.auth_type)() - credentials = auth_plugin.get_auth(args.auth.key, args.auth.value) - cert = None if args.cert: cert = args.cert @@ -168,7 +163,7 @@ def get_requests_kwargs(args, base_headers=None): }.get(args.verify, args.verify), 'cert': cert, 'timeout': args.timeout, - 'auth': credentials, + 'auth': args.auth, 'proxies': dict((p.key, p.value) for p in args.proxy), 'files': args.files, 'allow_redirects': args.follow, diff --git a/httpie/input.py b/httpie/input.py index 99a4849c..a323e4ee 100644 --- a/httpie/input.py +++ b/httpie/input.py @@ -15,6 +15,7 @@ from argparse import ArgumentParser, ArgumentTypeError, ArgumentError # TODO: Use MultiDict for headers once added to `requests`. # https://github.com/jkbrzt/httpie/issues/130 +from httpie.plugins import plugin_manager from requests.structures import CaseInsensitiveDict from httpie.compat import OrderedDict, urlsplit, str, is_pypy, is_py27 @@ -214,31 +215,56 @@ class HTTPieArgumentParser(ArgumentParser): self.env.stdout_isatty = False def _process_auth(self): - """ - If only a username provided via --auth, then ask for a password. - Or, take credentials from the URL, if provided. - - """ + # TODO: refactor + self.args.auth_plugin = None + default_auth_plugin = plugin_manager.get_auth_plugins()[0] + auth_type_set = self.args.auth_type != default_auth_plugin.auth_type url = urlsplit(self.args.url) - if self.args.auth: - if not self.args.auth.has_password(): - # Stdin already read (if not a tty) so it's save to prompt. - if self.args.ignore_stdin: - self.error('Unable to prompt for passwords because' - ' --ignore-stdin is set.') - self.args.auth.prompt_password(url.netloc) + if self.args.auth is None and not auth_type_set: + if url.username is not None: + # Handle http://username:password@hostname/ + username = url.username + password = url.password or '' + self.args.auth = AuthCredentials( + key=username, + value=password, + sep=SEP_CREDENTIALS, + orig=SEP_CREDENTIALS.join([username, password]) + ) - elif url.username is not None: - # Handle http://username:password@hostname/ - username = url.username - password = url.password or '' - self.args.auth = AuthCredentials( - key=username, - value=password, - sep=SEP_CREDENTIALS, - orig=SEP_CREDENTIALS.join([username, password]) - ) + if self.args.auth is not None or auth_type_set: + plugin = plugin_manager.get_auth_plugin(self.args.auth_type)() + + if plugin.auth_require and self.args.auth is None: + self.error('--auth required') + + plugin.raw_auth = self.args.auth + self.args.auth_plugin = plugin + already_parsed = isinstance(self.args.auth, AuthCredentials) + + if self.args.auth is None or not plugin.auth_parse: + self.args.auth = plugin.get_auth() + else: + if already_parsed: + # from the URL + credentials = self.args.auth + else: + credentials = parse_auth(self.args.auth) + + if (not credentials.has_password() + and plugin.prompt_password): + if self.args.ignore_stdin: + # Non-tty stdin read by now + self.error( + 'Unable to prompt for passwords because' + ' --ignore-stdin is set.' + ) + credentials.prompt_password(url.netloc) + self.args.auth = plugin.get_auth( + username=credentials.key, + password=credentials.value, + ) def _apply_no_options(self, no_options): """For every `--no-OPTION` in `no_options`, set `args.OPTION` to @@ -578,6 +604,9 @@ class AuthCredentialsArgType(KeyValueArgType): ) +parse_auth = AuthCredentialsArgType(SEP_CREDENTIALS) + + class RequestItemsDict(OrderedDict): """Multi-value dict for URL parameters and form data.""" diff --git a/httpie/plugins/base.py b/httpie/plugins/base.py index ff6e6a20..3129a80c 100644 --- a/httpie/plugins/base.py +++ b/httpie/plugins/base.py @@ -17,13 +17,36 @@ class AuthPlugin(BasePlugin): See for an example auth plugin. + See also `test_auth_plugins.py` + """ # The value that should be passed to --auth-type # to use this auth plugin. Eg. "my-auth" auth_type = None - def get_auth(self, username, password): + # Set to `False` to make it possible to invoke this auth + # plugin without requiring the user to specify credentials + # through `--auth, -a`. + auth_require = True + + # By default the `-a` argument is parsed for `username:password`. + # Set this to `False` to disable the parsing and error handling. + auth_parse = True + + # If both `auth_parse` and `prompt_password` are set to `True`, + # and the value of `-a` lacks the password part, + # then the user will be prompted to type the password in. + prompt_password = True + + # Will be set to the raw value of `-a` (if provided) before + # `get_auth()` gets called. + raw_auth = None + + def get_auth(self, username=None, password=None): """ + If `auth_parse` is set to `True`, then `username` + and `password` contain the parsed credentials. + Return a ``requests.auth.AuthBase`` subclass instance. """ diff --git a/httpie/plugins/builtin.py b/httpie/plugins/builtin.py index 0e3aa26d..6b9640d1 100644 --- a/httpie/plugins/builtin.py +++ b/httpie/plugins/builtin.py @@ -36,6 +36,7 @@ class BasicAuthPlugin(BuiltinAuthPlugin): name = 'Basic HTTP auth' auth_type = 'basic' + # noinspection PyMethodOverriding def get_auth(self, username, password): return HTTPBasicAuth(username, password) @@ -45,5 +46,6 @@ class DigestAuthPlugin(BuiltinAuthPlugin): name = 'Digest HTTP auth' auth_type = 'digest' + # noinspection PyMethodOverriding def get_auth(self, username, password): return requests.auth.HTTPDigestAuth(username, password) diff --git a/httpie/plugins/manager.py b/httpie/plugins/manager.py index 239d32ae..f7a2a063 100644 --- a/httpie/plugins/manager.py +++ b/httpie/plugins/manager.py @@ -24,6 +24,9 @@ class PluginManager(object): for plugin in plugins: self._plugins.append(plugin) + def unregister(self, plugin): + self._plugins.remove(plugin) + def load_installed_plugins(self): for entry_point_name in ENTRY_POINT_NAMES: for entry_point in iter_entry_points(entry_point_name): diff --git a/httpie/sessions.py b/httpie/sessions.py index 249b78ba..ff3969be 100644 --- a/httpie/sessions.py +++ b/httpie/sessions.py @@ -51,11 +51,10 @@ def get_response(requests_session, session_name, dump_request(kwargs) session.update_headers(kwargs['headers']) - if args.auth: + if args.auth_plugin: session.auth = { - 'type': args.auth_type, - 'username': args.auth.key, - 'password': args.auth.value, + 'type': args.auth_plugin.auth_type, + 'raw_auth': args.auth_plugin.raw_auth, } elif session.auth: kwargs['auth'] = session.auth @@ -147,10 +146,31 @@ class Session(BaseConfigDict): auth = self.get('auth', None) if not auth or not auth['type']: return - auth_plugin = plugin_manager.get_auth_plugin(auth['type'])() - return auth_plugin.get_auth(auth['username'], auth['password']) + + plugin = plugin_manager.get_auth_plugin(auth['type'])() + + credentials = {'username': None, 'password': None} + try: + # New style + plugin.raw_auth = auth['raw_auth'] + except KeyError: + # Old style + credentials = { + 'username': auth['username'], + 'password': auth['password'], + } + else: + if plugin.auth_parse: + from httpie.input import parse_auth + parsed = parse_auth(plugin.raw_auth) + credentials = { + 'username': parsed.key, + 'password': parsed.value, + } + + return plugin.get_auth(**credentials) @auth.setter def auth(self, auth): - assert set(['type', 'username', 'password']) == set(auth.keys()) + assert set(['type', 'raw_auth']) == set(auth.keys()) self['auth'] = auth diff --git a/tests/test_auth.py b/tests/test_auth.py index 1d7395b6..c279e1cd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -60,5 +60,5 @@ def test_only_username_in_url(url): """ args = httpie.cli.parser.parse_args(args=[url], env=TestEnvironment()) assert args.auth - assert args.auth.key == 'username' - assert args.auth.value == '' + assert args.auth.username == 'username' + assert args.auth.password == '' diff --git a/tests/test_auth_plugins.py b/tests/test_auth_plugins.py new file mode 100644 index 00000000..233c599b --- /dev/null +++ b/tests/test_auth_plugins.py @@ -0,0 +1,89 @@ +from utils import http, HTTP_OK +from httpie.plugins import AuthPlugin, plugin_manager + +# TODO: run all these tests in session mode as well + +# Basic auth encoded 'username' and 'password' +BASIC_AUTH_HEADER_VALUE = 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' +BASIC_AUTH_URL = '/basic-auth/username/password' + + +def dummy_auth(auth_header=BASIC_AUTH_HEADER_VALUE): + + def inner(r): + r.headers['Authorization'] = auth_header + return r + + return inner + + +def test_auth_plugin_parse_false(httpbin): + + class ParseFalseAuthPlugin(AuthPlugin): + auth_type = 'parse-false' + auth_parse = False + + def get_auth(self, username=None, password=None): + assert username is None + assert password is None + assert self.raw_auth == BASIC_AUTH_HEADER_VALUE + return dummy_auth(self.raw_auth) + + plugin_manager.register(ParseFalseAuthPlugin) + try: + r = http( + httpbin + BASIC_AUTH_URL, + '--auth-type', 'parse-false', + '--auth', BASIC_AUTH_HEADER_VALUE + ) + assert HTTP_OK in r + finally: + plugin_manager.unregister(ParseFalseAuthPlugin) + + +def test_auth_plugin_require_false(httpbin): + + class RequireFalseAuthPlugin(AuthPlugin): + auth_type = 'require-false' + auth_require = False + + def get_auth(self, username=None, password=None): + assert self.raw_auth is None + assert username is None + assert password is None + return dummy_auth() + + plugin_manager.register(RequireFalseAuthPlugin) + try: + r = http( + httpbin + BASIC_AUTH_URL, + '--auth-type', 'require-false', + ) + assert HTTP_OK in r + finally: + plugin_manager.unregister(RequireFalseAuthPlugin) + + +def test_auth_plugin_prompt_false(httpbin): + + class PromptFalseAuthPlugin(AuthPlugin): + auth_type = 'prompt-false' + prompt_password = False + + def get_auth(self, username=None, password=None): + assert self.raw_auth == 'username:' + assert username == 'username' + assert password == '' + return dummy_auth() + + plugin_manager.register(PromptFalseAuthPlugin) + + try: + r = http( + httpbin + BASIC_AUTH_URL, + '--auth-type', 'prompt-false', + '--auth', 'username:' + ) + assert HTTP_OK in r + finally: + plugin_manager.unregister(PromptFalseAuthPlugin)