diff --git a/src/XrdMacaroons/XrdMacaroonsHandler.cc b/src/XrdMacaroons/XrdMacaroonsHandler.cc index 095693a6717..ce81bac4f72 100644 --- a/src/XrdMacaroons/XrdMacaroonsHandler.cc +++ b/src/XrdMacaroons/XrdMacaroonsHandler.cc @@ -17,6 +17,41 @@ using namespace Macaroons; + +char *unquote(const char *str) { + int l = strlen(str); + char *r = (char *) malloc(l + 1); + r[0] = '\0'; + int i, j = 0; + + for (i = 0; i < l; i++) { + + if (str[i] == '%') { + char savec[3]; + if (l <= i + 3) { + free(r); + return NULL; + } + savec[0] = str[i + 1]; + savec[1] = str[i + 2]; + savec[2] = '\0'; + + r[j] = strtol(savec, 0, 16); + + i += 2; + } else if (str[i] == '+') r[j] = ' '; + else r[j] = str[i]; + + j++; + } + + r[j] = '\0'; + + return r; + +} + + static ssize_t determine_validity(const std::string& input) { @@ -95,6 +130,7 @@ Handler::GenerateID(const XrdSecEntity &entity, const std::string &activities, return result; } + std::string Handler::GenerateActivities(const XrdHttpExtReq & req) const { @@ -109,19 +145,174 @@ Handler::GenerateActivities(const XrdHttpExtReq & req) const return result; } + // See if the macaroon handler is interested in this request. // We intercept all POST requests as we will be looking for a particular // header. bool Handler::MatchesPath(const char *verb, const char *path) { - return !strcmp(verb, "POST"); + return !strcmp(verb, "POST") || !strncmp(path, "/.well-known/", 13) || + !strncmp(path, "/.oauth2/", 9); +} + + +int Handler::ProcessOAuthConfig(XrdHttpExtReq &req) { + if (req.verb != "GET") + { + return req.SendSimpleResp(405, NULL, NULL, "Only GET is valid for oauth config.", 0); + } + auto header = req.headers.find("Host"); + if (header == req.headers.end()) + { + return req.SendSimpleResp(400, NULL, NULL, "Host header is required.", 0); + } + + json_object *response_obj = json_object_new_object(); + if (!response_obj) + { + return req.SendSimpleResp(500, NULL, NULL, "Unable to create new JSON response object.", 0); + } + std::string token_endpoint = "https://" + header->second + "/.oauth2/token"; + json_object *endpoint_obj = + json_object_new_string_len(token_endpoint.c_str(), token_endpoint.size()); + if (!endpoint_obj) + { + return req.SendSimpleResp(500, NULL, NULL, "Unable to create a new JSON macaroon string.", 0); + } + json_object_object_add(response_obj, "token_endpoint", endpoint_obj); + + const char *response_result = json_object_to_json_string_ext(response_obj, JSON_C_TO_STRING_PRETTY); + int retval = req.SendSimpleResp(200, NULL, NULL, response_result, 0); + json_object_put(response_obj); + return retval; +} + + +int Handler::ProcessTokenRequest(XrdHttpExtReq &req) +{ + if (req.verb != "POST") + { + return req.SendSimpleResp(405, NULL, NULL, "Only POST is valid for token request.", 0); + } + auto header = req.headers.find("Content-Type"); + if (header == req.headers.end()) + { + return req.SendSimpleResp(400, NULL, NULL, "Content-Type missing; not a valid macaroon request?", 0); + } + if (header->second != "application/x-www-form-urlencoded") + { + return req.SendSimpleResp(400, NULL, NULL, "Content-Type must be set to `application/macaroon-request' to request a macaroon", 0); + } + char *request_data_raw; + // Note: this does not null-terminate the buffer contents. + if (req.BuffgetData(req.length, &request_data_raw, true) != req.length) + { + return req.SendSimpleResp(400, NULL, NULL, "Missing or invalid body of request.", 0); + } + std::string request_data(request_data_raw, req.length); + bool found_grant_type = false; + ssize_t validity = -1; + std::string scope; + std::string token; + std::istringstream token_stream(request_data); + while (std::getline(token_stream, token, '&')) + { + std::string::size_type eq = token.find("="); + if (eq == std::string::npos) + { + return req.SendSimpleResp(400, NULL, NULL, "Invalid format for form-encoding", 0); + } + std::string key = token.substr(0, eq); + std::string value = token.substr(eq + 1); + //std::cout << "Found key " << key << ", value " << value << std::endl; + if (key == "grant_type") + { + found_grant_type = true; + if (value != "client_credentials") + { + return req.SendSimpleResp(400, NULL, NULL, "Invalid grant type specified.", 0); + } + } + else if (key == "expire_in") + { + try + { + validity = std::stoll(value); + } + catch (...) + { + return req.SendSimpleResp(400, NULL, NULL, "Expiration request not parseable.", 0); + } + if (validity <= 0) + { + return req.SendSimpleResp(400, NULL, NULL, "Expiration request has invalid value.", 0); + } + } + else if (key == "scope") + { + char *value_raw = unquote(value.c_str()); + if (value_raw == NULL) + { + return req.SendSimpleResp(400, NULL, NULL, "Unable to unquote scope.", 0); + } + scope = value_raw; + free(value_raw); + } + } + if (!found_grant_type) + { + return req.SendSimpleResp(400, NULL, NULL, "Grant type not specified.", 0); + } + if (scope.empty()) + { + return req.SendSimpleResp(400, NULL, NULL, "Scope was not specified.", 0); + } + std::istringstream token_stream_scope(scope); + std::string path; + std::vector other_caveats; + while (std::getline(token_stream_scope, token, ' ')) + { + std::string::size_type col = token.find(":"); + if (col == std::string::npos) + { + return req.SendSimpleResp(400, NULL, NULL, "Invalid format for requested scope", 0); + } + std::string key = token.substr(0, col); + std::string value = token.substr(col + 1); + //std::cout << "Found activity " << key << ", path " << value << std::endl; + if (path.empty()) + { + path = value; + } + else if (value != path) + { + std::stringstream ss; + ss << "Encountered requested scope request for authorization " << key + << " with resource path " << value << "; however, prior request had path " + << path; + m_log->Emsg("MacaroonRequest", ss.str().c_str()); + return req.SendSimpleResp(500, NULL, NULL, "Server only supports all scopes having the same path", 0); + } + other_caveats.push_back(key); + } + if (path.empty()) + { + path = "/"; + } + return GenerateMacaroonResponse(req, path, other_caveats, validity, true); } // Process a macaroon request. int Handler::ProcessReq(XrdHttpExtReq &req) { + if (req.resource == "/.well-known/oauth-authorization-server") { + return ProcessOAuthConfig(req); + } else if (req.resource == "/.oauth2/token") { + return ProcessTokenRequest(req); + } + auto header = req.headers.find("Content-Type"); if (header == req.headers.end()) { @@ -176,46 +367,53 @@ int Handler::ProcessReq(XrdHttpExtReq &req) { return req.SendSimpleResp(400, NULL, NULL, "Invalid ISO 8601 duration for validity key", 0); } - time_t now; - time(&now); - if (m_max_duration > 0) - { - now += (validity > m_max_duration) ? m_max_duration : validity; - } - else - { - now += validity; - } - char utc_time_buf[21]; - if (!strftime(utc_time_buf, 21, "%FT%TZ", gmtime(&now))) - { - return req.SendSimpleResp(500, NULL, NULL, "Internal error constructing UTC time", 0); - } - std::string utc_time_str(utc_time_buf); - std::stringstream ss; - ss << "before:" << utc_time_str; - std::string utc_time_caveat = ss.str(); - json_object *caveats_obj; std::vector other_caveats; if (json_object_object_get_ex(macaroon_req, "caveats", &caveats_obj)) - { + { if (json_object_is_type(caveats_obj, json_type_array)) { // Caveats were provided. Let's record them. // TODO - could just add these in-situ. No need for the other_caveats vector. int array_length = json_object_array_length(caveats_obj); other_caveats.reserve(array_length); for (int idx=0; idx &other_caveats, ssize_t validity, bool oauth_response) +{ + time_t now; + time(&now); + if (m_max_duration > 0) + { + validity = (validity > m_max_duration) ? m_max_duration : validity; + } + now += validity; + + char utc_time_buf[21]; + if (!strftime(utc_time_buf, 21, "%FT%TZ", gmtime(&now))) + { + return req.SendSimpleResp(500, NULL, NULL, "Internal error constructing UTC time", 0); + } + std::string utc_time_str(utc_time_buf); + std::stringstream ss; + ss << "before:" << utc_time_str; + std::string utc_time_caveat = ss.str(); std::string activities = GenerateActivities(req); std::string macaroon_id = GenerateID(req.GetSecEntity(), activities, utc_time_str); @@ -276,7 +474,7 @@ int Handler::ProcessReq(XrdHttpExtReq &req) } } - std::string path_caveat = "path:" + req.resource; + std::string path_caveat = "path:" + resource; struct macaroon *mac_with_path = macaroon_add_first_party_caveat(mac_with_activities, reinterpret_cast(path_caveat.c_str()), path_caveat.size(), @@ -314,11 +512,17 @@ int Handler::ProcessReq(XrdHttpExtReq &req) { return req.SendSimpleResp(500, NULL, NULL, "Unable to create a new JSON macaroon string.", 0); } - json_object_object_add(response_obj, "macaroon", macaroon_obj); + json_object_object_add(response_obj, oauth_response ? "access_token" : "macaroon", macaroon_obj); + + json_object *expire_in_obj = json_object_new_int64(validity); + if (!expire_in_obj) + { + return req.SendSimpleResp(500, NULL, NULL, "Unable to create a new JSON validity object.", 0); + } + json_object_object_add(response_obj, "expires_in", expire_in_obj); const char *macaroon_result = json_object_to_json_string_ext(response_obj, JSON_C_TO_STRING_PRETTY); int retval = req.SendSimpleResp(200, NULL, NULL, macaroon_result, 0); json_object_put(response_obj); return retval; } - diff --git a/src/XrdMacaroons/XrdMacaroonsHandler.hh b/src/XrdMacaroons/XrdMacaroonsHandler.hh index 3cfb22f230c..446a38d8d8c 100644 --- a/src/XrdMacaroons/XrdMacaroonsHandler.hh +++ b/src/XrdMacaroons/XrdMacaroonsHandler.hh @@ -2,6 +2,7 @@ #include #include #include +#include #include "XrdHttp/XrdHttpExtHandler.hh" @@ -50,6 +51,10 @@ private: std::string GenerateID(const XrdSecEntity &, const std::string &, const std::string &); std::string GenerateActivities(const XrdHttpExtReq &) const; + int ProcessOAuthConfig(XrdHttpExtReq &req); + int ProcessTokenRequest(XrdHttpExtReq& req); + int GenerateMacaroonResponse(XrdHttpExtReq& req, const std::string &response, const std::vector &, ssize_t validity, bool oauth_response); + static bool xsecretkey(XrdOucStream &Config, XrdSysError *log, std::string &secret); static bool xsitename(XrdOucStream &Config, XrdSysError *log, std::string &location); static bool xtrace(XrdOucStream &Config, XrdSysError *log); diff --git a/src/XrdMacaroons/macaroon-init b/src/XrdMacaroons/macaroon-init new file mode 100755 index 00000000000..1c5c8e70161 --- /dev/null +++ b/src/XrdMacaroons/macaroon-init @@ -0,0 +1,154 @@ +#!/usr/bin/python + +""" +Given an X509 proxy, generate a dCache-style macaroon. +""" + +from __future__ import print_function + +import os +import sys +import json +import urlparse +import argparse + +import requests + + +class NoTokenEndpoint(Exception): + pass + + +def parse_args(): + """ + Parse command line arguments to this tool + """ + parser = argparse.ArgumentParser( + description="Generate a macaroon for authorized transfers") + parser.add_argument("url", metavar="URL", + help="URL to generate macaroon for.") + parser.add_argument("--activity", nargs="+", help="Activity for authorization (LIST," + "DOWNLOAD,UPLOAD, etc)") + parser.add_argument("--validity", type=int, default=10, help="Time," + "in minutes, the resulting macaroon should be valid.", + required=False) + return parser.parse_args() + + +def configure_authenticated_session(): + """ + Generate a new session object for use with requests to the issuer. + + Configures TLS appropriately to work with a GSI environment. + """ + euid = os.geteuid() + if euid == 0: + cert = '/etc/grid-security/hostcert.pem' + key = '/etc/grid-security/hostkey.pem' + else: + cert = '/tmp/x509up_u%d' % euid + key = '/tmp/x509up_u%d' % euid + + cert = os.environ.get('X509_USER_PROXY', cert) + key = os.environ.get('X509_USER_PROXY', key) + + session = requests.Session() + + if os.path.exists(cert): + session.cert = cert + if os.path.exists(key): + session.cert = (cert, key) + #session.verify = '/etc/grid-security/certificates' + + return session + + +def get_token_endpoint(issuer): + """ + From the provided issuer, use OAuth auto-discovery to bootstrap the token endpoint. + """ + parse_result = urlparse.urlparse(issuer) + norm_path = os.path.normpath(parse_result.path) + new_path = norm_path if norm_path != "/" else "" + new_path = "/.well-known/oauth-authorization-server" + new_path + config_url = urlparse.urlunparse(urlparse.ParseResult( + scheme = "https", + netloc = parse_result.netloc, + path = new_path, + fragment = "", + query = "", + params = "")) + response = requests.get(config_url) + endpoint_info = json.loads(response.text) + if response.status_code != requests.codes.ok: + print >> sys.stderr, "Failed to access the auto-discovery URL (%s) for issuer %s (status=%d): %s" % (config_url, issuer, response.status_code, response.text[:2048]) + raise NoTokenEndpoint() + elif 'token_endpoint' not in endpoint_info: + print >> sys.stderr, "Token endpoint not available for issuer %s" % issuer + raise NoTokenEndpoint() + return endpoint_info['token_endpoint'] + + +def generate_token(url, validity, activity): + """ + Call out to the macaroon issuer, using the specified validity and activity, + and receive a resulting token. + """ + print("Querying %s for new token." % url, file=sys.stderr) + validity = "PT%dM" % validity + print("Validity: %s, activities: %s." % (validity, ", ".join(activity)), + file=sys.stderr) + data_json = {"caveats": ["activity:%s" % ",".join(activity)], + "validity": validity} + with configure_authenticated_session() as session: + response = session.post(url, + headers={"Content-Type": "application/macaroon-request"}, + data=json.dumps(data_json) + ) + + if response.status_code == requests.codes.ok: + print("Successfully generated a new token:", file=sys.stderr) + return response.text + else: + print("Issuer failed request (status %d): %s" % (response.status_code, response.text[:2048]), file=sys.stderr) + sys.exit(1) + + +def generate_token_oauth(url, validity, activity): + parse_result = urlparse.urlparse(url) + token_issuer = urlparse.urlunparse(urlparse.ParseResult( + scheme = "https", + netloc = parse_result.netloc, + path = "/", + fragment = "", + query = "", + params = "")) + token_endpoint = get_token_endpoint(token_issuer) + print("Querying %s for new token." % token_endpoint) + scope = " ".join(["{}:{}".format(i, parse_result.path) for i in activity]) + with configure_authenticated_session() as session: + response = session.post(token_endpoint, headers={"Accept": "application/json"}, + data={"grant_type": "client_credentials", + "scope": scope, + "expire_in": "{}".format(validity*60)}) + + if response.status_code == requests.codes.ok: + print("Successfully generated a new token:") + return response.text + else: + print("Issuer failed request (status %d): %s" % (response.status_code, response.text[:2048]), + file=sys.stderr) + sys.exit(1) + + +def main(): + args = parse_args() + try: + token = generate_token_oauth(args.url, args.validity, args.activity) + except NoTokenEndpoint: + token = generate_token(args.url, args.validity, args.activity) + print(token) + + +if __name__ == '__main__': + main()