From a91c76503fb1d325f1b1acbe222e05184f19571e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 8 Mar 2017 13:35:35 +0000 Subject: [PATCH 1/4] Add support for auth schemes --- coreapi/__init__.py | 6 +- coreapi/auth.py | 69 +++++++++++++++++++++++ coreapi/client.py | 13 +++-- coreapi/compat.py | 2 + coreapi/transports/http.py | 111 ++++++++++++++++++++++++++----------- coreapi/utils.py | 14 +++++ docs/api-guide/auth.md | 84 ++++++++++++++++++++++++++++ docs/api-guide/client.md | 23 +++++--- docs/index.md | 7 ++- mkdocs.yml | 1 + 10 files changed, 283 insertions(+), 47 deletions(-) create mode 100644 coreapi/auth.py create mode 100644 docs/api-guide/auth.md diff --git a/coreapi/__init__.py b/coreapi/__init__.py index bfed1a4..5f069ba 100644 --- a/coreapi/__init__.py +++ b/coreapi/__init__.py @@ -1,12 +1,12 @@ # coding: utf-8 -from coreapi import codecs, exceptions, transports, utils +from coreapi import auth, codecs, exceptions, transports, utils from coreapi.client import Client from coreapi.document import Array, Document, Link, Object, Error, Field -__version__ = '2.2.4' +__version__ = '2.3.0' __all__ = [ 'Array', 'Document', 'Link', 'Object', 'Error', 'Field', 'Client', - 'codecs', 'exceptions', 'transports', 'utils', + 'auth', 'codecs', 'exceptions', 'transports', 'utils', ] diff --git a/coreapi/auth.py b/coreapi/auth.py new file mode 100644 index 0000000..c3fee4a --- /dev/null +++ b/coreapi/auth.py @@ -0,0 +1,69 @@ +from coreapi.utils import domain_matches +from requests.auth import AuthBase, HTTPBasicAuth + + +class BasicAuthentication(HTTPBasicAuth): + allow_cookies = False + + def __init__(self, username, password, domain=None): + self.domain = domain + super(BasicAuthentication, self).__init__(username, password) + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + return super(BasicAuthentication, self).__call__(request) + + +class TokenAuthentication(AuthBase): + allow_cookies = False + prefix = 'Bearer' + + def __init__(self, token, prefix=None, domain=None): + """ + * Use an unauthenticated client, and make a request to obtain a token. + * Create an authenticated client using eg. `TokenAuthentication(token="")` + """ + self.token = token + self.domain = domain + if prefix is not None: + self.prefix = prefix + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + request.headers['Authorization'] = '%s %s' % (self.prefix, self.token) + return request + + +class SessionAuthentication(AuthBase): + """ + Enables session based login. + + * Make an initial request to obtain a CSRF token. + * Make a login request. + """ + allow_cookies = True + safe_methods = ('GET', 'HEAD', 'OPTIONS', 'TRACE') + + def __init__(self, csrf_cookie_name=None, csrf_header_name=None, domain=None): + self.csrf_cookie_name = csrf_cookie_name + self.csrf_header_name = csrf_header_name + self.csrf_token = None + self.domain = domain + + def store_csrf_token(self, response, **kwargs): + if self.csrf_cookie_name in response.cookies: + self.csrf_token = response.cookies[self.csrf_cookie_name] + + def __call__(self, request): + if not domain_matches(request, self.domain): + return request + + if self.csrf_token and self.csrf_header_name is not None and (request.method not in self.safe_methods): + request.headers[self.csrf_header_name] = self.csrf_token + if self.csrf_cookie_name is not None: + request.register_hook('response', self.store_csrf_token) + return request diff --git a/coreapi/client.py b/coreapi/client.py index e6ef9ce..d02a59c 100644 --- a/coreapi/client.py +++ b/coreapi/client.py @@ -89,18 +89,23 @@ def get_default_decoders(): ] -def get_default_transports(): +def get_default_transports(auth=None, session=None): return [ - transports.HTTPTransport() + transports.HTTPTransport(auth=auth, session=session) ] class Client(itypes.Object): - def __init__(self, decoders=None, transports=None): + def __init__(self, decoders=None, transports=None, auth=None, session=None): + assert transports is None or auth is None, ( + "Cannot specify both 'auth' and 'transports'. " + "When specifying transport instances explicitly you should set " + "the authentication directly on the transport." + ) if decoders is None: decoders = get_default_decoders() if transports is None: - transports = get_default_transports() + transports = get_default_transports(auth=auth) self._decoders = itypes.List(decoders) self._transports = itypes.List(transports) diff --git a/coreapi/compat.py b/coreapi/compat.py index 6d06c2f..9890ec1 100644 --- a/coreapi/compat.py +++ b/coreapi/compat.py @@ -12,6 +12,7 @@ try: # Python 2 import urlparse + import cookielib as cookiejar string_types = (basestring,) text_type = unicode @@ -26,6 +27,7 @@ def b64encode(input_string): # Python 3 import urllib.parse as urlparse from io import IOBase + from http import cookiejar string_types = (str,) text_type = str diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 2973914..694b731 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from collections import OrderedDict from coreapi import exceptions, utils -from coreapi.compat import urlparse +from coreapi.compat import cookiejar, urlparse from coreapi.document import Document, Object, Link, Array, Error from coreapi.transports.base import BaseTransport from coreapi.utils import guess_filename, is_file, File @@ -11,6 +11,7 @@ import itypes import mimetypes import uritemplate +import warnings Params = collections.namedtuple('Params', ['path', 'query', 'data', 'files']) @@ -18,9 +19,11 @@ class ForceMultiPartDict(dict): - # A dictionary that always evaluates as True. - # Allows us to force requests to use multipart encoding, even when no - # file parameters are passed. + """ + A dictionary that always evaluates as True. + Allows us to force requests to use multipart encoding, even when no + file parameters are passed. + """ def __bool__(self): return True @@ -28,6 +31,55 @@ def __nonzero__(self): return True +class BlockAll(cookiejar.CookiePolicy): + """ + A cookie policy that rejects all cookies. + Used to override the default `requests` behavior. + """ + return_ok = set_ok = domain_return_ok = path_return_ok = lambda self, *args, **kwargs: False + netscape = True + rfc2965 = hide_cookie2 = False + + +class DomainCredentials(requests.auth.AuthBase): + """ + Custom auth class to support deprecated 'credentials' argument. + """ + allow_cookies = False + credentials = None + + def __init__(self, credentials=None): + self.credentials = credentials + + def __call__(self, request): + if not self.credentials: + return request + + # Include any authorization credentials relevant to this domain. + url_components = urlparse.urlparse(request.url) + host = url_components.hostname + if host in self.credentials: + request.headers['Authorization'] = self.credentials[host] + return request + + +class CallbackAdapter(requests.adapters.HTTPAdapter): + """ + Custom requests HTTP adapter, to support deprecated callback arguments. + """ + def __init__(self, request_callback=None, response_callback=None): + self.request_callback = request_callback + self.response_callback = response_callback + + def send(self, request, **kwargs): + if self.request_callback is not None: + self.request_callback(request) + response = super(CallbackAdapter, self).send(request, **kwargs) + if self.response_callback is not None: + self.response_callback(response) + return response + + def _get_method(action): if not action: return 'GET' @@ -107,7 +159,7 @@ def _get_url(url, path_params): return url -def _get_headers(url, decoders, credentials=None): +def _get_headers(url, decoders): """ Return a dictionary of HTTP headers to use in the outgoing request. """ @@ -120,13 +172,6 @@ def _get_headers(url, decoders, credentials=None): 'user-agent': 'coreapi' } - if credentials: - # Include any authorization credentials relevant to this domain. - url_components = urlparse.urlparse(url) - host = url_components.hostname - if host in credentials: - headers['authorization'] = credentials[host] - return headers @@ -288,24 +333,34 @@ def _handle_inplace_replacements(document, link, link_ancestors): class HTTPTransport(BaseTransport): schemes = ['http', 'https'] - def __init__(self, credentials=None, headers=None, session=None, request_callback=None, response_callback=None): + def __init__(self, credentials=None, headers=None, auth=None, session=None, request_callback=None, response_callback=None): if headers: headers = {key.lower(): value for key, value in headers.items()} if session is None: session = requests.Session() - self._credentials = itypes.Dict(credentials or {}) + if auth is not None: + session.auth = auth + if not getattr(session.auth, 'allow_cookies', False): + session.cookies.set_policy(BlockAll()) + + if credentials is not None: + warnings.warn( + "The 'credentials' argument is now deprecated in favor of 'auth'.", + DeprecationWarning + ) + auth = DomainCredentials(credentials) + if request_callback is not None or response_callback is not None: + warnings.warn( + "The 'request_callback' and 'response_callback' arguments are now deprecated. " + "Use a custom 'session' instance instead.", + DeprecationWarning + ) + session.mount('https://', CallbackAdapter(request_callback, response_callback)) + session.mount('http://', CallbackAdapter(request_callback, response_callback)) + self._headers = itypes.Dict(headers or {}) self._session = session - # Fallback for v1.x overrides. - # Will be removed at some point, most likely in a 2.1 release. - self._request_callback = request_callback - self._response_callback = response_callback - - @property - def credentials(self): - return self._credentials - @property def headers(self): return self._headers @@ -316,19 +371,11 @@ def transition(self, link, decoders, params=None, link_ancestors=None, force_cod encoding = _get_encoding(link.encoding) params = _get_params(method, encoding, link.fields, params) url = _get_url(link.url, params.path) - headers = _get_headers(url, decoders, self.credentials) + headers = _get_headers(url, decoders) headers.update(self.headers) request = _build_http_request(session, url, method, headers, encoding, params) - - if self._request_callback is not None: - self._request_callback(request) - response = session.send(request) - - if self._response_callback is not None: - self._response_callback(response) - result = _decode_result(response, decoders, force_codec) if isinstance(result, Document) and link_ancestors: diff --git a/coreapi/utils.py b/coreapi/utils.py index 2b049d5..4283583 100644 --- a/coreapi/utils.py +++ b/coreapi/utils.py @@ -6,6 +6,20 @@ import tempfile +def domain_matches(request, domain): + """ + Domain string matching against an outgoing request. + Patterns starting with '*' indicate a wildcard domain. + """ + if (domain is None) or (domain == '*'): + return True + + host = urlparse.urlparse(request.url).hostname + if domain.startswith('*'): + return host.endswith(domain[1:]) + return host == domain + + def get_installed_codecs(): packages = [ (package, package.load()) for package in diff --git a/docs/api-guide/auth.md b/docs/api-guide/auth.md new file mode 100644 index 0000000..9ebc68f --- /dev/null +++ b/docs/api-guide/auth.md @@ -0,0 +1,84 @@ +# Authentication + +Authentication instances are responsible for handling the network authentication. + +## Using authentication + +Typically, you'll provide authentication configuration by passing an authentication instance to the client. + + import coreapi + + auth = coreapi.auth.BasicAuthentication(username='...', password='...') + coreapi.Client(auth=auth) + +It's recommended that you limit authentication scheme to only provide credentials to endpoints that match the expected domain. + + auth = coreapi.auth.BasicAuthentication( + username='...', + password='...', + domain='api.example.com' + ) + +You can also provide wildcard domains: + + auth = coreapi.auth.BasicAuthentication( + username='...', + password='...', + domain='*.example.com' + ) + +--- + +## Available authentication schemes + +The following authentication schemes are provided as built-in options... + +### BasicAuthentication + +Uses [HTTP Basic Authentication][basic-auth]. + +**Signature**: `BasicAuthentication(username, password, domain='*')` + +### TokenAuthentication + +Uses [HTTP Bearer token authentication][bearer-auth], and can be used for OAuth 2, JWT, and custom token authentication schemes. + +Outgoing requests will include the provided token in the request`Authorization` headers, in the following format: + + Authorization: Bearer xxxx-xxxxxxxx-xxxx + +The prefix may be customized if required, in order to support HTTP authentication schemes that are not [officially registered][http-auth-schemes]. + +A typical authentication flow using `TokenAuthentication` would be: + +* Using an unauthenticated client make a request providing the users credentials to an endpoint to that returns an API token. +* Instantiate an authenticated client using the returned token, and use this for all future requests. + +**Signature**: `TokenAuthentication(token, prefix='Bearer', domain='*')` + +### SessionAuthentication + +This authentication scheme enables cookies in order to allow a session cookie to be saved and maintained throughout the client's session. + +In order to support CSRF protected sessions, this scheme also supports saving CSRF tokens in the incoming response cookies, and mirroring those tokens back to the server by using a CSRF header in any subsequent outgoing requests. + +A typical authentication flow using `SessionAuthentication` would be: + +* Using an unauthenticated client make an initial request to an endpoint that returns a CSRF cookie. +* Use the unauthenticated client to make a request to a login endpoint, providing the users credentials. +* Subsequent requests by the client will now be authenticated. + +**Signature**: `SessionAuthentication(csrf_cookie_name=None, csrf_header_name=None, domain='*')` + +--- + +## Custom authentication + +Custom authentication classes may be created by subclassing `requests.AuthBase`, and implmenting the following: + +* Set the `allow_cookies` class attribute to either `True` or `False`. +* Provide a `__call__(self, request)` method, which should return an authenticated request instance. + +[basic-auth]: https://tools.ietf.org/html/rfc7617 +[bearer-auth]: https://tools.ietf.org/html/rfc6750 +[http-auth-schemes]: https://www.iana.org/assignments/http-authschemes/ \ No newline at end of file diff --git a/docs/api-guide/client.md b/docs/api-guide/client.md index ea07c80..e08926f 100644 --- a/docs/api-guide/client.md +++ b/docs/api-guide/client.md @@ -30,20 +30,29 @@ A client instance holds the configuration about which transports are available for making network requests, and which codecs are available for decoding the content of network responses. -This configuration is set by passing either or both of the `decoders` and -`transports` arguments. The signature of the `Client` class is: +The signature of the `Client` class is: - Client(decoders=None, transports=None) + Client(decoders=None, transports=None, auth=None, session=None) -For example the following would instantiate a client that is capable of -decoding either Core JSON schema responses, or decoding plain JSON +Arguments: + +* `decoders` - A list of decoder instances for decoding the content of responses. +* `transports` - A list of transport instances available for making network requests. +* `auth` - A authentication instance. Used when instantiating the default HTTP transport. +* `session` - A `requests` session instance. Used when instantiating the default HTTP transport. + +For example the following would instantiate a client, authenticated using HTTP basic auth, that is capable of decoding either Core JSON schema responses, or decoding plain JSON data responses: + from coreapi import codecs + from coreapi.auth import BasicAuthentication + decoders = [ codecs.CoreJSONCodec(), codecs.JSONCodec() ] - client = Client(decoders=decoders) + auth = BasicAuthentication(domain='*', username='example', password='xxx') + client = Client(decoders=decoders, auth=auth) When no arguments are passed, the following defaults are used: @@ -55,7 +64,7 @@ When no arguments are passed, the following defaults are used: ] transports = [ - transports.HTTPTransport() # http, https + transports.HTTPTransport(auth=auth, session=session) # http, https ] The configured decoders and transports are made available as read-only diff --git a/docs/index.md b/docs/index.md index efb36bd..51886bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,11 @@ Interact with the API: 'date': '2016-10-12' }) +Creating an authenticated client instance: + + auth = coreapi.auth.TokenAuthentication(token='xxxx-xxxxxxxx-xxxx') + client = Client(auth=auth) + ## Supported formats The following schema and hypermedia formats are currently supported, either @@ -53,7 +58,7 @@ Other media | `*/*` | Returns a temporary download file. ## License -Copyright © 2015-2016, Tom Christie. +Copyright © 2015-2017, Tom Christie. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/mkdocs.yml b/mkdocs.yml index 3e99c5a..3575753 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ pages: - API Guide: - Clients: api-guide/client.md - Documents: api-guide/document.md + - Authentication: api-guide/auth.md - Codecs: api-guide/codecs.md - Transports: api-guide/transports.md - Exceptions: api-guide/exceptions.md From f719334dfda8c0200dac47d3ef44eb8f5b94fde3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 8 Mar 2017 13:39:58 +0000 Subject: [PATCH 2/4] Improve error titles --- coreapi/transports/http.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coreapi/transports/http.py b/coreapi/transports/http.py index 694b731..a548024 100644 --- a/coreapi/transports/http.py +++ b/coreapi/transports/http.py @@ -299,7 +299,8 @@ def _decode_result(response, decoders, force_codec=False): # Coerce 4xx and 5xx codes into errors. is_error = response.status_code >= 400 and response.status_code <= 599 if is_error and not isinstance(result, Error): - result = _coerce_to_error(result, default_title=response.reason) + default_title = '%d %s' % (response.status_code, response.reason) + result = _coerce_to_error(result, default_title=default_title) return result From 704337e5c67ad73526d80e8849528a8f381fb9e8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Mar 2017 09:18:38 +0000 Subject: [PATCH 3/4] Use 'scheme' not 'prefix' for TokenAuthentication. --- coreapi/auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coreapi/auth.py b/coreapi/auth.py index c3fee4a..e110547 100644 --- a/coreapi/auth.py +++ b/coreapi/auth.py @@ -18,23 +18,23 @@ def __call__(self, request): class TokenAuthentication(AuthBase): allow_cookies = False - prefix = 'Bearer' + scheme = 'Bearer' - def __init__(self, token, prefix=None, domain=None): + def __init__(self, token, scheme=None, domain=None): """ * Use an unauthenticated client, and make a request to obtain a token. * Create an authenticated client using eg. `TokenAuthentication(token="")` """ self.token = token self.domain = domain - if prefix is not None: - self.prefix = prefix + if scheme is not None: + self.scheme = scheme def __call__(self, request): if not domain_matches(request, self.domain): return request - request.headers['Authorization'] = '%s %s' % (self.prefix, self.token) + request.headers['Authorization'] = '%s %s' % (self.scheme, self.token) return request From 8cfad1d30845b1e6f99785c1ddede4a283725701 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 9 Mar 2017 09:19:48 +0000 Subject: [PATCH 4/4] Tweak docs --- docs/api-guide/auth.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/auth.md b/docs/api-guide/auth.md index 9ebc68f..c4210e4 100644 --- a/docs/api-guide/auth.md +++ b/docs/api-guide/auth.md @@ -6,7 +6,7 @@ Authentication instances are responsible for handling the network authentication Typically, you'll provide authentication configuration by passing an authentication instance to the client. - import coreapi + import coreapi auth = coreapi.auth.BasicAuthentication(username='...', password='...') coreapi.Client(auth=auth) @@ -43,18 +43,18 @@ Uses [HTTP Basic Authentication][basic-auth]. Uses [HTTP Bearer token authentication][bearer-auth], and can be used for OAuth 2, JWT, and custom token authentication schemes. -Outgoing requests will include the provided token in the request`Authorization` headers, in the following format: +Outgoing requests will include the provided token in the request `Authorization` headers, in the following format: Authorization: Bearer xxxx-xxxxxxxx-xxxx -The prefix may be customized if required, in order to support HTTP authentication schemes that are not [officially registered][http-auth-schemes]. +The scheme name may be customized if required, in order to support HTTP authentication schemes that are not [officially registered][http-auth-schemes]. A typical authentication flow using `TokenAuthentication` would be: * Using an unauthenticated client make a request providing the users credentials to an endpoint to that returns an API token. * Instantiate an authenticated client using the returned token, and use this for all future requests. -**Signature**: `TokenAuthentication(token, prefix='Bearer', domain='*')` +**Signature**: `TokenAuthentication(token, scheme='Bearer', domain='*')` ### SessionAuthentication @@ -81,4 +81,4 @@ Custom authentication classes may be created by subclassing `requests.AuthBase`, [basic-auth]: https://tools.ietf.org/html/rfc7617 [bearer-auth]: https://tools.ietf.org/html/rfc6750 -[http-auth-schemes]: https://www.iana.org/assignments/http-authschemes/ \ No newline at end of file +[http-auth-schemes]: https://www.iana.org/assignments/http-authschemes/