summaryrefslogtreecommitdiff
path: root/macaroonbakery/httpbakery/client.py
diff options
context:
space:
mode:
authorColin Watson <cjwatson@debian.org>2017-11-06 10:04:48 +0000
committerColin Watson <cjwatson@debian.org>2017-11-06 10:04:48 +0000
commit37d61d0415f6cc96a7a9abe057e1ae0f89fd977e (patch)
tree4ca3c2560d2ba062adb7de86d047d67db8984940 /macaroonbakery/httpbakery/client.py
parent3d9eaeb5dacee168a93da090e2c0d46eedbe51a2 (diff)
Import py-macaroon-bakery_0.0.5.orig.tar.gz
Diffstat (limited to 'macaroonbakery/httpbakery/client.py')
-rw-r--r--macaroonbakery/httpbakery/client.py442
1 files changed, 324 insertions, 118 deletions
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