From cfda18b43e2e5782e5595e54f4a48f30b860e5dd Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Mon, 8 Apr 2013 15:35:55 +1200 Subject: [PATCH] Subclass keystone middleware to set headers Replace old forked auth_token with a subclass of keystoneclient middleware. The advantages of moving to keystoneclient middleware: - can use v3 (or v2) keystone api - PKI tokens - token revocation The subclass sets the following headers to be consumed by our request context filter: - X-Admin-User - X-Admin-Pass - X-Admin-Tenant-Name - X-Auth-Url The need to override _build_user_headers should be a short-term thing, X-Admin-* isn't actually used currently, and there are a few options that need to be discussed for getting X-Auth-Url to the engine. Change-Id: Iacc5046fbf559724a4ae0bd6091d662e23d65544 Blueprint: keystone-middleware --- etc/heat/heat-api-cfn-paste.ini | 3 - etc/heat/heat-api-cloudwatch-paste.ini | 3 - etc/heat/heat-api-paste.ini | 5 +- heat/common/auth_token.py | 508 +------------------------ 4 files changed, 13 insertions(+), 506 deletions(-) diff --git a/etc/heat/heat-api-cfn-paste.ini b/etc/heat/heat-api-cfn-paste.ini index 18625852..83adcd67 100644 --- a/etc/heat/heat-api-cfn-paste.ini +++ b/etc/heat/heat-api-cfn-paste.ini @@ -29,9 +29,6 @@ keystone_ec2_uri = http://localhost:5000/v2.0/ec2tokens [filter:authtoken] paste.filter_factory = heat.common.auth_token:filter_factory -service_protocol = http -service_host = 127.0.0.1 -service_port = 5000 auth_host = 127.0.0.1 auth_port = 35357 auth_protocol = http diff --git a/etc/heat/heat-api-cloudwatch-paste.ini b/etc/heat/heat-api-cloudwatch-paste.ini index af144be5..3396e5bd 100644 --- a/etc/heat/heat-api-cloudwatch-paste.ini +++ b/etc/heat/heat-api-cloudwatch-paste.ini @@ -29,9 +29,6 @@ keystone_ec2_uri = http://localhost:5000/v2.0/ec2tokens [filter:authtoken] paste.filter_factory = heat.common.auth_token:filter_factory -service_protocol = http -service_host = 127.0.0.1 -service_port = 5000 auth_host = 127.0.0.1 auth_port = 35357 auth_protocol = http diff --git a/etc/heat/heat-api-paste.ini b/etc/heat/heat-api-paste.ini index 18bad73d..68bd6a9d 100644 --- a/etc/heat/heat-api-paste.ini +++ b/etc/heat/heat-api-paste.ini @@ -32,13 +32,10 @@ paste.filter_factory = heat.common.context:ContextMiddleware_filter_factory [filter:authtoken] paste.filter_factory = heat.common.auth_token:filter_factory -service_protocol = http -service_host = 127.0.0.1 -service_port = 5000 auth_host = 127.0.0.1 auth_port = 35357 auth_protocol = http -auth_uri = http://127.0.0.1:35357/v2.0 +auth_uri = http://127.0.0.1:5000/v2.0 # These must be set to your local values in order for the token # authentication to work. diff --git a/heat/common/auth_token.py b/heat/common/auth_token.py index 10c48bc2..3fa38458 100644 --- a/heat/common/auth_token.py +++ b/heat/common/auth_token.py @@ -15,508 +15,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -TOKEN-BASED AUTH MIDDLEWARE - -This WSGI component: - -* Verifies that incoming client requests have valid tokens by validating - tokens with the auth service. -* Rejects 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) -* Collects and forwards identity information based on a valid token - such as user name, tenant, etc - -Refer to: http://keystone.openstack.org/middlewarearchitecture.html - -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 - swift/cloud files - -Used for communication between components -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -WWW-Authenticate - HTTP header returned to a user indicating which endpoint to use - to retrieve a new token - -What we add to the request for use by the OpenStack service -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -HTTP_X_IDENTITY_STATUS - 'Confirmed' or 'Invalid' - The underlying service will only see a value of 'Invalid' if the Middleware - is configured to run in 'delay_auth_decision' mode - -HTTP_X_TENANT_ID - Identity service managed unique identifier, string - -HTTP_X_TENANT_NAME - Unique tenant identifier, string - -HTTP_X_USER_ID - Identity-service managed unique identifier, string - -HTTP_X_USER_NAME - Unique user identifier, string - -HTTP_X_ROLES - Comma delimited list of case-sensitive Roles - -HTTP_X_TENANT - *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME - Keystone-assigned unique identifier, deprecated - -HTTP_X_USER - *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME - Unique user name, string - -HTTP_X_ROLE - *Deprecated* in favor of HTTP_X_ROLES - This is being renamed, and the new header contains the same data. - -""" - -import httplib -import json import logging -import time - -import webob -import webob.exc - +from keystoneclient.middleware import auth_token LOG = logging.getLogger(__name__) -class InvalidUserToken(Exception): - pass - - -class ServiceError(Exception): - pass - - -class AuthProtocol(object): - """Auth Middleware that handles authenticating client calls.""" - - def __init__(self, app, conf): - LOG.info('Starting keystone auth_token middleware') - self.conf = conf - self.app = app - - # 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)) - - # 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', 35357)) - auth_protocol = conf.get('auth_protocol', 'https') - if auth_protocol == 'http': - self.http_client_class = httplib.HTTPConnection - else: - self.http_client_class = httplib.HTTPSConnection - - default_auth_uri = '%s://%s:%s' % (auth_protocol, - self.auth_host, - self.auth_port) - self.auth_uri = conf.get('auth_uri', default_auth_uri) - - # Credentials used to verify this component with the Auth service since - # validating tokens is a privileged call - self.admin_token = conf.get('admin_token') - self.admin_user = conf.get('admin_user') - self.admin_password = conf.get('admin_password') - self.admin_tenant_name = conf.get('admin_tenant_name', 'admin') - - # Token caching via memcache - self._cache = None - self._iso8601 = None - memcache_servers = conf.get('memcache_servers') - # By default the token will be cached for 5 minutes - self.token_cache_time = conf.get('token_cache_time', 300) - if memcache_servers: - try: - import memcache - import iso8601 - LOG.info('Using memcache for caching token') - self._cache = memcache.Client(memcache_servers.split(',')) - self._iso8601 = iso8601 - except NameError as e: - LOG.warn('disabled caching due to missing libraries %s', e) - - def __call__(self, env, start_response): - """Handle incoming request. - - Authenticate send downstream on success. Reject request if - we can't authenticate. - - """ - LOG.debug('Authenticating user token') - try: - self._remove_auth_headers(env) - user_token = self._get_user_token_from_header(env) - token_info = self._validate_user_token(user_token) - user_headers = self._build_user_headers(token_info) - self._add_headers(env, user_headers) - return self.app(env, start_response) - - except InvalidUserToken: - if self.delay_auth_decision: - LOG.info('Invalid user token - deferring reject downstream') - self._add_headers(env, {'X-Identity-Status': 'Invalid'}) - return self.app(env, start_response) - else: - LOG.info('Invalid user token - rejecting request') - return self._reject_request(env, start_response) - - except ServiceError as e: - LOG.critical('Unable to obtain admin token: %s' % e) - resp = webob.exc.HTTPServiceUnavailable() - return resp(env, start_response) - - def _remove_auth_headers(self, env): - """Remove headers so a user can't fake authentication. - - :param env: wsgi request environment - - """ - auth_headers = ( - 'X-Identity-Status', - 'X-Tenant-Id', - 'X-Tenant-Name', - 'X-User-Id', - 'X-User-Name', - 'X-Roles', - # Deprecated - 'X-User', - 'X-Tenant', - 'X-Role', - ) - LOG.debug('Removing headers from request environment: %s' % - ','.join(auth_headers)) - self._remove_headers(env, auth_headers) - - def _get_user_token_from_header(self, env): - """Get token id from request. - - :param env: wsgi request environment - :return token id - :raises InvalidUserToken if no token is provided in request - - """ - token = self._get_header(env, 'X-Auth-Token', - self._get_header(env, 'X-Storage-Token')) - if token: - return token - else: - LOG.warn("Unable to find authentication token in headers: %s", env) - raise InvalidUserToken('Unable to find token in headers') - - def _reject_request(self, env, start_response): - """Redirect client to auth server. - - :param env: wsgi request environment - :param start_response: wsgi response callback - :returns HTTPUnauthorized http response - - """ - headers = [('WWW-Authenticate', 'Keystone uri=\'%s\'' % self.auth_uri)] - resp = webob.exc.HTTPUnauthorized('Authentication required', headers) - return resp(env, start_response) - - def get_admin_token(self): - """Return admin token, possibly fetching a new one. - - :return admin token id - :raise ServiceError when unable to retrieve token from keystone - - """ - if not self.admin_token: - self.admin_token = self._request_admin_token() - - return self.admin_token - - def _get_http_connection(self): - return self.http_client_class(self.auth_host, self.auth_port) - - def _json_request(self, method, path, body=None, additional_headers=None): - """HTTP request helper used to make json requests. - - :param method: http method - :param path: relative request url - :param body: dict to encode to json as request body. Optional. - :param additional_headers: dict of additional headers to send with - http request. Optional. - :return (http response object, response body parsed as json) - :raise ServerError when unable to communicate with keystone - - """ - conn = self._get_http_connection() - - kwargs = { - 'headers': { - 'Content-type': 'application/json', - 'Accept': 'application/json', - }, - } - - if additional_headers: - kwargs['headers'].update(additional_headers) - - if body: - kwargs['body'] = json.dumps(body) - - try: - conn.request(method, path, **kwargs) - response = conn.getresponse() - body = response.read() - except Exception as e: - LOG.error('HTTP connection exception: %s' % e) - raise ServiceError('Unable to communicate with keystone') - finally: - conn.close() - - try: - data = json.loads(body) - except ValueError: - LOG.debug('Keystone did not return json-encoded body') - data = {} - - return response, data - - def _request_admin_token(self): - """Retrieve new token as admin user from keystone. - - :return token id upon success - :raises ServerError when unable to communicate with keystone - - """ - params = { - 'auth': { - 'passwordCredentials': { - 'username': self.admin_user, - 'password': self.admin_password, - }, - 'tenantName': self.admin_tenant_name, - } - } - - response, data = self._json_request('POST', - '/v2.0/tokens', - body=params) - - try: - token = data['access']['token']['id'] - assert token - return token - except (AssertionError, KeyError): - LOG.warn("Unexpected response from keystone service: %s", data) - raise ServiceError('invalid json response') - - def _validate_user_token(self, user_token, retry=True): - """Authenticate user token with keystone. - - :param user_token: user's token id - :param retry: flag that forces the middleware to retry - user authentication when an indeterminate - response is received. Optional. - :return token object received from keystone on success - :raise InvalidUserToken if token is rejected - :raise ServiceError if unable to authenticate token - - """ - cached = self._cache_get(user_token) - if cached: - return cached - - headers = {'X-Auth-Token': self.get_admin_token()} - response, data = self._json_request('GET', - '/v2.0/tokens/%s' % user_token, - additional_headers=headers) - - if response.status == 200: - self._cache_put(user_token, data) - return data - if response.status == 404: - # FIXME(ja): I'm assuming the 404 status means that user_token is - # invalid - not that the admin_token is invalid - self._cache_store_invalid(user_token) - LOG.warn("Authorization failed for token %s", user_token) - raise InvalidUserToken('Token authorization failed') - if response.status == 401: - LOG.info('Keystone rejected admin token %s, resetting', headers) - self.admin_token = None - else: - LOG.error('Bad response code while validating token: %s' % - response.status) - if retry: - LOG.info('Retrying validation') - return self._validate_user_token(user_token, False) - else: - LOG.warn("Invalid user token: %s. Keystone response: %s.", - user_token, data) - - raise InvalidUserToken() - +class AuthProtocol(auth_token.AuthProtocol): + """ + Subclass of keystoneclient auth_token middleware which also + sets the 'X-Auth-Url' header to the value specified in the config. + """ def _build_user_headers(self, token_info): - """Convert token object into headers. - - Build headers that represent authenticated user: - * X_IDENTITY_STATUS: Confirmed or Invalid - * X_TENANT_ID: id of tenant if tenant is present - * X_TENANT_NAME: name of tenant if tenant is present - * X_USER_ID: id of user - * X_USER_NAME: name of user - * X_ROLES: list of roles - - Additional (deprecated) headers include: - * X_USER: name of user - * X_TENANT: For legacy compatibility before we had ID and Name - * X_ROLE: list of roles - - :param token_info: token object returned by keystone on authentication - :raise InvalidUserToken when unable to parse token object - - """ - user = token_info['access']['user'] - token = token_info['access']['token'] - roles = ','.join([role['name'] for role in user.get('roles', [])]) - - def get_tenant_info(): - """Returns a (tenant_id, tenant_name) tuple from context.""" - def essex(): - """Essex puts the tenant ID and name on the token.""" - return (token['tenant']['id'], token['tenant']['name']) - - def pre_diablo(): - """Pre-diablo, Keystone only provided tenantId.""" - return (token['tenantId'], token['tenantId']) - - def default_tenant(): - """Assume the user's default tenant.""" - return (user['tenantId'], user['tenantName']) - - for method in [essex, pre_diablo, default_tenant]: - try: - return method() - except KeyError: - pass - - raise InvalidUserToken('Unable to determine tenancy.') - - tenant_id, tenant_name = get_tenant_info() - - user_id = user['id'] - user_name = user['name'] - - return { - 'X-Identity-Status': 'Confirmed', - 'X-Tenant-Id': tenant_id, - 'X-Tenant-Name': tenant_name, - 'X-User-Id': user_id, - 'X-User-Name': user_name, - 'X-Roles': roles, - # Deprecated - 'X-User': user_name, - 'X-Tenant': tenant_name, - 'X-Role': roles, - 'X-Admin-User': self.admin_user, - 'X-Admin-Pass': self.admin_password, - 'X-Admin-Tenant-Name': self.admin_tenant_name, - 'X-Auth-Url': self.conf['auth_uri'], - } - - def _header_to_env_var(self, key): - """Convert header to wsgi env variable. - - :param key: http header name (ex. 'X-Auth-Token') - :return wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN') - - """ - return 'HTTP_%s' % (key.replace('-', '_').upper()) - - def _add_headers(self, env, headers): - """Add http headers to environment.""" - for (k, v) in headers.iteritems(): - env_key = self._header_to_env_var(k) - env[env_key] = v - - def _remove_headers(self, env, keys): - """Remove http headers from environment.""" - for k in keys: - env_key = self._header_to_env_var(k) - try: - del env[env_key] - except KeyError: - pass - - def _get_header(self, env, key, default=None): - """Get http header from environment.""" - env_key = self._header_to_env_var(key) - return env.get(env_key, default) - - def _cache_get(self, token): - """Return token information from cache. - - If token is invalid raise InvalidUserToken - return token only if fresh (not expired). - """ - if self._cache and token: - key = 'tokens/%s' % token - cached = self._cache.get(key) - if cached == 'invalid': - LOG.debug('Cached Token %s is marked unauthorized', token) - raise InvalidUserToken('Token authorization failed') - if cached: - data, expires = cached - if time.time() < float(expires): - LOG.debug('Returning cached token %s', token) - return data - else: - LOG.debug('Cached Token %s seems expired', token) - - def _cache_put(self, token, data): - """Put token data into the cache. - - Stores the parsed expire date in cache allowing - quick check of token freshness on retrieval. - """ - if self._cache and data: - key = 'tokens/%s' % token - if 'token' in data.get('access', {}): - timestamp = data['access']['token']['expires'] - expires = self._iso8601.parse_date(timestamp).strftime('%s') - else: - LOG.error('invalid token format') - return - LOG.debug('Storing %s token in memcache', token) - self._cache.set(key, - (data, expires), - time=self.token_cache_time) - - def _cache_store_invalid(self, token): - """Store invalid token in cache.""" - if self._cache: - key = 'tokens/%s' % token - LOG.debug('Marking token %s as unauthorized in memcache', token) - self._cache.set(key, - 'invalid', - time=self.token_cache_time) + rval = super(AuthProtocol, self)._build_user_headers(token_info) + rval['X-Auth-Url'] = self.auth_uri + rval['X-Admin-User'] = self.admin_user + rval['X-Admin-Pass'] = self.admin_password + rval['X-Admin-Tenant-Name'] = self.admin_tenant_name + return rval def filter_factory(global_conf, **local_conf): -- 2.45.2