From 37d61d0415f6cc96a7a9abe057e1ae0f89fd977e Mon Sep 17 00:00:00 2001 From: Colin Watson Date: Mon, 6 Nov 2017 10:04:48 +0000 Subject: Import py-macaroon-bakery_0.0.5.orig.tar.gz --- macaroonbakery/httpbakery/__init__.py | 48 ++- macaroonbakery/httpbakery/agent.py | 52 ---- macaroonbakery/httpbakery/agent/__init__.py | 17 ++ macaroonbakery/httpbakery/agent/agent.py | 180 +++++++++++ macaroonbakery/httpbakery/browser.py | 86 ++++++ macaroonbakery/httpbakery/client.py | 442 ++++++++++++++++++++-------- macaroonbakery/httpbakery/discharge.py | 33 +++ macaroonbakery/httpbakery/error.py | 151 +++++++++- macaroonbakery/httpbakery/interactor.py | 73 +++++ macaroonbakery/httpbakery/keyring.py | 26 +- 10 files changed, 913 insertions(+), 195 deletions(-) delete mode 100644 macaroonbakery/httpbakery/agent.py create mode 100644 macaroonbakery/httpbakery/agent/__init__.py create mode 100644 macaroonbakery/httpbakery/agent/agent.py create mode 100644 macaroonbakery/httpbakery/browser.py create mode 100644 macaroonbakery/httpbakery/discharge.py create mode 100644 macaroonbakery/httpbakery/interactor.py (limited to 'macaroonbakery/httpbakery') diff --git a/macaroonbakery/httpbakery/__init__.py b/macaroonbakery/httpbakery/__init__.py index 3b40dc2..3f183c5 100644 --- a/macaroonbakery/httpbakery/__init__.py +++ b/macaroonbakery/httpbakery/__init__.py @@ -1,17 +1,55 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. -from macaroonbakery.httpbakery.client import BakeryAuth, extract_macaroons +from macaroonbakery.httpbakery.client import ( + BakeryException, + Client, + extract_macaroons, +) from macaroonbakery.httpbakery.error import ( - BAKERY_PROTOCOL_HEADER, discharged_required_response, request_version + BAKERY_PROTOCOL_HEADER, + DischargeError, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + Error, + ErrorInfo, + InteractionError, + InteractionMethodNotFound, + discharge_required_response, + request_version, ) from macaroonbakery.httpbakery.keyring import ThirdPartyLocator - +from macaroonbakery.httpbakery.interactor import ( + DischargeToken, + Interactor, + LegacyInteractor, + WEB_BROWSER_INTERACTION_KIND, +) +from macaroonbakery.httpbakery.browser import ( + WebBrowserInteractionInfo, + WebBrowserInteractor, +) +from macaroonbakery.httpbakery.discharge import discharge __all__ = [ 'BAKERY_PROTOCOL_HEADER', - 'BakeryAuth', + 'BakeryException', + 'Client', + 'DischargeError', + 'DischargeToken', + 'ERR_DISCHARGE_REQUIRED', + 'ERR_INTERACTION_REQUIRED', + 'Error', + 'ErrorInfo', + 'InteractionError', + 'InteractionMethodNotFound', + 'Interactor', + 'LegacyInteractor', 'ThirdPartyLocator', - 'discharged_required_response', + 'WEB_BROWSER_INTERACTION_KIND', + 'WebBrowserInteractionInfo', + 'WebBrowserInteractor', + 'discharge', + 'discharge_required_response', 'extract_macaroons', 'request_version', ] diff --git a/macaroonbakery/httpbakery/agent.py b/macaroonbakery/httpbakery/agent.py deleted file mode 100644 index e5a09e4..0000000 --- a/macaroonbakery/httpbakery/agent.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2017 Canonical Ltd. -# Licensed under the LGPLv3, see LICENCE file for details. -import base64 -import json - -import nacl.public -import nacl.encoding -import requests.cookies -import six -from six.moves.urllib.parse import urlparse - - -class AgentFileFormatError(Exception): - ''' AgentFileFormatError is the exception raised when an agent file has a bad - structure. - ''' - 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. - ''' - - with open(filename) as f: - data = json.load(f) - try: - key = nacl.public.PrivateKey(data['key']['private'], - nacl.encoding.Base64Encoder) - 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) as e: - raise AgentFileFormatError('invalid agent file', e) diff --git a/macaroonbakery/httpbakery/agent/__init__.py b/macaroonbakery/httpbakery/agent/__init__.py new file mode 100644 index 0000000..db252de --- /dev/null +++ b/macaroonbakery/httpbakery/agent/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. + +from macaroonbakery.httpbakery.agent.agent import ( + load_agent_file, + Agent, + AgentInteractor, + AgentFileFormatError, + AuthInfo, +) +__all__ = [ + 'Agent', + 'AgentFileFormatError', + 'AgentInteractor', + 'AuthInfo', + 'load_agent_file', +] diff --git a/macaroonbakery/httpbakery/agent/agent.py b/macaroonbakery/httpbakery/agent/agent.py new file mode 100644 index 0000000..862f00e --- /dev/null +++ b/macaroonbakery/httpbakery/agent/agent.py @@ -0,0 +1,180 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import base64 +from collections import namedtuple +import json + +import nacl.public +import nacl.encoding +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 + + +class AgentFileFormatError(Exception): + ''' AgentFileFormatError is the exception raised when an agent file has a + bad structure. + ''' + 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. + ''' + + with open(filename) as f: + data = json.load(f) + try: + key = nacl.public.PrivateKey(data['key']['private'], + nacl.encoding.Base64Encoder) + 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) as e: + raise AgentFileFormatError('invalid agent file', e) + + +class InteractionInfo(object): + '''Holds the information expected in the agent interaction entry in an + interaction-required error. + ''' + def __init__(self, login_url): + self._login_url = login_url + + @property + def login_url(self): + ''' Return the URL from which to acquire a macaroon that can be used + to complete the agent login. To acquire the macaroon, make a POST + request to the URL with user and public-key parameters. + :return string + ''' + return self._login_url + + @classmethod + def from_dict(cls, json_dict): + '''Return an InteractionInfo obtained from the given dictionary as + deserialized from JSON. + @param json_dict The deserialized JSON object. + ''' + return InteractionInfo(json_dict.get('login-url')) + + +class AgentInteractor(httpbakery.Interactor, httpbakery.LegacyInteractor): + ''' Interactor that performs interaction using the agent login protocol. + ''' + def __init__(self, auth_info): + self._auth_info = auth_info + + def kind(self): + '''Implement Interactor.kind by returning the agent kind''' + return 'agent' + + def interact(self, client, location, interaction_required_err): + '''Implement Interactor.interact by obtaining obtaining + a macaroon from the discharger, discharging it with the + local private key using the discharged macaroon as + a discharge token''' + p = interaction_required_err.interaction_method('agent', + InteractionInfo) + if p.login_url is None or p.login_url == '': + raise httpbakery.InteractionError( + 'no login-url field found in agent interaction method') + agent = self._find_agent(location) + if not location.endswith('/'): + location += '/' + login_url = urljoin(location, p.login_url) + resp = requests.get(login_url, json={ + 'Username': agent.username, + 'PublicKey': self._auth_info.key.encode().decode('utf-8'), + }) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon: {}'.format(resp.status_code) + ) + m = resp.json().get('macaroon') + if m is None: + raise httpbakery.InteractionError('no macaroon in response') + m = bakery.Macaroon.from_dict(m) + ms = bakery.discharge_all(m, None, self._auth_info.key) + b = bytearray() + for m in ms: + b.extend(utils.b64decode(m.serialize())) + return httpbakery.DischargeToken(kind='agent', value=bytes(b)) + + def _find_agent(self, location): + ''' Finds an appropriate agent entry for the given location. + :return Agent + ''' + for a in self._auth_info.agents: + # Don't worry about trailing slashes + if a.url.rstrip('/') == location.rstrip('/'): + return a + raise httpbakery.InteractionMethodNotFound( + 'cannot find username for discharge location {}'.format(location)) + + def legacy_interact(self, client, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by obtaining + 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( + 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()) + if resp.status_code != 200: + raise httpbakery.InteractionError( + 'cannot acquire agent macaroon: {}'.format(resp.status_code)) + 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 username holds the username agent (string). + ''' + + +class AuthInfo(namedtuple('AuthInfo', 'key, agents')): + ''' Holds the agent information required to set up agent authentication + information. + + It holds the agent's private key and information about the username + associated with each known agent-authentication server. + @param key the agent's private key (bakery.PrivateKey). + @param agents information about the known agents (list of Agent). + ''' diff --git a/macaroonbakery/httpbakery/browser.py b/macaroonbakery/httpbakery/browser.py new file mode 100644 index 0000000..e3ce538 --- /dev/null +++ b/macaroonbakery/httpbakery/browser.py @@ -0,0 +1,86 @@ +# Copyright 2017 Canonical Ltd. +# 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 +) +from macaroonbakery.httpbakery.error import InteractionError + + +class WebBrowserInteractor(Interactor, LegacyInteractor): + ''' Handles web-browser-based interaction-required errors by opening a + web browser to allow the user to prove their credentials interactively. + ''' + def __init__(self, open=visit_page_with_browser): + '''Create a WebBrowserInteractor that uses the given function + to open a browser window. The open function is expected to take + a single argument of string type, the URL to open. + ''' + self._open_web_browser = open + + def kind(self): + return WEB_BROWSER_INTERACTION_KIND + + def legacy_interact(self, ctx, location, visit_url): + '''Implement LegacyInteractor.legacy_interact by opening the + web browser window''' + self._open_web_browser(visit_url) + + def interact(self, ctx, location, ir_err): + '''Implement Interactor.interact by opening the browser window + and waiting for the discharge token''' + p = ir_err.interaction_method(self.kind(), WebBrowserInteractionInfo) + if not location.endswith('/'): + location += '/' + visit_url = urljoin(location, p.visit_url) + wait_token_url = urljoin(location, p.wait_token_url) + self._open_web_browser(visit_url) + return self._wait_for_token(ctx, wait_token_url) + + def _wait_for_token(self, ctx, wait_token_url): + ''' Returns a token from a the wait token URL + @param wait_token_url URL to wait for (string) + :return DischargeToken + ''' + resp = requests.get(wait_token_url) + if resp.status_code != 200: + raise InteractionError('cannot get {}'.format(wait_token_url)) + json_resp = resp.json() + kind = json_resp.get('kind') + if kind is None: + raise InteractionError( + 'cannot get kind token from {}'.format(wait_token_url)) + token_val = json_resp.get('token') + if token_val is None: + token_val = json_resp.get('token64') + if token_val is None: + raise InteractionError( + 'cannot get token from {}'.format(wait_token_url)) + token_val = base64.b64decode(token_val) + return DischargeToken(kind=kind, value=token_val) + + +class WebBrowserInteractionInfo(namedtuple('WebBrowserInteractionInfo', + 'visit_url, wait_token_url')): + ''' holds the information expected in the browser-window interaction + entry in an interaction-required error. + + :param visit_url holds the URL to be visited in a web browser. + :param wait_token_url holds a URL that will block on GET until the browser + interaction has completed. + ''' + @classmethod + def from_dict(cls, info_dict): + '''Create a new instance of WebBrowserInteractionInfo, as expected + by the Error.interaction_method method. + @param info_dict The deserialized JSON object + @return a new WebBrowserInteractionInfo object. + ''' + return WebBrowserInteractionInfo(visit_url=info_dict.get('VisitURL'), + wait_token_url=info_dict('WaitURL')) diff --git a/macaroonbakery/httpbakery/client.py b/macaroonbakery/httpbakery/client.py index b62c61d..b3036a1 100644 --- a/macaroonbakery/httpbakery/client.py +++ b/macaroonbakery/httpbakery/client.py @@ -4,65 +4,246 @@ import base64 import json import requests from six.moves.http_cookies import SimpleCookie -from six.moves.http_cookiejar import Cookie from six.moves.urllib.parse import urljoin -from six.moves.urllib.parse import urlparse -from pymacaroons import Macaroon -from pymacaroons.serializers.json_serializer import JsonSerializer - -from macaroonbakery.bakery import discharge_all +import macaroonbakery 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, + ERR_DISCHARGE_REQUIRED, + ERR_INTERACTION_REQUIRED, + Error, + InteractionError, + InteractionMethodNotFound, +) +from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER +from macaroonbakery.httpbakery.browser import WebBrowserInteractor -ERR_INTERACTION_REQUIRED = 'interaction required' -ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' TIME_OUT = 30 MAX_DISCHARGE_RETRIES = 3 -class BakeryAuth: - ''' BakeryAuth holds the context for making HTTP requests with macaroons. +class BakeryException(requests.RequestException): + '''Raised when some errors happen using the httpbakery + authorizer''' + - This will automatically acquire and discharge macaroons around the - requests framework. - Usage: - from macaroonbakery import httpbakery - jar = requests.cookies.RequestsCookieJar() - resp = requests.get('some protected url', - cookies=jar, - auth=httpbakery.BakeryAuth(cookies=jar)) - resp.raise_for_status() +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, visit_page=None, key=None, - cookies=requests.cookies.RequestsCookieJar()): + 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) - @param visit_page function called when the discharge process requires - further interaction taking a visit_url string as parameter. - @param key holds the client's private nacl key. If set, the client - will try to discharge third party caveats with the special location - "local" by using this key. - @param cookies storage for the cookies {CookieJar}. It should be the - same than in the requests cookies + 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()) ''' - if visit_page is None: - visit_page = utils.visit_page_with_browser - if 'agent-login' in cookies.keys(): - self._visit_page = _visit_page_for_agent(cookies, key) + kwargs.setdefault('auth', self.auth()) + 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: - self._visit_page = visit_page - self._jar = cookies - self._key = key + 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'), + 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.deserialize_json( + resp.json().get('Macaroon')) + else: + raise DischargeError() + + 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') + + +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-Version'] = '1' - hook = _prepare_discharge_hook(req.copy(), self._key, self._jar, - self._visit_page) + 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, key, jar, visit_page): +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 @@ -76,106 +257,131 @@ def _prepare_discharge_hook(req, key, jar, visit_page): def hook(response, *args, **kwargs): ''' Requests hooks system, this is the hook for the response. ''' - status_401 = (response.status_code == 401 - and response.headers.get('WWW-Authenticate') == - 'Macaroon') - if not status_401 and response.status_code != 407: + status_code = response.status_code + + if status_code != 407 and status_code != 401: return response - if response.headers.get('Content-Type') != 'application/json': + if (status_code == 401 and response.headers.get('WWW-Authenticate') != + 'Macaroon'): return response - try: - error = response.json() - except: - raise BakeryException( - 'unable to read discharge error response') - if error.get('Code') != ERR_DISCHARGE_REQUIRED: + 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 discharges') - info = error.get('Info') - if not isinstance(info, dict): - raise BakeryException( - 'unable to read info in discharge error response') - serialized_macaroon = info.get('Macaroon') - if not isinstance(serialized_macaroon, dict): - raise BakeryException( - 'unable to read macaroon in discharge error response') - - macaroon = utils.deserialize(serialized_macaroon) - discharges = discharge_all(macaroon, visit_page, jar, key) - encoded_discharges = map(utils.serialize_macaroon_string, discharges) - - macaroons = '[' + ','.join(encoded_discharges) + ']' - all_macaroons = base64.urlsafe_b64encode( - macaroons.encode('utf-8')).decode('ascii') - - full_path = urljoin(response.url, - info['MacaroonPath']) - parsed_url = urlparse(full_path) - if info and info.get('CookieNameSuffix'): - name = 'macaroon-' + info['CookieNameSuffix'] - else: - name = 'macaroon-' + discharges[0].signature - cookie = Cookie( - version=0, - name=name, - value=all_macaroons, - port=None, - port_specified=False, - domain=parsed_url[1], - domain_specified=True, - domain_initial_dot=False, - path=parsed_url[2], - path_specified=True, - secure=False, - expires=None, - discard=False, - comment=None, - comment_url=None, - rest=None, - rfc2109=False) - jar.set_cookie(cookie) + 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 = jar + req._cookies = client.cookies req.headers.pop('Cookie', None) req.prepare_cookies(req._cookies) - req.headers['Bakery-Protocol-Version'] = '1' + req.headers[BAKERY_PROTOCOL_HEADER] = \ + str(bakery.LATEST_VERSION) with requests.Session() as s: return s.send(req) return hook -class BakeryException(requests.RequestException): - ''' Bakery exception ''' - - -def _visit_page_for_agent(cookies, key): - def visit_page_for_agent(visit_url): - resp = requests.get(visit_url, cookies=cookies, - auth=BakeryAuth(cookies=cookies, key=key)) - resp.raise_for_status() - return visit_page_for_agent - - def extract_macaroons(headers): ''' 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 ''' - cookie_string = "\n".join(headers.get_all('Cookie', failobj=[])) - cs = SimpleCookie() - cs.load(cookie_string) mss = [] - for c in cs: - if not c.startswith('macaroon-'): - continue - data = base64.b64decode(cs[c].value) + + def add_macaroon(data): + data = utils.b64decode(data) data_as_objs = json.loads(data.decode('utf-8')) - ms = [Macaroon.deserialize(json.dumps(x), serializer=JsonSerializer()) - for x in data_as_objs] + ms = [utils.macaroon_from_dict(x) for x in data_as_objs] mss.append(ms) + + cookieHeader = headers.get('Cookie') + if cookieHeader 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)) + 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(','): + 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: + return 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: + 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 + # 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 diff --git a/macaroonbakery/httpbakery/discharge.py b/macaroonbakery/httpbakery/discharge.py new file mode 100644 index 0000000..ef3481a --- /dev/null +++ b/macaroonbakery/httpbakery/discharge.py @@ -0,0 +1,33 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import macaroonbakery.utils as utils +import macaroonbakery as bakery + + +def discharge(ctx, content, key, locator, checker): + '''Handles a discharge request as received by the /discharge + endpoint. + @param ctx The context passed to the checker {checkers.AuthContext} + @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} + @return The discharge macaroon {macaroonbakery.Macaroon} + ''' + id = content.get('id') + if id is None: + id = content.get('id64') + if id is not None: + id = utils.b64decode(id) + caveat = content.get('caveat64') + if caveat is not None: + caveat = utils.b64decode(caveat) + + return bakery.discharge( + ctx, + id=id, + caveat=caveat, + key=key, + checker=checker, + locator=locator, + ) diff --git a/macaroonbakery/httpbakery/error.py b/macaroonbakery/httpbakery/error.py index e138c66..422b346 100644 --- a/macaroonbakery/httpbakery/error.py +++ b/macaroonbakery/httpbakery/error.py @@ -1,11 +1,37 @@ # Copyright 2017 Canonical Ltd. # Licensed under the LGPLv3, see LICENCE file for details. +from collections import namedtuple import json -import macaroonbakery +import macaroonbakery as bakery +ERR_INTERACTION_REQUIRED = 'interaction required' +ERR_DISCHARGE_REQUIRED = 'macaroon discharge required' -def discharged_required_response(macaroon, path, cookie_suffix_name): + +class InteractionMethodNotFound(Exception): + '''This is thrown by client-side interaction methods when + they find that a given interaction isn't supported by the + client for a location''' + pass + + +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)) + + +class InteractionError(Exception): + '''This is thrown by Client when it fails to deal with an + interaction-required error + ''' + def __init__(self, msg): + super(InteractionError, self).__init__('cannot start interactive session: {}'.format(msg)) + + +def discharge_required_response(macaroon, path, cookie_suffix_name, + message=None): ''' Get response content and headers from a discharge macaroons error. @param macaroon may hold a macaroon that, when discharged, may @@ -18,17 +44,19 @@ def discharged_required_response(macaroon, path, cookie_suffix_name): older clients will always use ("macaroon-" + macaroon.signature() in hex) @return content(bytes) and the headers to set on the response(dict). ''' + if message is None: + message = 'discharge required' content = json.dumps( { 'Code': 'macaroon discharge required', - 'Message': 'discharge required', + 'Message': message, 'Info': { 'Macaroon': macaroon.to_dict(), 'MacaroonPath': path, 'CookieNameSuffix': cookie_suffix_name }, } - ) + ).encode('utf-8') return content, { 'WWW-Authenticate': 'Macaroon', 'Content-Type': 'application/json' @@ -49,19 +77,124 @@ def request_version(req_headers): version is used, which is OK because versions are backwardly compatible. @param req_headers: the request headers as a dict. - @return: bakery protocol version (for example macaroonbakery.BAKERY_V1) + @return: bakery protocol version (for example macaroonbakery.VERSION_1) ''' vs = req_headers.get(BAKERY_PROTOCOL_HEADER) if vs is None: # No header - use backward compatibility mode. - return macaroonbakery.BAKERY_V1 + return bakery.VERSION_1 try: x = int(vs) except ValueError: # Badly formed header - use backward compatibility mode. - return macaroonbakery.BAKERY_V1 - if x > macaroonbakery.LATEST_BAKERY_VERSION: + return bakery.VERSION_1 + if x > bakery.LATEST_VERSION: # Later version than we know about - use the # latest version that we can. - return macaroonbakery.LATEST_BAKERY_VERSION + return bakery.LATEST_VERSION return x + + +class Error(namedtuple('Error', 'code, message, version, info')): + '''This class defines an error value as returned from + an httpbakery API. + ''' + @classmethod + def from_dict(cls, serialized): + '''Create an error from a JSON-deserialized object + @param serialized the object holding the serialized error {dict} + ''' + code = serialized.get('Code') + message = serialized.get('Message') + info = ErrorInfo.from_dict(serialized.get('Info')) + return Error(code=code, message=message, info=info, + version=bakery.LATEST_VERSION) + + def interaction_method(self, kind, x): + ''' Checks whether the error is an InteractionRequired error + that implements the method with the given name, and JSON-unmarshals the + method-specific data into x by calling its from_dict method + with the deserialized JSON object. + @param kind The interaction method kind (string). + @param x A class with a class method from_dict that returns a new + instance of the interaction info for the given kind. + @return The result of x.from_dict. + ''' + if self.info is None or self.code != ERR_INTERACTION_REQUIRED: + raise InteractionError( + 'not an interaction-required error (code {})'.format( + self.code) + ) + entry = self.info.interaction_methods.get(kind) + if entry is None: + raise InteractionMethodNotFound( + 'interaction method {} not found'.format(kind) + ) + return x.from_dict(entry) + + +class ErrorInfo( + namedtuple('ErrorInfo', 'macaroon, macaroon_path, cookie_name_suffix, ' + 'interaction_methods, visit_url, wait_url')): + ''' Holds additional information provided + by an error. + + @param macaroon may hold a macaroon that, when + discharged, may allow access to a service. + This field is associated with the ERR_DISCHARGE_REQUIRED + error code. + + @param macaroon_path holds the URL path to be associated + with the macaroon. The macaroon is potentially + valid for all URLs under the given path. + If it is empty, the macaroon will be associated with + the original URL from which the error was returned. + + @param cookie_name_suffix holds the desired cookie name suffix to be + associated with the macaroon. The actual name used will be + ("macaroon-" + cookie_name_suffix). Clients may ignore this field - + older clients will always use ("macaroon-" + + macaroon.signature() in hex). + + @param visit_url holds a URL that the client should visit + in a web browser to authenticate themselves. + + @param wait_url holds a URL that the client should visit + to acquire the discharge macaroon. A GET on + this URL will block until the client has authenticated, + and then it will return the discharge macaroon. + ''' + + __slots__ = () + + @classmethod + def from_dict(cls, serialized): + '''Create a new ErrorInfo object from a JSON deserialized + dictionary + @param serialized The JSON object {dict} + @return ErrorInfo object + ''' + if serialized is None: + return None + macaroon = serialized.get('Macaroon') + if macaroon is not None: + macaroon = bakery.Macaroon.from_dict(macaroon) + path = serialized.get('MacaroonPath') + cookie_name_suffix = serialized.get('CookieNameSuffix') + visit_url = serialized.get('VisitURL') + wait_url = serialized.get('WaitURL') + interaction_methods = serialized.get('InteractionMethods') + return ErrorInfo(macaroon=macaroon, macaroon_path=path, + cookie_name_suffix=cookie_name_suffix, + visit_url=visit_url, wait_url=wait_url, + interaction_methods=interaction_methods) + + def __new__(cls, macaroon=None, macaroon_path=None, + cookie_name_suffix=None, interaction_methods=None, + visit_url=None, wait_url=None): + '''Override the __new__ method so that we can + have optional arguments, which namedtuple doesn't + allow''' + return super(ErrorInfo, cls).__new__( + cls, macaroon, macaroon_path, cookie_name_suffix, + interaction_methods, visit_url, wait_url) diff --git a/macaroonbakery/httpbakery/interactor.py b/macaroonbakery/httpbakery/interactor.py new file mode 100644 index 0000000..0c15338 --- /dev/null +++ b/macaroonbakery/httpbakery/interactor.py @@ -0,0 +1,73 @@ +# Copyright 2017 Canonical Ltd. +# Licensed under the LGPLv3, see LICENCE file for details. +import abc +from collections import namedtuple + +WEB_BROWSER_INTERACTION_KIND = 'browser-window' + + +class Interactor(object): + ''' Represents a way of persuading a discharger that it should grant a + discharge macaroon. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def kind(self): + '''Returns the interaction method name. This corresponds to the key in + the Error.interaction_methods type. + @return {str} + ''' + 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 + used to acquire the discharge macaroon. The location provides + the third party caveat location to make it possible to use + relative URLs. The client holds the client being used to do the current + request. + + If the given interaction isn't supported by the client for + the given location, it may raise an InteractionMethodNotFound + which will cause the interactor to be ignored that time. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param interaction_required_err The error causing the interaction to + take place {Error} + @return {DischargeToken} The discharge token. + ''' + raise NotImplementedError('interact method must be defined in ' + 'subclass') + + +class LegacyInteractor(object): + ''' May optionally be implemented by Interactor implementations that + implement the legacy interaction-required error protocols. + ''' + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def legacy_interact(self, client, location, visit_url): + ''' Implements the "visit" half of a legacy discharge + interaction. The "wait" half will be implemented by httpbakery. + The location is the location specified by the third party + caveat. The client holds the client being used to do the current + request. + @param client The client being used for the current request {Client} + @param location Third party caveat location {str} + @param visit_url The visit_url field from the error {str} + @return None + ''' + raise NotImplementedError('legacy_interact method must be defined in ' + 'subclass') + + +class DischargeToken(namedtuple('DischargeToken', 'kind, value')): + ''' Holds a token that is intended to persuade a discharger to discharge + a third party caveat. + @param kind holds the kind of the token. By convention this + matches the name of the interaction method used to + obtain the token, but that's not required {str} + @param value holds the token data. {bytes} + ''' diff --git a/macaroonbakery/httpbakery/keyring.py b/macaroonbakery/httpbakery/keyring.py index f4e93f7..01a4349 100644 --- a/macaroonbakery/httpbakery/keyring.py +++ b/macaroonbakery/httpbakery/keyring.py @@ -3,10 +3,11 @@ from six.moves.urllib.parse import urlparse import requests -import macaroonbakery +import macaroonbakery as bakery +from macaroonbakery.httpbakery.error import BAKERY_PROTOCOL_HEADER -class ThirdPartyLocator(macaroonbakery.ThirdPartyLocator): +class ThirdPartyLocator(bakery.ThirdPartyLocator): ''' Implements macaroonbakery.ThirdPartyLocator by first looking in the backing cache and, if that fails, making an HTTP request to find the information associated with the given discharge location. @@ -23,33 +24,36 @@ class ThirdPartyLocator(macaroonbakery.ThirdPartyLocator): def third_party_info(self, loc): u = urlparse(loc) if u.scheme != 'https' and not self._allow_insecure: - raise macaroonbakery.ThirdPartyInfoNotFound( + raise bakery.ThirdPartyInfoNotFound( 'untrusted discharge URL {}'.format(loc)) loc = loc.rstrip('/') info = self._cache.get(loc) if info is not None: return info url_endpoint = '/discharge/info' - resp = requests.get(loc + url_endpoint) + headers = { + BAKERY_PROTOCOL_HEADER: str(bakery.LATEST_VERSION) + } + resp = requests.get(url=loc + url_endpoint, headers=headers) status_code = resp.status_code if status_code == 404: url_endpoint = '/publickey' - resp = requests.get(loc + url_endpoint) + resp = requests.get(url=loc + url_endpoint, headers=headers) status_code = resp.status_code if status_code != 200: - raise macaroonbakery.ThirdPartyInfoNotFound( + raise bakery.ThirdPartyInfoNotFound( 'unable to get info from {}'.format(url_endpoint)) json_resp = resp.json() if json_resp is None: - raise macaroonbakery.ThirdPartyInfoNotFound( + raise bakery.ThirdPartyInfoNotFound( 'no response from /discharge/info') pk = json_resp.get('PublicKey') if pk is None: - raise macaroonbakery.ThirdPartyInfoNotFound( + raise bakery.ThirdPartyInfoNotFound( 'no public key found in /discharge/info') - idm_pk = macaroonbakery.PublicKey.deserialize(pk) - version = json_resp.get('Version', macaroonbakery.BAKERY_V1) - self._cache[loc] = macaroonbakery.ThirdPartyInfo( + idm_pk = bakery.PublicKey.deserialize(pk) + version = json_resp.get('Version', bakery.VERSION_1) + self._cache[loc] = bakery.ThirdPartyInfo( version=version, public_key=idm_pk ) -- cgit v1.2.3