From 47908a4735d757d010aa30dcab4a2d4eb410aae6 Mon Sep 17 00:00:00 2001 From: termie Date: Tue, 10 Jan 2012 17:21:28 -0800 Subject: [PATCH] add legacy middleware --- keystone/bufferedhttp.py | 165 ++++++++ keystone/middleware/__init__.py | 1 + keystone/middleware/auth_token.py | 397 ++++++++++++++++++ .../{middleware.py => middleware/internal.py} | 0 4 files changed, 563 insertions(+) create mode 100644 keystone/bufferedhttp.py create mode 100644 keystone/middleware/__init__.py create mode 100755 keystone/middleware/auth_token.py rename keystone/{middleware.py => middleware/internal.py} (100%) diff --git a/keystone/bufferedhttp.py b/keystone/bufferedhttp.py new file mode 100644 index 0000000000..fdb35ee657 --- /dev/null +++ b/keystone/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 diff --git a/keystone/middleware/__init__.py b/keystone/middleware/__init__.py new file mode 100644 index 0000000000..1593e6e2f4 --- /dev/null +++ b/keystone/middleware/__init__.py @@ -0,0 +1 @@ +from keystone.middleware.internal import * diff --git a/keystone/middleware/auth_token.py b/keystone/middleware/auth_token.py new file mode 100755 index 0000000000..c4e28589d0 --- /dev/null +++ b/keystone/middleware/auth_token.py @@ -0,0 +1,397 @@ +#!/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 etc... + +Refer to: http://wiki.openstack.org/openstack-authn + + +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 httplib +import json +import os + +import eventlet +from eventlet import wsgi +from paste import deploy +from urlparse import urlparse +import webob +import webob.exc +from webob.exc import HTTPUnauthorized + +from keystone.bufferedhttp import http_connect_raw as http_connect + +PROTOCOL_NAME = "Token Authentication" + + +class AuthProtocol(object): + """Auth Middleware that handles authenticating client calls""" + + def _init_protocol_common(self, app, conf): + """ Common initialization code""" + print "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 OpenStack 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 + self.service_protocol = conf.get('service_protocol', 'https') + self.service_host = conf.get('service_host') + self.service_port = int(conf.get('service_port')) + self.service_url = '%s://%s:%s' % (self.service_protocol, + self.service_host, + self.service_port) + # used to verify this component with the OpenStack service or PAPIAuth + self.service_pass = conf.get('service_pass') + + # 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, 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') + + # where to tell clients to find the auth service (default to url + # constructed based on endpoint we have for the service to use) + self.auth_location = conf.get('auth_uri', + "%s://%s:%s" % (self.auth_protocol, + self.auth_host, + self.auth_port)) + + # Credentials used to verify this component with the Auth service since + # validating tokens is a privileged call + self.admin_token = conf.get('admin_token') + + def __init__(self, app, conf): + """ Common initialization code """ + + #TODO(ziad): maybe we refactor this into a superclass + self._init_protocol_common(app, conf) # Applies to all protocols + self._init_protocol(conf) # Specific to this protocol + + def __call__(self, env, start_response): + """ Handle incoming request. Authenticate. And send downstream. """ + + #Prep headers to forward request to local or remote downstream service + proxy_headers = env.copy() + for header in proxy_headers.iterkeys(): + if header[0:5] == 'HTTP_': + proxy_headers[header[5:]] = proxy_headers[header] + del proxy_headers[header] + + #Look for authentication claims + claims = self._get_claims(env) + if not claims: + #No claim(s) 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", env, proxy_headers) + else: + #Respond to client as appropriate for this auth protocol + return self._reject_request(env, start_response) + else: + # this request is presenting claims. Let's validate them + valid = self._validate_claims(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", env, proxy_headers) + else: + #Respond to client as appropriate for this auth protocol + return self._reject_claims(env, start_response) + else: + self._decorate_request("X_IDENTITY_STATUS", + "Confirmed", env, proxy_headers) + + #Collect information about valid claims + if valid: + claims = self._expound_claims(claims) + + # Store authentication data + if claims: + self._decorate_request('X_AUTHORIZATION', "Proxy %s" % + claims['user'], env, proxy_headers) + + # For legacy compatibility before we had ID and Name + self._decorate_request('X_TENANT', + claims['tenant'], env, proxy_headers) + + # Services should use these + self._decorate_request('X_TENANT_NAME', + claims.get('tenant_name', claims['tenant']), + env, proxy_headers) + self._decorate_request('X_TENANT_ID', + claims['tenant'], env, proxy_headers) + + self._decorate_request('X_USER', + claims['user'], env, proxy_headers) + 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, env, proxy_headers) + + # NOTE(todd): unused + self.expanded = True + + #Send request downstream + return self._forward_request(env, start_response, proxy_headers) + + # NOTE(todd): unused + 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": "application/json"} + params = {"passwordCredentials": {"username": username, + "password": password, + "tenantId": "1"}} + 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, env, start_response): + """Redirect client to auth server""" + return webob.exc.HTTPUnauthorized("Authentication required", + [("WWW-Authenticate", + "Keystone uri='%s'" % self.auth_location)])(env, + start_response) + + def _reject_claims(self, env, start_response): + """Client sent bad claims""" + return webob.exc.HTTPUnauthorized()(env, + start_response) + + def _validate_claims(self, claims): + """Validate claims, and provide identity information isf 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 + #auth = self.get_admin_auth_token("admin", "secrete", "1") + #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": "application/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, claims): + # 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": "application/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'): + raise LookupError('Unable to locate claims: %s' % resp.status) + + token_info = json.loads(data) + roles = [] + role_refs = token_info["access"]["user"]["roles"] + if role_refs != None: + for role_ref in role_refs: + # Nova looks for the non case-sensitive role 'Admin' + # to determine admin-ness + roles.append(role_ref["name"]) + + try: + tenant = token_info['access']['token']['tenant']['id'] + tenant_name = token_info['access']['token']['tenant']['name'] + except: + tenant = None + tenant_name = None + if not tenant: + tenant = token_info['access']['user'].get('tenantId') + tenant_name = token_info['access']['user'].get('tenantName') + verified_claims = {'user': token_info['access']['user']['username'], + 'tenant': tenant, + 'roles': roles} + if tenant_name: + verified_claims['tenantName'] = tenant_name + return verified_claims + + def _decorate_request(self, index, value, env, proxy_headers): + """Add headers to request""" + proxy_headers[index] = value + env["HTTP_%s" % index] = value + + def _forward_request(self, env, start_response, proxy_headers): + """Token/Auth processed & claims added to headers""" + self._decorate_request('AUTHORIZATION', + "Basic %s" % self.service_pass, env, proxy_headers) + #now decide how to pass on the call + if self.app: + # Pass to downstream WSGI component + return self.app(env, start_response) + #.custom_start_response) + else: + # We are forwarding to a remote service (no downstream WSGI app) + req = webob.Request(proxy_headers) + parsed = urlparse(req.url) + + conn = http_connect(self.service_host, + self.service_port, + req.method, + parsed.path, + 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 + + if resp.status == 401 or resp.status == 305: + # Add our own headers to the list + headers = [("WWW_AUTHENTICATE", + "Keystone uri='%s'" % self.auth_location)] + return webob.Response(status=resp.status, + body=data, + headerlist=headers)(env, start_response) + else: + return webob.Response(status=resp.status, + body=data)(env, 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 = deploy.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/keystone/middleware.py b/keystone/middleware/internal.py similarity index 100% rename from keystone/middleware.py rename to keystone/middleware/internal.py