launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27373
Re: [Merge] ~cjwatson/launchpad:candid-authorization into launchpad:master
Review: Approve
LGTM, just a note/reminder that you need to request to the Candid people to add the callback_url to their allow list (staging and prod as needed). Inline comment double-checking an expected header.
Diff comments:
> diff --git a/lib/lp/services/webapp/candid.py b/lib/lp/services/webapp/candid.py
> new file mode 100644
> index 0000000..56a1f6c
> --- /dev/null
> +++ b/lib/lp/services/webapp/candid.py
> @@ -0,0 +1,235 @@
> +# Copyright 2021 Canonical Ltd. This software is licensed under the
> +# GNU Affero General Public License version 3 (see the file LICENSE).
> +
> +"""Interaction with the Candid identity service."""
> +
> +__all__ = [
> + "BadCandidMacaroon",
> + "CandidCallbackView",
> + "CandidFailure",
> + "CandidUnconfiguredError",
> + "request_candid_discharge",
> + ]
> +
> +from base64 import b64encode
> +import hashlib
> +import hmac
> +import http.client
> +import json
> +from urllib.parse import urlencode
> +import uuid
> +
> +from lazr.restful.declarations import error_status
> +from requests import HTTPError
> +from pymacaroons import Macaroon
> +from pymacaroons.serializers import JsonSerializer
> +from zope.browserpage import ViewPageTemplateFile
> +from zope.session.interfaces import ISession
> +
> +from lp.services.timeline.requesttimeline import get_request_timeline
> +from lp.services.config import config
> +from lp.services.timeout import (
> + raise_for_status_redacted,
> + urlfetch,
> + )
> +from lp.services.webapp.publisher import LaunchpadView
> +from lp.services.webapp.url import urlappend
> +from lp.services.webapp.vhosts import allvhosts
> +
> +
> +class CandidUnconfiguredError(Exception):
> + """The Candid service is not configured."""
> +
> +
> +class BadCandidMacaroon(Exception):
> + """The macaroon is unsuitable for being discharged by Candid."""
> +
> +
> +@error_status(http.client.BAD_REQUEST)
> +class CandidFailure(Exception):
> + """Candid authorization failed."""
> +
> + def __init__(self, message, response=None):
> + super().__init__(message)
> + self.response = response
> +
> +
> +def _make_candid_request(request, endpoint_name, expect_401=False, **kwargs):
> + """Make a POST request to the Candid API."""
> + url = urlappend(config.launchpad.candid_service_root, endpoint_name)
> + base_headers = {"Bakery-Protocol-Version": "2"}
> + timeline = get_request_timeline(request)
> + action = timeline.start("candid", url)
> + try:
> + response = urlfetch(
> + url, method="POST", headers=base_headers,
> + check_status=not expect_401, **kwargs)
> + if expect_401 and response.status_code != 401:
> + raise_for_status_redacted(response)
> + raise CandidFailure(
> + "Initial discharge request unexpectedly succeeded without "
> + "authorization",
> + response=response)
> + return response
> + except HTTPError as e:
> + raise CandidFailure(str(e), response=e.response)
> + finally:
> + action.finish()
> +
> +
> +def _deserialize_json_macaroon(macaroon_raw):
> + """Deserialize a macaroon serialized using JSON."""
> + return Macaroon.deserialize(macaroon_raw, JsonSerializer())
> +
> +
> +def _extract_third_party_caveat(macaroon):
> + """Extract the Candid third-party caveat from a macaroon."""
> + try:
> + return next(
> + caveat for caveat in macaroon.caveats
> + if caveat.location == config.launchpad.candid_service_root)
> + except StopIteration:
> + raise BadCandidMacaroon(
> + "Missing Candid caveat: {}".format(
> + config.launchpad.candid_service_root))
> +
> +
> +def _get_candid_login_url_for_discharge(request, macaroon, state,
> + callback_url):
> + """Get the login URL to web-discharge a third-party caveat."""
> + caveat = _extract_third_party_caveat(macaroon)
> + response = _make_candid_request(
> + request, "discharge",
> + data={"id64": b64encode(caveat.caveat_id_bytes)}, expect_401=True)
> + try:
> + interaction_methods = response.json()["Info"]["InteractionMethods"]
> + browser_redirect = interaction_methods["browser-redirect"]
> + return "{}?{}".format(
> + browser_redirect["LoginURL"],
> + urlencode({"return_to": callback_url, "state": state}))
> + except KeyError:
> + raise CandidFailure(
> + "Initial discharge request did not contain expected fields",
> + response=response)
> +
> +
> +def request_candid_discharge(request, macaroon_raw):
> + """Redirect to Candid to request a discharge for a given macaroon."""
> + if (not config.launchpad.candid_service_root or
> + not config.launchpad.csrf_secret):
> + raise CandidUnconfiguredError("The Candid service is not configured.")
> +
> + macaroon = _deserialize_json_macaroon(macaroon_raw)
> +
> + csrf_token = hashlib.sha256(
> + (uuid.uuid4().hex + config.launchpad.csrf_secret).encode()).hexdigest()
> +
> + session_data = ISession(request)["launchpad.candid"]
> + session_data["macaroon"] = macaroon_raw
> + session_data["csrf-token"] = csrf_token
> +
> + starting_url = request.getURL()
> + form_args = [
> + (key, value) for key, value in request.form.items()
> + if key not in (
> + "discharge_macaroon_action", "discharge_macaroon_field")]
> + query_string = urlencode(form_args, doseq=True)
> + if query_string:
> + starting_url += "?%s" % query_string
> +
> + # Once the user authenticates with Candid, they will be redirected to
> + # the /+candid-callback page, which must send them back to the URL they
> + # were when they started the authorization process. To help with that,
> + # we encode that URL and some additional data as query parameters in the
> + # return_to URL passed to Candid.
> + starting_data = [("starting_url", starting_url)]
> + for passthrough_name in (
> + "discharge_macaroon_action", "discharge_macaroon_field"):
> + passthrough_field = request.form.get(passthrough_name, None)
> + if passthrough_field is not None:
> + starting_data.append((passthrough_name, passthrough_field))
> + return_to = "%s?%s" % (
> + urlappend(allvhosts.configs["mainsite"].rooturl, "+candid-callback"),
> + urlencode(starting_data))
> +
> + login_url = _get_candid_login_url_for_discharge(
> + request, macaroon, csrf_token, return_to)
> +
> + request.response.redirect(login_url, temporary_if_possible=True)
> +
> +
> +class CandidErrorView(LaunchpadView):
> +
> + page_title = "Authorization error"
> + template = ViewPageTemplateFile("templates/candid-error.pt")
> +
> + def __init__(self, context, request, candid_error):
> + super().__init__(context, request)
> + self.candid_error = candid_error
> +
> +
> +class CandidCallbackView(LaunchpadView):
> + """Callback view for Candid authorization."""
> +
> + template = ViewPageTemplateFile("templates/login-discharge-macaroon.pt")
> +
> + def _gatherParams(self, request):
> + params = dict(request.form)
> + for key, value in request.query_string_params.items():
> + if len(value) > 1:
> + raise ValueError("Did not expect multi-valued fields.")
> + params[key] = value[0]
> + return params
> +
> + def _get_serialized_discharge(self, request, macaroon, code):
> + """Get the discharge macaroon generated after a Candid web login."""
> + caveat = _extract_third_party_caveat(macaroon)
> + response = _make_candid_request(
> + request, "discharge-token", json={"code": code})
If I remember correctly, note that this request must set the Content-type to 'application/json' (but not the one getting the login URL, or the discharge below... I hated that). From what I see in the tests below, I'm guessing that setting the json param handles that, but just double-checking.
> + token = response.json()["token"]
> + data = {
> + "id64": b64encode(caveat.caveat_id_bytes),
> + "token64": token["value"],
> + "token-kind": token["kind"],
> + }
> + response = _make_candid_request(request, "discharge", data=data)
> + return json.dumps(response.json()["Macaroon"])
> +
> + def initialize(self):
> + self.params = self._gatherParams(self.request)
> + session_data = ISession(self.request)["launchpad.candid"]
> +
> + try:
> + if ("macaroon" not in session_data or
> + "csrf-token" not in session_data):
> + raise CandidFailure("Candid session lost or not started")
> + if ("starting_url" not in self.params or
> + "discharge_macaroon_field" not in self.params or
> + "code" not in self.params):
> + raise CandidFailure("Missing parameters to Candid callback")
> +
> + # Validate CSRF token.
> + if not hmac.compare_digest(
> + self.params.get("state", ""), session_data["csrf-token"]):
> + raise CandidFailure("CSRF token mismatch")
> +
> + # Get unbound discharge macaroon from Candid.
> + code = self.params["code"]
> + macaroon = _deserialize_json_macaroon(session_data["macaroon"])
> + self.discharge_macaroon_raw = self._get_serialized_discharge(
> + self.request, macaroon, code)
> + self.candid_error = None
> + except CandidFailure as e:
> + self.candid_error = str(e)
> + finally:
> + # Prevent replay attacks. PGSessionPkgData.__delitem__ ensures
> + # that this succeeds even if the key does not exist.
> + del session_data["macaroon"]
> + del session_data["csrf-token"]
> +
> + def render(self):
> + if self.candid_error is not None:
> + return CandidErrorView(
> + self.context, self.request, self.candid_error)()
> + else:
> + return super().render()
--
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/406724
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:candid-authorization into launchpad:master.
References