diff options
Diffstat (limited to 'macaroonbakery/httpbakery')
-rw-r--r-- | macaroonbakery/httpbakery/__init__.py | 12 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_browser.py (renamed from macaroonbakery/httpbakery/browser.py) | 17 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_client.py (renamed from macaroonbakery/httpbakery/client.py) | 111 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_discharge.py (renamed from macaroonbakery/httpbakery/discharge.py) | 7 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_error.py (renamed from macaroonbakery/httpbakery/error.py) | 10 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_interactor.py (renamed from macaroonbakery/httpbakery/interactor.py) | 9 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/_keyring.py (renamed from macaroonbakery/httpbakery/keyring.py) | 6 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/agent/__init__.py | 8 | ||||
-rw-r--r-- | macaroonbakery/httpbakery/agent/_agent.py (renamed from macaroonbakery/httpbakery/agent/agent.py) | 115 |
9 files changed, 161 insertions, 134 deletions
diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py index 3f183c5..07a805b 100644 --- a/macaroonbakery/httpbakery/__init__.py +++ b/macaroonbakery/httpbakery/__init__.py @@ -1,11 +1,11 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.client import ( +from ._client import ( BakeryException, Client, extract_macaroons, ) -from macaroonbakery.httpbakery.error import ( +from ._error import ( BAKERY_PROTOCOL_HEADER, DischargeError, ERR_DISCHARGE_REQUIRED, @@ -17,18 +17,18 @@ from macaroonbakery.httpbakery.error import ( discharge_required_response, request_version, ) -from macaroonbakery.httpbakery.keyring import ThirdPartyLocator -from macaroonbakery.httpbakery.interactor import ( +from ._keyring import ThirdPartyLocator +from ._interactor import ( DischargeToken, Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND, ) -from macaroonbakery.httpbakery.browser import ( +from ._browser import ( WebBrowserInteractionInfo, WebBrowserInteractor, ) -from macaroonbakery.httpbakery.discharge import discharge +from ._discharge import discharge __all__ = [ 'BAKERY_PROTOCOL_HEADER', diff --git a/macaroonbakery/httpbakery/browser.py b/macaroonbakery/httpbakery/_browser.py index e3ce538..a1ccbb0 100644 --- a/macaroonbakery/httpbakery/browser.py +++ b/macaroonbakery/httpbakery/_browser.py @@ -2,15 +2,18 @@ # Licensed under the LGPLv3, see LICENCE file for details. import base64 from collections import namedtuple -import requests -from six.moves.urllib.parse import urljoin -from macaroonbakery.utils import visit_page_with_browser -from macaroonbakery.httpbakery.interactor import ( - Interactor, LegacyInteractor, WEB_BROWSER_INTERACTION_KIND, - DischargeToken +import requests +from ._error import InteractionError +from ._interactor import ( + WEB_BROWSER_INTERACTION_KIND, + DischargeToken, + Interactor, + LegacyInteractor, ) -from macaroonbakery.httpbakery.error import InteractionError +from macaroonbakery._utils import visit_page_with_browser + +from six.moves.urllib.parse import urljoin class WebBrowserInteractor(Interactor, LegacyInteractor): diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/_client.py index b3036a1..d877140 100644 --- a/macaroonbakery/httpbakery/client.py +++ b/macaroonbakery/httpbakery/_client.py @@ -2,31 +2,35 @@ # Licensed under the LGPLv3, see LICENCE file for details. import base64 import json -import requests -from six.moves.http_cookies import SimpleCookie -from six.moves.urllib.parse import urljoin +import logging -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery import macaroonbakery.checkers as checkers -from macaroonbakery import utils -from macaroonbakery.httpbakery.interactor import ( - LegacyInteractor, - WEB_BROWSER_INTERACTION_KIND, -) -from macaroonbakery.httpbakery.error import ( - DischargeError, +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 macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER -from macaroonbakery.httpbakery.browser import WebBrowserInteractor +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 @@ -58,7 +62,7 @@ class Client: if cookies is None: cookies = requests.cookies.RequestsCookieJar() self._interaction_methods = interaction_methods - self._key = key + self.key = key self.cookies = cookies def auth(self): @@ -76,7 +80,10 @@ class Client: requests.request(method, url, auth=client.auth()) ''' - kwargs.setdefault('auth', self.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): @@ -84,18 +91,20 @@ class Client: 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. + 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') + raise BakeryException('unable to read info in discharge error ' + 'response') discharges = bakery.discharge_all( error.info.macaroon, self.acquire_discharge, - self._key, + self.key, ) - macaroons = '[' + ','.join(map(utils.macaroon_to_json_string, discharges)) + ']' + 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) @@ -104,7 +113,6 @@ class Client: else: name = 'macaroon-auth' expires = checkers.macaroons_expiry_time(checkers.Namespace(), discharges) - expires = None # TODO(rogpeppe) remove this line after fixing the tests. self.cookies.set_cookie(utils.cookie( name=name, value=all_macaroons.decode('ascii'), @@ -128,7 +136,8 @@ class Client: raise DischargeError(cause.message) if cause.info is None: raise DischargeError( - 'interaction-required response with no info: {}'.format(resp.json()) + 'interaction-required response with no info: {}'.format( + resp.json()) ) loc = cav.location if not loc.endswith('/'): @@ -141,10 +150,10 @@ class Client: # the token acquired by the interaction method. resp = self._acquire_discharge_with_token(cav, payload, token) if resp.status_code == 200: - return bakery.Macaroon.deserialize_json( - resp.json().get('Macaroon')) + return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) else: - raise DischargeError() + raise DischargeError( + 'discharge failed with code {}'.format(resp.status_code)) def _acquire_discharge_with_token(self, cav, payload, token): req = {} @@ -167,14 +176,14 @@ class Client: error response. @return DischargeToken, bakery.Macaroon ''' - if self._interaction_methods is None or len(self._interaction_methods) == 0: + 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: @@ -184,7 +193,8 @@ class Client: except InteractionMethodNotFound: continue if token is None: - raise InteractionError('interaction method returned an empty token') + raise InteractionError('interaction method returned an empty ' + 'token') return token, None raise InteractionError('no supported interaction method') @@ -195,13 +205,13 @@ class Client: method_urls = { "interactive": visit_url } - if len(self._interaction_methods) > 1 or \ - self._interaction_methods[0].kind() != WEB_BROWSER_INTERACTION_KIND: + 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: @@ -220,7 +230,10 @@ class Client: interactor.legacy_interact(self, location, visit_url) return _wait_for_macaroon(wait_url) - raise InteractionError('no methods supported') + raise InteractionError('no methods supported; supported [{}]; provided [{}]'.format( + ' '.join([x.kind() for x in self._interaction_methods]), + ' '.join(method_urls.keys()), + )) class _BakeryAuth: @@ -290,11 +303,20 @@ def _prepare_discharge_hook(req, client): return hook -def extract_macaroons(headers): +def extract_macaroons(headers_or_request): ''' Returns an array of any macaroons found in the given slice of cookies. - @param headers: dict of headers - @return: An array of array of mpy macaroons + 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): @@ -303,22 +325,22 @@ def extract_macaroons(headers): ms = [utils.macaroon_from_dict(x) for x in data_as_objs] mss.append(ms) - cookieHeader = headers.get('Cookie') - if cookieHeader is not None: + 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(cookieHeader)) + 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. - macaroonHeader = headers.get('Macaroons') - if macaroonHeader is not None: - for h in macaroonHeader.split(','): + # 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 @@ -345,7 +367,7 @@ def _wait_for_macaroon(wait_url): } resp = requests.get(url=wait_url, headers=headers) if resp.status_code != 200: - return InteractionError('cannot get {}'.format(wait_url)) + raise InteractionError('cannot get {}'.format(wait_url)) return bakery.Macaroon.from_dict(resp.json().get('Macaroon')) @@ -376,8 +398,7 @@ def _legacy_get_interaction_methods(u): if resp.status_code == 200: json_resp = resp.json() for m in json_resp: - relative_url(u, json_resp[m]) - method_urls[m] = relative_url(u, json_resp[m]) + 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 diff --git a/macaroonbakery/httpbakery/discharge.py b/macaroonbakery/httpbakery/_discharge.py index ef3481a..f868d23 100644 --- a/macaroonbakery/httpbakery/discharge.py +++ b/macaroonbakery/httpbakery/_discharge.py @@ -1,7 +1,7 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import macaroonbakery.utils as utils -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery +import macaroonbakery._utils as utils def discharge(ctx, content, key, locator, checker): @@ -11,7 +11,8 @@ def discharge(ctx, content, key, locator, checker): @param content URL and form parameters {dict} @param locator Locator used to add third party caveats returned by the checker {macaroonbakery.ThirdPartyLocator} - @param checker Used to check third party caveats {macaroonbakery.ThirdPartyCaveatChecker} + @param checker {macaroonbakery.ThirdPartyCaveatChecker} Used to check third + party caveats. @return The discharge macaroon {macaroonbakery.Macaroon} ''' id = content.get('id') diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/_error.py index 422b346..ff75f13 100644 --- a/macaroonbakery/httpbakery/error.py +++ b/macaroonbakery/httpbakery/_error.py @@ -1,9 +1,9 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from collections import namedtuple import json +from collections import namedtuple -import macaroonbakery as bakery +import macaroonbakery.bakery as bakery ERR_INTERACTION_REQUIRED = 'interaction required' ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' @@ -19,7 +19,8 @@ class InteractionMethodNotFound(Exception): class DischargeError(Exception): '''This is thrown by Client when a third party has refused a discharge''' def __init__(self, msg): - super(DischargeError, self).__init__('third party refused discharge: {}'.format(msg)) + super(DischargeError, self).__init__( + 'third party refused discharge: {}'.format(msg)) class InteractionError(Exception): @@ -27,7 +28,8 @@ class InteractionError(Exception): interaction-required error ''' def __init__(self, msg): - super(InteractionError, self).__init__('cannot start interactive session: {}'.format(msg)) + super(InteractionError, self).__init__( + 'cannot start interactive session: {}'.format(msg)) def discharge_required_response(macaroon, path, cookie_suffix_name, diff --git a/macaroonbakery/httpbakery/interactor.py b/macaroonbakery/httpbakery/_interactor.py index 0c15338..7fba4ef 100644 --- a/macaroonbakery/httpbakery/interactor.py +++ b/macaroonbakery/httpbakery/_interactor.py @@ -18,8 +18,7 @@ class Interactor(object): the Error.interaction_methods type. @return {str} ''' - raise NotImplementedError('kind method must be defined in ' - 'subclass') + raise NotImplementedError('kind method must be defined in subclass') def interact(self, client, location, interaction_required_err): ''' Performs the interaction, and returns a token that can be @@ -37,8 +36,7 @@ class Interactor(object): take place {Error} @return {DischargeToken} The discharge token. ''' - raise NotImplementedError('interact method must be defined in ' - 'subclass') + raise NotImplementedError('interact method must be defined in subclass') class LegacyInteractor(object): @@ -59,8 +57,7 @@ class LegacyInteractor(object): @param visit_url The visit_url field from the error {str} @return None ''' - raise NotImplementedError('legacy_interact method must be defined in ' - 'subclass') + raise NotImplementedError('legacy_interact method must be defined in subclass') class DischargeToken(namedtuple('DischargeToken', 'kind, value')): diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/_keyring.py index 01a4349..8d9ab43 100644 --- a/macaroonbakery/httpbakery/keyring.py +++ b/macaroonbakery/httpbakery/_keyring.py @@ -1,10 +1,10 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from six.moves.urllib.parse import urlparse +import macaroonbakery.bakery as bakery import requests +from ._error import BAKERY_PROTOCOL_HEADER -import macaroonbakery as bakery -from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER +from six.moves.urllib.parse import urlparse class ThirdPartyLocator(bakery.ThirdPartyLocator): diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py index db252de..c0a7523 100644 --- a/macaroonbakery/httpbakery/agent/__init__.py +++ b/macaroonbakery/httpbakery/agent/__init__.py @@ -1,8 +1,9 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.agent.agent import ( - load_agent_file, +from ._agent import ( + load_auth_info, + read_auth_info, Agent, AgentInteractor, AgentFileFormatError, @@ -13,5 +14,6 @@ __all__ = [ 'AgentFileFormatError', 'AgentInteractor', 'AuthInfo', - 'load_agent_file', + 'load_auth_info', + 'read_auth_info', ] diff --git a/macaroonbakery/httpbakery/agent/agent.py b/macaroonbakery/httpbakery/agent/_agent.py index ad56015..b717261 100644 --- a/macaroonbakery/httpbakery/agent/agent.py +++ b/macaroonbakery/httpbakery/agent/_agent.py @@ -1,20 +1,18 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -import base64 -from collections import namedtuple +import copy import json +import logging +from collections import namedtuple -import nacl.public -import nacl.encoding -import nacl.exceptions +import macaroonbakery.bakery as bakery +import macaroonbakery.httpbakery as httpbakery +import macaroonbakery._utils as utils import requests.cookies -import six -from six.moves.urllib.parse import urlparse + from six.moves.urllib.parse import urljoin -import macaroonbakery as bakery -import macaroonbakery.utils as utils -import macaroonbakery.httpbakery as httpbakery +log = logging.getLogger(__name__) class AgentFileFormatError(Exception): @@ -24,40 +22,41 @@ class AgentFileFormatError(Exception): pass -def load_agent_file(filename, cookies=None): - ''' Loads agent information from the specified file. - - The agent cookies are added to cookies, or a newly created cookie jar - if cookies is not specified. The updated cookies is returned along - with the private key associated with the agent. These can be passed - directly as the cookies and key parameter to BakeryAuth. +def load_auth_info(filename): + '''Loads agent authentication information from the specified file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param filename The name of the file to open (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. ''' - with open(filename) as f: - data = json.load(f) + return read_auth_info(f.read()) + + +def read_auth_info(agent_file_content): + '''Loads agent authentication information from the + specified content string, as read from an agents file. + The returned information is suitable for passing as an argument + to the AgentInteractor constructor. + @param agent_file_content The agent file content (str) + @return AuthInfo The authentication information + @raises AgentFileFormatError when the file format is bad. + ''' try: - key = nacl.public.PrivateKey( - data['key']['private'], - nacl.encoding.Base64Encoder, + data = json.loads(agent_file_content) + return AuthInfo( + key=bakery.PrivateKey.deserialize(data['key']['private']), + agents=list( + Agent(url=a['url'], username=a['username']) + for a in data.get('agents', []) + ), ) - if cookies is None: - cookies = requests.cookies.RequestsCookieJar() - for agent in data['agents']: - u = urlparse(agent['url']) - value = {'username': agent['username'], - 'public_key': data['key']['public']} - jv = json.dumps(value) - if six.PY3: - jv = jv.encode('utf-8') - v = base64.b64encode(jv) - if six.PY3: - v = v.decode('utf-8') - cookie = requests.cookies.create_cookie('agent-login', v, - domain=u.netloc, - path=u.path) - cookies.set_cookie(cookie) - return cookies, key - except (KeyError, ValueError, nacl.exceptions.TypeError) as e: + except ( + KeyError, + ValueError, + TypeError, + ) as e: raise AgentFileFormatError('invalid agent file', e) @@ -110,9 +109,10 @@ class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): if not location.endswith('/'): location += '/' login_url = urljoin(location, p.login_url) + # TODO use client to make the request. resp = requests.get(login_url, json={ 'Username': agent.username, - 'PublicKey': self._auth_info.key.encode().decode('utf-8'), + 'PublicKey': str(self._auth_info.key), }) if resp.status_code != 200: raise httpbakery.InteractionError( @@ -144,30 +144,31 @@ class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): the discharge macaroon using the client's private key ''' agent = self._find_agent(location) - pk_encoded = self._auth_info.key.public_key.encode().decode('utf-8') - value = { - 'username': agent.username, - 'public_key': pk_encoded, - } - # TODO(rogpeppe) use client passed into interact method. - client = httpbakery.Client(key=self._auth_info.key) - client.cookies.set_cookie(utils.cookie( + # Shallow-copy the client so that we don't unexpectedly side-effect + # it by changing the key. Another possibility might be to + # set up agent authentication differently, in such a way that + # we're sure that client.key is the same as self._auth_info.key. + client = copy.copy(client) + client.key = self._auth_info.key + resp = client.request( + method='POST', url=visit_url, - name='agent-login', - value=base64.urlsafe_b64encode( - json.dumps(value).encode('utf-8')).decode('utf-8'), - )) - resp = requests.get(url=visit_url, cookies=client.cookies, auth=client.auth()) + json={ + 'username': agent.username, + 'public_key': str(self._auth_info.key.public_key), + }, + ) if resp.status_code != 200: raise httpbakery.InteractionError( - 'cannot acquire agent macaroon: {}'.format(resp.status_code)) - if not resp.json().get('agent-login', False): + 'cannot acquire agent macaroon from {}: {} (response body: {!r})'.format(visit_url, resp.status_code, resp.text)) + if not resp.json().get('agent_login', False): raise httpbakery.InteractionError('agent login failed') class Agent(namedtuple('Agent', 'url, username')): ''' Represents an agent that can be used for agent authentication. - @param url holds the URL of the discharger that knows about the agent (string). + @param url(string) holds the URL of the discharger that knows about + the agent. @param username holds the username agent (string). ''' |