diff options
Diffstat (limited to 'macaroonbakery/httpbakery/_client.py')
-rw-r--r-- | macaroonbakery/httpbakery/_client.py | 408 |
1 files changed, 408 insertions, 0 deletions
diff --git a/macaroonbakery/httpbakery/_client.py b/macaroonbakery/httpbakery/_client.py new file mode 100644 index 0000000..d877140 --- /dev/null +++ b/macaroonbakery/httpbakery/_client.py @@ -0,0 +1,408 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +import json +import logging + +import macaroonbakery.bakery as bakery +import macaroonbakery.checkers as checkers +import macaroonbakery._utils as utils +from ._browser import WebBrowserInteractor +from ._error import ( + BAKERY_PROTOCOL_HEADER, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + DischargeError, + Error, + InteractionError, + InteractionMethodNotFound, +) +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + LegacyInteractor, +) + +import requests +from six.moves.http_cookies import SimpleCookie +from six.moves.urllib.parse import urljoin + +TIME_OUT = 30 +MAX_DISCHARGE_RETRIES = 3 + +log = logging.getLogger('httpbakery') + + +class BakeryException(requests.RequestException): + '''Raised when some errors happen using the httpbakery + authorizer''' + + +class Client: + '''Client holds the context for making HTTP requests with macaroons. + To make a request, use the auth method to obtain + an HTTP authorizer suitable for passing as the auth parameter + to a requests method. Note that the same cookie jar + should be passed to requests as is used to initialize + the client. + For example: + import macaroonbakery.httpbakery + client = httpbakery.Client() + resp = requests.get('some protected url', + cookies=client.cookies, + auth=client.auth()) + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client {bakery.PrivateKey} + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. If not provided, one + will be created. + ''' + def __init__(self, interaction_methods=None, key=None, cookies=None): + if interaction_methods is None: + interaction_methods = [WebBrowserInteractor()] + if cookies is None: + cookies = requests.cookies.RequestsCookieJar() + self._interaction_methods = interaction_methods + self.key = key + self.cookies = cookies + + def auth(self): + '''Return an authorizer object suitable for passing + to requests methods that accept one. + If a request returns a discharge-required error, + the authorizer will acquire discharge macaroons + and retry the request. + ''' + return _BakeryAuth(self) + + def request(self, method, url, **kwargs): + '''Use the requests library to make a request. + Using this method is like doing: + + requests.request(method, url, auth=client.auth()) + ''' + # TODO should we raise an exception if auth or cookies are explicitly + # mentioned in kwargs? + kwargs['auth'] = self.auth() + kwargs['cookies'] = self.cookies + return requests.request(method=method, url=url, **kwargs) + + def handle_error(self, error, url): + '''Try to resolve the given error, which should be a response + to the given URL, by discharging any macaroon contained in + it. That is, if error.code is ERR_DISCHARGE_REQUIRED + then it will try to discharge err.info.macaroon. If the discharge + succeeds, the discharged macaroon will be saved to the client's cookie + jar, otherwise an exception will be raised. + ''' + if error.info is None or error.info.macaroon is None: + raise BakeryException('unable to read info in discharge error ' + 'response') + + discharges = bakery.discharge_all( + error.info.macaroon, + self.acquire_discharge, + self.key, + ) + macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, + discharges)) + ']' + all_macaroons = base64.urlsafe_b64encode(utils.to_bytes(macaroons)) + + full_path = relative_url(url, error.info.macaroon_path) + if error.info.cookie_name_suffix is not None: + name = 'macaroon-' + error.info.cookie_name_suffix + else: + name = 'macaroon-auth' + expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges) + self.cookies.set_cookie(utils.cookie( + name=name, + value=all_macaroons.decode('ascii'), + url=full_path, + expires=expires, + )) + + def acquire_discharge(self, cav, payload): + ''' Request a discharge macaroon from the caveat location + as an HTTP URL. + @param cav Third party {pymacaroons.Caveat} to be discharged. + @param payload External caveat data {bytes}. + @return The acquired macaroon {macaroonbakery.Macaroon} + ''' + resp = self._acquire_discharge_with_token(cav, payload, None) + # TODO Fabrice what is the other http response possible ?? + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + cause = Error.from_dict(resp.json()) + if cause.code != ERR_INTERACTION_REQUIRED: + raise DischargeError(cause.message) + if cause.info is None: + raise DischargeError( + 'interaction-required response with no info: {}'.format( + resp.json()) + ) + loc = cav.location + if not loc.endswith('/'): + loc = loc + '/' + token, m = self._interact(loc, cause, payload) + if m is not None: + # We've acquired the macaroon directly via legacy interaction. + return m + # Try to acquire the discharge again, but this time with + # the token acquired by the interaction method. + resp = self._acquire_discharge_with_token(cav, payload, token) + if resp.status_code == 200: + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + else: + raise DischargeError( + 'discharge failed with code {}'.format(resp.status_code)) + + def _acquire_discharge_with_token(self, cav, payload, token): + req = {} + _add_json_binary_field(cav.caveat_id_bytes, req, 'id') + if token is not None: + _add_json_binary_field(token.value, req, 'token') + req['token-kind'] = token.kind + if payload is not None: + req['caveat64'] = base64.urlsafe_b64encode(payload).rstrip( + b'=').decode('utf-8') + target = relative_url(cav.location, 'discharge') + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + return self.request('POST', target, data=req, headers=headers) + + def _interact(self, location, error_info, payload): + '''Gathers a macaroon by directing the user to interact with a + web page. The error_info argument holds the interaction-required + error response. + @return DischargeToken, bakery.Macaroon + ''' + if (self._interaction_methods is None or + len(self._interaction_methods) == 0): + raise InteractionError('interaction required but not possible') + # TODO(rogpeppe) make the robust against a wider range of error info. + if error_info.info.interaction_methods is None and \ + error_info.info.visit_url is not None: + # It's an old-style error; deal with it differently. + return None, self._legacy_interact(location, error_info) + for interactor in self._interaction_methods: + found = error_info.info.interaction_methods.get(interactor.kind()) + if found is None: + continue + try: + token = interactor.interact(self, location, error_info) + except InteractionMethodNotFound: + continue + if token is None: + raise InteractionError('interaction method returned an empty ' + 'token') + return token, None + + raise InteractionError('no supported interaction method') + + def _legacy_interact(self, location, error_info): + visit_url = relative_url(location, error_info.info.visit_url) + wait_url = relative_url(location, error_info.info.wait_url) + method_urls = { + "interactive": visit_url + } + if (len(self._interaction_methods) > 1 or + self._interaction_methods[0].kind() != + WEB_BROWSER_INTERACTION_KIND): + # We have several possible methods or we only support a non-window + # method, so we need to fetch the possible methods supported by + # the discharger. + method_urls = _legacy_get_interaction_methods(visit_url) + for interactor in self._interaction_methods: + kind = interactor.kind() + if kind == WEB_BROWSER_INTERACTION_KIND: + # This is the old name for browser-window interaction. + kind = "interactive" + + if not isinstance(interactor, LegacyInteractor): + # Legacy interaction mode isn't supported. + continue + + visit_url = method_urls.get(kind) + if visit_url is None: + continue + + visit_url = relative_url(location, visit_url) + interactor.legacy_interact(self, location, visit_url) + return _wait_for_macaroon(wait_url) + + raise InteractionError('no methods supported; supported [{}]; provided [{}]'.format( + ' '.join([x.kind() for x in self._interaction_methods]), + ' '.join(method_urls.keys()), + )) + + +class _BakeryAuth: + '''_BakeryAuth implements an authorizer as required + by the requests HTTP client. + ''' + def __init__(self, client): + ''' + @param interaction_methods A list of Interactor implementations. + @param key The private key of the client (macaroonbakery.PrivateKey) + @param cookies storage for the cookies {CookieJar}. It should be the + same as in the requests cookies. + ''' + self._client = client + + def __call__(self, req): + req.headers[BAKERY_PROTOCOL_HEADER] = str(bakery.LATEST_VERSION) + hook = _prepare_discharge_hook(req.copy(), self._client) + req.register_hook(event='response', hook=hook) + return req + + +def _prepare_discharge_hook(req, client): + ''' Return the hook function (called when the response is received.) + + This allows us to intercept the response and do any necessary + macaroon discharge before returning. + ''' + class Retry: + # Define a local class so that we can use its class variable as + # mutable state accessed by the closures below. + count = 0 + + def hook(response, *args, **kwargs): + ''' Requests hooks system, this is the hook for the response. + ''' + status_code = response.status_code + + if status_code != 407 and status_code != 401: + return response + if (status_code == 401 and response.headers.get('WWW-Authenticate') != + 'Macaroon'): + return response + + if response.headers.get('Content-Type') != 'application/json': + return response + errorJSON = response.json() + if errorJSON.get('Code') != ERR_DISCHARGE_REQUIRED: + return response + error = Error.from_dict(errorJSON) + Retry.count += 1 + if Retry.count >= MAX_DISCHARGE_RETRIES: + raise BakeryException('too many ({}) discharge requests'.format( + Retry.count) + ) + client.handle_error(error, req.url) + # Replace the private _cookies from req as it is a copy of + # the original cookie jar passed into the requests method and we need + # to set the cookie for this request. + req._cookies = client.cookies + req.headers.pop('Cookie', None) + req.prepare_cookies(req._cookies) + req.headers[BAKERY_PROTOCOL_HEADER] = \ + str(bakery.LATEST_VERSION) + with requests.Session() as s: + return s.send(req) + return hook + + +def extract_macaroons(headers_or_request): + ''' Returns an array of any macaroons found in the given slice of cookies. + If the argument implements a get_header method, that will be used + instead of the get method to retrieve headers. + @param headers_or_request: dict of headers or a + urllib.request.Request-like object. + @return: A list of list of mpy macaroons + ''' + def get_header(key, default=None): + try: + return headers_or_request.get_header(key, default) + except AttributeError: + return headers_or_request.get(key, default) + + mss = [] + + def add_macaroon(data): + data = utils.b64decode(data) + data_as_objs = json.loads(data.decode('utf-8')) + ms = [utils.macaroon_from_dict(x) for x in data_as_objs] + mss.append(ms) + + cookie_header = get_header('Cookie') + if cookie_header is not None: + cs = SimpleCookie() + # The cookie might be a unicode object, so convert it + # to ASCII. This may cause an exception under Python 2. + # TODO is that a problem? + cs.load(str(cookie_header)) + for c in cs: + if c.startswith('macaroon-'): + add_macaroon(cs[c].value) + # Python doesn't make it easy to have multiple values for a + # key, so split the header instead, which is necessary + # for HTTP1.1 compatibility anyway (see RFC 7230, section 3.2.2) + macaroon_header = get_header('Macaroons') + if macaroon_header is not None: + for h in macaroon_header.split(','): + add_macaroon(h) + return mss + + +def _add_json_binary_field(b, serialized, field): + '''' Set the given field to the given val (bytes) in the serialized + dictionary. + If the value isn't valid utf-8, we base64 encode it and use field+"64" + as the field name. + ''' + try: + val = b.decode('utf-8') + serialized[field] = val + except UnicodeDecodeError: + val = base64.b64encode(b).decode('utf-8') + serialized[field + '64'] = val + + +def _wait_for_macaroon(wait_url): + ''' Returns a macaroon from a legacy wait endpoint. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=wait_url, headers=headers) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_url)) + + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) + + +def relative_url(base, new): + ''' Returns new path relative to an original URL. + ''' + if new == '': + return base + if not base.endswith('/'): + base += '/' + return urljoin(base, new) + + +def _legacy_get_interaction_methods(u): + ''' Queries a URL as found in an ErrInteractionRequired VisitURL field to + find available interaction methods. + It does this by sending a GET request to the URL with the Accept + header set to "application/json" and parsing the resulting + response as a dict. + ''' + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION), + 'Accept': 'application/json' + } + resp = requests.get(url=u, headers=headers) + method_urls = {} + if resp.status_code == 200: + json_resp = resp.json() + for m in json_resp: + method_urls[m] = relative_url(u, json_resp[m]) + + if method_urls.get('interactive') is None: + # There's no "interactive" method returned, but we know + # the server does actually support it, because all dischargers + # are required to, so fill it in with the original URL. + method_urls['interactive'] = u + return method_urls |