From: Salvatore Orlando Date: Thu, 11 Aug 2011 16:53:43 +0000 (+0100) Subject: Authentication with Keystone. X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=2e3931e7ba0161913dc359119ecc1899de31aa07;p=openstack-build%2Fneutron-build.git Authentication with Keystone. auth_token Middleware tweaked and imported in Quantum tree Developing Authorization middleware --- diff --git a/etc/quantum.conf b/etc/quantum.conf index ba96a9a27..193ce8b39 100644 --- a/etc/quantum.conf +++ b/etc/quantum.conf @@ -16,10 +16,23 @@ use = egg:Paste#urlmap /: quantumversions /v0.1: quantumapi +[filter:tokenauth] +paste.filter_factory = quantum.common.auth_token:filter_factory +auth_host = 127.0.0.1 +auth_port = 5001 +auth_protocol = http +# Not sure the admin token thing is right... +#admin_token = 9a82c95a-99e9-4c3a-b5ee-199f6ba7ff04 +admin_user = admin +admin_password = secrete + +[pipeline:quantumapi] +pipeline = tokenauth quantumapiapp + [app:quantumversions] paste.app_factory = quantum.api.versions:Versions.factory -[app:quantumapi] +[app:quantumapiapp] paste.app_factory = quantum.api:APIRouterV01.factory diff --git a/quantum/cli.py b/quantum/cli.py index a015565d8..940416e8c 100644 --- a/quantum/cli.py +++ b/quantum/cli.py @@ -104,7 +104,9 @@ def detail_net(manager, *args): def api_detail_net(client, *args): tid, nid = args try: - res = client.list_network_details(nid)["networks"]["network"] + res = client.list_network_details(nid)["network"] + print "BLOODY MARY" + print res except Exception, e: LOG.error("Failed to get network details: %s" % e) return diff --git a/quantum/client.py b/quantum/client.py index ca05236fa..f021e2db0 100644 --- a/quantum/client.py +++ b/quantum/client.py @@ -134,7 +134,9 @@ class Client(object): c.request(method, action, body, headers) res = c.getresponse() + print "RESPONSE RECEIVED" status_code = self.get_status_code(res) + print "STATUS CODE:%s" %status_code if status_code in (httplib.OK, httplib.CREATED, httplib.ACCEPTED, diff --git a/quantum/common/auth_token.py b/quantum/common/auth_token.py new file mode 100755 index 000000000..c52dcec21 --- /dev/null +++ b/quantum/common/auth_token.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# +# Copyright (c) 2010-2011 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +""" +TOKEN-BASED AUTH MIDDLEWARE + +This WSGI component performs multiple jobs: +- it verifies that incoming client requests have valid tokens by verifying + tokens with the auth service. +- it will reject unauthenticated requests UNLESS it is in 'delay_auth_decision' + mode, which means the final decision is delegated to the downstream WSGI + component (usually the OpenStack service) +- it will collect and forward identity information from a valid token + such as user name, groups, etc... + +Refer to: http://wiki.openstack.org/openstack-authn + +This WSGI component has been derived from Keystone's auth_token +middleware module. It contains some specialization for Quantum. + +HEADERS +------- +Headers starting with HTTP_ is a standard http header +Headers starting with HTTP_X is an extended http header + +> Coming in from initial call from client or customer +HTTP_X_AUTH_TOKEN : the client token being passed in +HTTP_X_STORAGE_TOKEN: the client token being passed in (legacy Rackspace use) + to support cloud files +> Used for communication between components +www-authenticate : only used if this component is being used remotely +HTTP_AUTHORIZATION : basic auth password used to validate the connection + +> What we add to the request for use by the OpenStack service +HTTP_X_AUTHORIZATION: the client identity being passed in + +""" + +import eventlet +from eventlet import wsgi +import httplib +import json +import logging +import os +from paste.deploy import loadapp +from urlparse import urlparse +from webob.exc import HTTPUnauthorized, HTTPUseProxy +from webob.exc import Request, Response + +from quantum.common.bufferedhttp import http_connect_raw as http_connect + +PROTOCOL_NAME = "Token Authentication" +LOG = logging.getLogger('quantum.common.auth_token') + +class AuthProtocol(object): + """Auth Middleware that handles authenticating client calls""" + + def _init_protocol_common(self, app, conf): + """ Common initialization code""" + LOG.info("Starting the %s component", PROTOCOL_NAME) + + self.conf = conf + self.app = app + #if app is set, then we are in a WSGI pipeline and requests get passed + # on to app. If it is not set, this component should forward requests + + # where to find the Quantum service (if not in local WSGI chain) + # these settings are only used if this component is acting as a proxy + # and the OpenSTack service is running remotely + if not self.app: + self.service_protocol = conf.get('quantum_protocol', 'https') + self.service_host = conf.get('quantum_host') + self.service_port = int(conf.get('quantum_port')) + self.service_url = '%s://%s:%s' % (self.service_protocol, + self.service_host, + self.service_port) + + # delay_auth_decision means we still allow unauthenticated requests + # through and we let the downstream service make the final decision + self.delay_auth_decision = int(conf.get('delay_auth_decision', 0)) + + def _init_protocol(self, app, conf): + """ Protocol specific initialization """ + + # where to find the auth service (we use this to validate tokens) + self.auth_host = conf.get('auth_host') + self.auth_port = int(conf.get('auth_port')) + self.auth_protocol = conf.get('auth_protocol', 'https') + self.auth_location = "%s://%s:%s" % (self.auth_protocol, + self.auth_host, + self.auth_port) + LOG.debug("AUTH SERVICE LOCATION:%s", self.auth_location) + # Credentials used to verify this component with the Auth service since + # validating tokens is a priviledged call + self.admin_user = conf.get('admin_user') + self.admin_password = conf.get('admin_password') + self.admin_token = conf.get('admin_token') + + def __init__(self, app, conf): + """ Common initialization code """ + #TODO(ziad): maybe we rafactor this into a superclass + self._init_protocol_common(app, conf) # Applies to all protocols + self._init_protocol(app, conf) # Specific to this protocol + + def __call__(self, env, start_response): + """ Handle incoming request. Authenticate. And send downstream. """ + LOG.debug("entering AuthProtocol.__call__") + LOG.debug("start response:%s", start_response) + self.start_response = start_response + self.env = env + + #Prep headers to forward request to local or remote downstream service + self.proxy_headers = env.copy() + for header in self.proxy_headers.iterkeys(): + if header[0:5] == 'HTTP_': + self.proxy_headers[header[5:]] = self.proxy_headers[header] + del self.proxy_headers[header] + + #Look for authentication claims + LOG.debug("Looking for authentication claims") + self.claims = self._get_claims(env) + if not self.claims: + #No claim(s) provided + LOG.debug("No claims provided") + if self.delay_auth_decision: + #Configured to allow downstream service to make final decision. + #So mark status as Invalid and forward the request downstream + self._decorate_request("X_IDENTITY_STATUS", "Invalid") + else: + #Respond to client as appropriate for this auth protocol + return self._reject_request() + else: + # this request is presenting claims. Let's validate them + LOG.debug("Claims found. Validating.") + valid = self._validate_claims(self.claims) + if not valid: + # Keystone rejected claim + if self.delay_auth_decision: + # Downstream service will receive call still and decide + self._decorate_request("X_IDENTITY_STATUS", "Invalid") + else: + #Respond to client as appropriate for this auth protocol + return self._reject_claims() + else: + self._decorate_request("X_IDENTITY_STATUS", "Confirmed") + + #Collect information about valid claims + if valid: + LOG.debug("Validation successful") + claims = self._expound_claims() + + # Store authentication data + if claims: + # TODO(Ziad): add additional details we may need, + # like tenant and group info + self._decorate_request('X_AUTHORIZATION', "Proxy %s" % + claims['user']) + self._decorate_request('X_TENANT', claims['tenant']) + self._decorate_request('X_USER', claims['user']) + if 'group' in claims: + self._decorate_request('X_GROUP', claims['group']) + if 'roles' in claims and len(claims['roles']) > 0: + if claims['roles'] != None: + roles = '' + for role in claims['roles']: + if len(roles) > 0: + roles += ',' + roles += role + self._decorate_request('X_ROLE', roles) + + # NOTE(todd): unused + self.expanded = True + LOG.debug("About to forward request") + #Send request downstream + return self._forward_request() + + # NOTE(todd): unused + # NOTE(salvatore-orlando): temporarily used again + def get_admin_auth_token(self, username, password): + """ + This function gets an admin auth token to be used by this service to + validate a user's token. Validate_token is a priviledged call so + it needs to be authenticated by a service that is calling it + """ + headers = {"Content-type": "application/json", "Accept": "text/json"} + params = {"passwordCredentials": {"username": username, + "password": password}} + conn = httplib.HTTPConnection("%s:%s" \ + % (self.auth_host, self.auth_port)) + conn.request("POST", "/v2.0/tokens", json.dumps(params), \ + headers=headers) + response = conn.getresponse() + data = response.read() + return data + + def _get_claims(self, env): + """Get claims from request""" + claims = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN')) + return claims + + def _reject_request(self): + """Redirect client to auth server""" + return HTTPUseProxy(location=self.auth_location)(self.env, + self.start_response) + + def _reject_claims(self): + """Client sent bad claims""" + return HTTPUnauthorized()(self.env, + self.start_response) + + def _validate_claims(self, claims): + """Validate claims, and provide identity information if applicable """ + + # Step 1: We need to auth with the keystone service, so get an + # admin token + #TODO(ziad): Need to properly implement this, where to store creds + # for now using token from ini + #TODO(salvatore-orlando): Temporarily restoring auth token retrieval, + # with credentials in configuration file + if not self.admin_token: + auth = self.get_admin_auth_token(self.admin_user, + self.admin_password) + LOG.debug("ADMIN TOKEN?%s",json.loads(auth)) + self.admin_token = json.loads(auth)["auth"]["token"]["id"] + + # Step 2: validate the user's token with the auth service + # since this is a priviledged op,m we need to auth ourselves + # by using an admin token + headers = {"Content-type": "application/json", + "Accept": "text/json", + "X-Auth-Token": self.admin_token} + ##TODO(ziad):we need to figure out how to auth to keystone + #since validate_token is a priviledged call + #Khaled's version uses creds to get a token + # "X-Auth-Token": admin_token} + # we're using a test token from the ini file for now + conn = http_connect(self.auth_host, self.auth_port, 'GET', + '/v2.0/tokens/%s' % claims, headers=headers) + resp = conn.getresponse() + # data = resp.read() + conn.close() + + if not str(resp.status).startswith('20'): + # Keystone rejected claim + return False + else: + #TODO(Ziad): there is an optimization we can do here. We have just + #received data from Keystone that we can use instead of making + #another call in _expound_claims + return True + + def _expound_claims(self): + # Valid token. Get user data and put it in to the call + # so the downstream service can use it + headers = {"Content-type": "application/json", + "Accept": "text/json", + "X-Auth-Token": self.admin_token} + ##TODO(ziad):we need to figure out how to auth to keystone + #since validate_token is a priviledged call + #Khaled's version uses creds to get a token + # "X-Auth-Token": admin_token} + # we're using a test token from the ini file for now + conn = http_connect(self.auth_host, self.auth_port, 'GET', + '/v2.0/tokens/%s' % self.claims, headers=headers) + resp = conn.getresponse() + data = resp.read() + conn.close() + + if not str(resp.status).startswith('20'): + raise LookupError('Unable to locate claims: %s' % resp.status) + + token_info = json.loads(data) + #TODO(Ziad): make this more robust + #first_group = token_info['auth']['user']['groups']['group'][0] + roles = [] + role_refs = token_info["auth"]["user"]["roleRefs"] + if role_refs != None: + for role_ref in role_refs: + roles.append(role_ref["roleId"]) + + verified_claims = {'user': token_info['auth']['user']['username'], + 'tenant': token_info['auth']['user']['tenantId'], + 'roles': roles} + + # TODO(Ziad): removed groups for now + # ,'group': '%s/%s' % (first_group['id'], + # first_group['tenantId'])} + return verified_claims + + def _decorate_request(self, index, value): + """Add headers to request""" + self.proxy_headers[index] = value + self.env["HTTP_%s" % index] = value + + def _forward_request(self): + """Token/Auth processed & claims added to headers""" + #now decide how to pass on the call + if self.app: + # Pass to downstream WSGI component + return self.app(self.env, self.start_response) + #.custom_start_response) + else: + # We are forwarding to a remote service (no downstream WSGI app) + req = Request(self.proxy_headers) + parsed = urlparse(req.url) + conn = http_connect(self.service_host, + self.service_port, + req.method, + parsed.path, + self.proxy_headers, + ssl=(self.service_protocol == 'https')) + resp = conn.getresponse() + data = resp.read() + #TODO(ziad): use a more sophisticated proxy + # we are rewriting the headers now + return Response(status=resp.status, body=data)(self.proxy_headers, + self.start_response) + + +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 AuthProtocol(app, conf) + return auth_filter + + +def app_factory(global_conf, **local_conf): + conf = global_conf.copy() + conf.update(local_conf) + return AuthProtocol(None, conf) + +if __name__ == "__main__": + app = loadapp("config:" + \ + os.path.join(os.path.abspath(os.path.dirname(__file__)), + os.pardir, + os.pardir, + "examples/paste/auth_token.ini"), + global_conf={"log_name": "auth_token.log"}) + wsgi.server(eventlet.listen(('', 8090)), app) diff --git a/quantum/common/authorization.py b/quantum/common/authorization.py new file mode 100644 index 000000000..30a90c428 --- /dev/null +++ b/quantum/common/authorization.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# +# Copyright (c) 2010-2011 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" Middleware for authorizing Quantum Operations + This is a first and very trivial implementation of a middleware + for authorizing requests to Quantum API. + It only verifies that the tenant requesting the operation owns the + network (and thus the port) on which it is going to operate. + It also verifies, if the operation involves an interface, that + the tenant owns that interface by querying an API on the service + where the interface is defined. +""" +import logging + +from webob.exc import HTTPUnauthorized, HTTPForbidden +from webob.exc import Request, Response + +LOG = logging.getLogger('quantum.common.authorization') + +class QuantumAuthorization(object): + """ Authorizes an operation before it reaches the API WSGI app""" + + def __call__(self, req, start_response): + """ Handle incoming request. Authorize. And send downstream. """ + LOG.debug("entering QuantumAuthorization.__call__") + self.start_response = start_response + self.req = req + + # Retrieves TENANT ID from headers as the request + # should already have been authenticated with Keystone + self.headers = req.copy() + if not "X_TENANT" in self.headers: + # This is bad, very bad + self._reject() + + auth_tenant_id = self.headers['X_TENANT'] + path = self.req.path + parts=path.split('/') + #TODO (salvatore-orlando): need bound checking here + idx = parts.index('tenants') + 1 + req_tenant_id = parts[idx] + + if auth_tenant_id != req_tenant_id: + # This is bad, very bad + self._forbid() + + # Okay, authorize it! + + def _reject(self): + """Apparently the request has not been authenticated """ + return HTTPUnauthorized()(self.env, + self.start_response) + + + def _forbid(self): + """Cannot authorize. Operating on non-owned resources""" + return HTTPForbidden()(self.env, + self.start_response) + + + +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 authz_filter(app): + return QuantumAuthorization(app, conf) + return authz_filter diff --git a/quantum/common/bufferedhttp.py b/quantum/common/bufferedhttp.py new file mode 100644 index 000000000..fdb35ee65 --- /dev/null +++ b/quantum/common/bufferedhttp.py @@ -0,0 +1,165 @@ +# Copyright (c) 2010-2011 OpenStack, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Monkey Patch httplib.HTTPResponse to buffer reads of headers. This can improve +performance when making large numbers of small HTTP requests. This module +also provides helper functions to make HTTP connections using +BufferedHTTPResponse. + +.. warning:: + + If you use this, be sure that the libraries you are using do not access + the socket directly (xmlrpclib, I'm looking at you :/), and instead + make all calls through httplib. +""" + +from urllib import quote +import logging +import time + +from eventlet.green.httplib import CONTINUE, HTTPConnection, HTTPMessage, \ + HTTPResponse, HTTPSConnection, _UNKNOWN + + +class BufferedHTTPResponse(HTTPResponse): + """HTTPResponse class that buffers reading of headers""" + + def __init__(self, sock, debuglevel=0, strict=0, + method=None): # pragma: no cover + self.sock = sock + self.fp = sock.makefile('rb') + self.debuglevel = debuglevel + self.strict = strict + self._method = method + + self.msg = None + + # from the Status-Line of the response + self.version = _UNKNOWN # HTTP-Version + self.status = _UNKNOWN # Status-Code + self.reason = _UNKNOWN # Reason-Phrase + + self.chunked = _UNKNOWN # is "chunked" being used? + self.chunk_left = _UNKNOWN # bytes left to read in current chunk + self.length = _UNKNOWN # number of bytes left in response + self.will_close = _UNKNOWN # conn will close at end of response + + def expect_response(self): + self.fp = self.sock.makefile('rb', 0) + version, status, reason = self._read_status() + if status != CONTINUE: + self._read_status = lambda: (version, status, reason) + self.begin() + else: + self.status = status + self.reason = reason.strip() + self.version = 11 + self.msg = HTTPMessage(self.fp, 0) + self.msg.fp = None + + +class BufferedHTTPConnection(HTTPConnection): + """HTTPConnection class that uses BufferedHTTPResponse""" + response_class = BufferedHTTPResponse + + def connect(self): + self._connected_time = time.time() + return HTTPConnection.connect(self) + + def putrequest(self, method, url, skip_host=0, skip_accept_encoding=0): + self._method = method + self._path = url + return HTTPConnection.putrequest(self, method, url, skip_host, + skip_accept_encoding) + + def getexpect(self): + response = BufferedHTTPResponse(self.sock, strict=self.strict, + method=self._method) + response.expect_response() + return response + + def getresponse(self): + response = HTTPConnection.getresponse(self) + logging.debug(("HTTP PERF: %(time).5f seconds to %(method)s " + "%(host)s:%(port)s %(path)s)"), + {'time': time.time() - self._connected_time, 'method': self._method, + 'host': self.host, 'port': self.port, 'path': self._path}) + return response + + +def http_connect(ipaddr, port, device, partition, method, path, + headers=None, query_string=None, ssl=False): + """ + Helper function to create an HTTPConnection object. If ssl is set True, + HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection + will be used, which is buffered for backend Swift services. + + :param ipaddr: IPv4 address to connect to + :param port: port to connect to + :param device: device of the node to query + :param partition: partition on the device + :param method: HTTP method to request ('GET', 'PUT', 'POST', etc.) + :param path: request path + :param headers: dictionary of headers + :param query_string: request query string + :param ssl: set True if SSL should be used (default: False) + :returns: HTTPConnection object + """ + if ssl: + conn = HTTPSConnection('%s:%s' % (ipaddr, port)) + else: + conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port)) + path = quote('/' + device + '/' + str(partition) + path) + if query_string: + path += '?' + query_string + conn.path = path + conn.putrequest(method, path) + if headers: + for header, value in headers.iteritems(): + conn.putheader(header, value) + conn.endheaders() + return conn + + +def http_connect_raw(ipaddr, port, method, path, headers=None, + query_string=None, ssl=False): + """ + Helper function to create an HTTPConnection object. If ssl is set True, + HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection + will be used, which is buffered for backend Swift services. + + :param ipaddr: IPv4 address to connect to + :param port: port to connect to + :param method: HTTP method to request ('GET', 'PUT', 'POST', etc.) + :param path: request path + :param headers: dictionary of headers + :param query_string: request query string + :param ssl: set True if SSL should be used (default: False) + :returns: HTTPConnection object + """ + if ssl: + conn = HTTPSConnection('%s:%s' % (ipaddr, port)) + else: + conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port)) + if query_string: + path += '?' + query_string + conn.path = path + conn.putrequest(method, path) + if headers: + for header, value in headers.iteritems(): + conn.putheader(header, value) + conn.endheaders() + return conn