# Copyright 2015 Open Platform for NFV Project, Inc. and its contributors # This software is distributed under the terms and conditions of the 'Apache-2.0' # license which can be found in the file 'LICENSE' in this package distribution # or at 'http://www.apache.org/licenses/LICENSE-2.0'. import webob import logging import json import six import requests import re import httplib from keystone import exception from cStringIO import StringIO from oslo_config import cfg # from keystoneclient import auth from keystonemiddleware.i18n import _, _LC, _LE, _LI, _LW _OPTS = [ cfg.StrOpt('auth_uri', default="http://127.0.0.1:35357/v3", help='Complete public Identity API endpoint.'), cfg.StrOpt('auth_version', default=None, help='API version of the admin Identity API endpoint.'), cfg.StrOpt('authz_login', default="admin", help='Name of the administrator who will connect to the Keystone Moon backends.'), cfg.StrOpt('authz_password', default="nomoresecrete", help='Password of the administrator who will connect to the Keystone Moon backends.'), cfg.StrOpt('logfile', default="/tmp/authz.log", help='File where logs goes.'), ] _AUTHZ_GROUP = 'keystone_authz' CONF = cfg.CONF CONF.register_opts(_OPTS, group=_AUTHZ_GROUP) # auth.register_conf_options(CONF, _AUTHZ_GROUP) # from http://developer.openstack.org/api-ref-objectstorage-v1.html SWIFT_API = ( ("^/v1/(?P[\w-]+)$", "GET", "get_account_details"), ("^/v1/(?P[\w-]+)$", "POST", "modify_account"), ("^/v1/(?P[\w-]+)$", "HEAD", "get_account"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)$", "GET", "get_container"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)$", "PUT", "create_container"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)$", "POST", "update_container_metadata"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)$", "DELETE", "delete_container"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)$", "HEAD", "get_container_metadata"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "GET", "get_object"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "PUT", "create_object"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "COPY", "copy_object"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "POST", "update_object_metadata"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "DELETE", "delete_object"), ("^/v1/(?P[\w-]+)/(?P[\w-]+)/(?P[\w-]+)$", "HEAD", "get_object_metadata"), ) class ServiceError(Exception): pass class AuthZProtocol(object): """Middleware that handles authenticating client calls.""" post = { "auth": { "identity": { "methods": [ "password" ], "password": { "user": { "domain": { "id": "Default" }, "name": "admin", "password": "nomoresecrete" } } }, "scope": { "project": { "domain": { "id": "Default" }, "name": "demo" } } } } def __init__(self, app, conf): self._LOG = logging.getLogger(conf.get('log_name', __name__)) # FIXME: events are duplicated in log file authz_fh = logging.FileHandler(CONF.keystone_authz["logfile"]) self._LOG.setLevel(logging.DEBUG) self._LOG.addHandler(authz_fh) self._LOG.info(_LI('Starting Keystone authz middleware')) self._conf = conf self._app = app # MOON self.auth_host = conf.get('auth_host', "127.0.0.1") self.auth_port = int(conf.get('auth_port', 35357)) auth_protocol = conf.get('auth_protocol', 'http') self._request_uri = '%s://%s:%s' % (auth_protocol, self.auth_host, self.auth_port) # SSL insecure = conf.get('insecure', False) cert_file = conf.get('certfile') key_file = conf.get('keyfile') if insecure: self._verify = False elif cert_file and key_file: self._verify = (cert_file, key_file) elif cert_file: self._verify = cert_file else: self._verify = None def __set_token(self): data = self.get_url("/v3/auth/tokens", post_data=self.post) if "token" not in data: raise Exception("Authentication problem ({})".format(data)) self.token = data["token"] def __unset_token(self): data = self.get_url("/v3/auth/tokens", method="DELETE", authtoken=True) if "content" in data and len(data["content"]) > 0: self._LOG.error("Error while unsetting token {}".format(data["content"])) self.token = None def get_url(self, url, post_data=None, delete_data=None, method="GET", authtoken=None): if post_data: method = "POST" if delete_data: method = "DELETE" self._LOG.debug("\033[32m{} {}\033[m".format(method, url)) conn = httplib.HTTPConnection(self.auth_host, self.auth_port) headers = { "Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } if authtoken: if self.x_subject_token: if method == "DELETE": headers["X-Subject-Token"] = self.x_subject_token headers["X-Auth-Token"] = self.x_subject_token else: headers["X-Auth-Token"] = self.x_subject_token if post_data: method = "POST" headers["Content-type"] = "application/json" post_data = json.dumps(post_data) conn.request(method, url, post_data, headers=headers) elif delete_data: method = "DELETE" conn.request(method, url, json.dumps(delete_data), headers=headers) else: conn.request(method, url, headers=headers) resp = conn.getresponse() headers = resp.getheaders() try: self.x_subject_token = dict(headers)["x-subject-token"] except KeyError: pass content = resp.read() conn.close() try: return json.loads(content) except ValueError: return {"content": content} def _deny_request(self, code): error_table = { 'AccessDenied': (401, 'Access denied'), 'InvalidURI': (400, 'Could not parse the specified URI'), 'NotFound': (404, 'URI not found'), 'Error': (500, 'Server error'), } resp = webob.Response(content_type='text/xml') resp.status = error_table[code][0] error_msg = ('\r\n' '\r\n %s\r\n ' '%s\r\n\r\n' % (code, error_table[code][1])) if six.PY3: error_msg = error_msg.encode() resp.body = error_msg return resp def _get_authz_from_moon(self, auth_token, tenant_id, subject_id, object_id, action_id): headers = {'X-Auth-Token': auth_token} self._LOG.debug('X-Auth-Token={}'.format(auth_token)) try: _url ='{}/v3/OS-MOON/authz/{}/{}/{}/{}'.format( self._request_uri, tenant_id, subject_id, object_id, action_id) self._LOG.info(_url) response = requests.get(_url, headers=headers, verify=self._verify) except requests.exceptions.RequestException as e: self._LOG.error(_LI('HTTP connection exception: %s'), e) resp = self._deny_request('InvalidURI') raise ServiceError(resp) if response.status_code < 200 or response.status_code >= 300: self._LOG.debug('Keystone reply error: status=%s reason=%s', response.status_code, response.reason) if response.status_code == 404: resp = self._deny_request('NotFound') elif response.status_code == 401: resp = self._deny_request('AccessDenied') else: resp = self._deny_request('Error') raise ServiceError(resp) return response def _find_openstack_component(self, env): if "nova.context" in env.keys(): return "nova" elif "swift.authorize" in env.keys(): return "swift" else: self._LOG.debug(env.keys()) return "unknown" def _get_action(self, env, component): """ Find and return the action of the request Actually, find only Nova action (start, destroy, pause, unpause, ...) :param env: the request :return: the action or "" """ action = "" self.input = "" if component == "nova": length = int(env.get('CONTENT_LENGTH', '0')) # TODO (dthom): compute for Nova, Cinder, Neutron, ... action = "" if length > 0: try: sub_action_object = env['wsgi.input'].read(length) self.input = sub_action_object action = json.loads(sub_action_object).keys()[0] body = StringIO(sub_action_object) env['wsgi.input'] = body except ValueError: self._LOG.error("Error in decoding sub-action") except Exception as e: self._LOG.error(str(e)) if not action or len(action) == 0 and "servers/detail" in env["PATH_INFO"]: return "list" if component == "swift": path = env["PATH_INFO"] method = env["REQUEST_METHOD"] for api in SWIFT_API: if re.match(api[0], path) and method == api[1]: action = api[2] return action @staticmethod def _get_object(env, component): if component == "nova": # http://developer.openstack.org/api-ref-compute-v2.1.html # nova URLs: # //servers/ # list details for server_id # //servers//action # execute action to server_id # //servers//metadata # show metadata from server_id # //servers/details # list servers url = env.get("PATH_INFO").split("/") if url[-1] == "detail": return "servers" try: return url[3] except IndexError: return elif component == "swift": # remove the "/v1/" part of the URL return env.get("PATH_INFO").split("/", 2)[-1].replace("/", "-") return "unknown" def __call__(self, env, start_response): req = webob.Request(env) # token = req.headers.get('X-Auth-Token', # req.headers.get('X-Storage-Token')) # if not token: # self._LOG.error("No token") # return self._app(env, start_response) subject_id = env.get("HTTP_X_USER_ID") tenant_id = env.get("HTTP_X_TENANT_ID") component = self._find_openstack_component(env) action_id = self._get_action(env, component) if action_id: object_id = self._get_object(env, component) if not object_id: object_id = "servers" self.__set_token() resp = self._get_authz_from_moon(self.x_subject_token, tenant_id, subject_id, object_id, action_id) self.__unset_token() if resp.status_code == 200: answer = json.loads(resp.content) self._LOG.debug(answer) if "authz" in answer and answer["authz"]: return self._app(env, start_response) raise exception.Unauthorized(message="You are not authorized to do that! ({})".format(unicode(answer["comment"]))) self._LOG.debug("No action_id found for {}".format(env.get("PATH_INFO"))) # If action is not found, we can't raise an exception because a lots of action is missing # in function self._get_action, it is not possible to get them all. return self._app(env, start_response) # raise exception.Unauthorized(message="You are not authorized to do that!") def filter_factory(global_conf, **local_conf): """Returns a WSGI filter app for use with paste.deploy.""" conf = global_conf.copy() conf.update(local_conf) def auth_filter(app): return AuthZProtocol(app, conf) return auth_filter