From 58126e14d7a9e50721b71aee0b9d113c4d79bd8c Mon Sep 17 00:00:00 2001 From: "Cody A.W. Somerville" Date: Sun, 5 May 2013 22:05:50 -0400 Subject: [PATCH] Support password authentication. Add alternative authentication middleware to authenticate against Keystone using username and password instead of validating existing auth token. The benefit being that you no longer require admin/service token to authenticate users. To use, update heat-api.conf: [paste_deploy] flavor = standalone This should make it possible to try heat out against existing public clouds. Change-Id: Ia584bba78e8984581f0fb6882bbb17d5efa238db --- etc/heat/api-paste.ini | 19 ++++- etc/heat/heat-api.conf | 3 +- heat/common/auth_password.py | 111 ++++++++++++++++++++++++++++ heat/tests/test_auth_password.py | 119 +++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 heat/common/auth_password.py create mode 100644 heat/tests/test_auth_password.py diff --git a/etc/heat/api-paste.ini b/etc/heat/api-paste.ini index 9ac9f719..8afcd66b 100644 --- a/etc/heat/api-paste.ini +++ b/etc/heat/api-paste.ini @@ -3,6 +3,17 @@ [pipeline:heat-api] pipeline = versionnegotiation authtoken context apiv1app +# heat-api pipeline for standalone heat +# ie. uses alternative auth backend that authenticates users against keystone +# using username and password instead of validating token (which requires +# an admin/service token). +# To enable, in heat-api.conf: +# [paste_deploy] +# flavor = standalone +# +[pipeline:heat-api-standalone] +pipeline = versionnegotiation authpassword context apiv1app + # heat-api pipeline for custom cloud backends # i.e. in heat-api.conf: # [paste_deploy] @@ -49,8 +60,14 @@ paste.filter_factory = heat.common.context:ContextMiddleware_filter_factory [filter:ec2authtoken] paste.filter_factory = heat.api.aws.ec2token:EC2Token_filter_factory +# Auth middleware that validates token against keystone [filter:authtoken] paste.filter_factory = heat.common.auth_token:filter_factory +# Auth middleware that validates username/password against keystone +[filter:authpassword] +paste.filter_factory = heat.common.auth_password:filter_factory + +# Auth middleware that validates against custom backend [filter:custombackendauth] -paste.filter_factory = heat.common.custom_backend_auth:filter_factory \ No newline at end of file +paste.filter_factory = heat.common.custom_backend_auth:filter_factory diff --git a/etc/heat/heat-api.conf b/etc/heat/heat-api.conf index a3db9dac..8566ab9b 100644 --- a/etc/heat/heat-api.conf +++ b/etc/heat/heat-api.conf @@ -29,9 +29,10 @@ rpc_backend=heat.openstack.common.rpc.impl_qpid -# Uncomment this if you're using a custom cloud backend: +# Uncomment to deploy different flavor of heat-api pipeline: # [paste_deploy] # flavor = custombackend +# flavor = standalone [keystone_authtoken] auth_host = 127.0.0.1 diff --git a/heat/common/auth_password.py b/heat/common/auth_password.py new file mode 100644 index 00000000..bcc97993 --- /dev/null +++ b/heat/common/auth_password.py @@ -0,0 +1,111 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 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. + +from keystoneclient.v2_0 import client as keystone_client +from keystoneclient import exceptions as keystone_exceptions +from oslo.config import cfg +from webob.exc import HTTPUnauthorized + +from heat.openstack.common import importutils + + +class KeystonePasswordAuthProtocol(object): + """ + Alternative authentication middleware that uses username and password + to authenticate against Keystone instead of validating existing auth token. + The benefit being that you no longer require admin/service token to + authenticate users. + """ + + def __init__(self, app, conf): + self.app = app + self.conf = conf + if 'auth_uri' in self.conf: + auth_url = self.conf['auth_uri'] + else: + # Import auth_token to have keystone_authtoken settings setup. + importutils.import_module('keystoneclient.middleware.auth_token') + auth_url = cfg.CONF.keystone_authtoken['auth_uri'] + self.auth_url = auth_url + + def __call__(self, env, start_response): + """Authenticate incoming request.""" + username = env.get('HTTP_X_AUTH_USER') + password = env.get('HTTP_X_AUTH_KEY') + # Determine tenant id from path. + tenant = env.get('PATH_INFO').split('/')[1] + if not tenant: + return self._reject_request(env, start_response) + try: + client = keystone_client.Client( + username=username, password=password, tenant_id=tenant, + auth_url=self.auth_url) + except (keystone_exceptions.Unauthorized, + keystone_exceptions.Forbidden, + keystone_exceptions.NotFound, + keystone_exceptions.AuthorizationFailure): + return self._reject_request(env, start_response) + env['keystone.token_info'] = client.auth_ref + env.update(self._build_user_headers(client.auth_ref)) + return self.app(env, start_response) + + def _reject_request(self, env, start_response): + """Redirect client to auth server.""" + headers = [('WWW-Authenticate', 'Keystone uri=\'%s\'' % self.auth_url)] + resp = HTTPUnauthorized('Authentication required', headers) + return resp(env, start_response) + + def _build_user_headers(self, token_info): + """Build headers that represent authenticated user from auth token.""" + tenant_id = token_info['token']['tenant']['id'] + tenant_name = token_info['token']['tenant']['name'] + user_id = token_info['user']['id'] + user_name = token_info['user']['username'] + roles = ','.join( + [role['name'] for role in token_info['user']['roles']]) + service_catalog = token_info['serviceCatalog'] + auth_token = token_info['token']['id'] + + headers = { + 'HTTP_X_IDENTITY_STATUS': 'Confirmed', + 'HTTP_X_PROJECT_ID': tenant_id, + 'HTTP_X_PROJECT_NAME': tenant_name, + 'HTTP_X_USER_ID': user_id, + 'HTTP_X_USER_NAME': user_name, + 'HTTP_X_ROLES': roles, + 'HTTP_X_SERVICE_CATALOG': service_catalog, + 'HTTP_X_AUTH_TOKEN': auth_token, + 'HTTP_X_AUTH_URL': self.auth_url, + # DEPRECATED + 'HTTP_X_USER': user_name, + 'HTTP_X_TENANT_ID': tenant_id, + 'HTTP_X_TENANT_NAME': tenant_name, + 'HTTP_X_TENANT': tenant_name, + 'HTTP_X_ROLE': roles, + } + + return headers + + +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 KeystonePasswordAuthProtocol(app, conf) + return auth_filter diff --git a/heat/tests/test_auth_password.py b/heat/tests/test_auth_password.py new file mode 100644 index 00000000..cd94d713 --- /dev/null +++ b/heat/tests/test_auth_password.py @@ -0,0 +1,119 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 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. + +from keystoneclient.v2_0 import client as keystone_client +from keystoneclient.exceptions import Unauthorized +import webob + +from heat.common.auth_password import KeystonePasswordAuthProtocol +from heat.tests.common import HeatTestCase + +EXPECTED_V2_DEFAULT_ENV_RESPONSE = { + 'HTTP_X_IDENTITY_STATUS': 'Confirmed', + 'HTTP_X_TENANT_ID': 'tenant_id1', + 'HTTP_X_TENANT_NAME': 'tenant_name1', + 'HTTP_X_USER_ID': 'user_id1', + 'HTTP_X_USER_NAME': 'user_name1', + 'HTTP_X_ROLES': 'role1,role2', + 'HTTP_X_USER': 'user_name1', # deprecated (diablo-compat) + 'HTTP_X_TENANT': 'tenant_name1', # deprecated (diablo-compat) + 'HTTP_X_ROLE': 'role1,role2', # deprecated (diablo-compat) +} + +TOKEN_RESPONSE = { + 'token': { + 'id': 'lalalalalala', + 'expires': '2020-01-01T00:00:10.000123Z', + 'tenant': { + 'id': 'tenant_id1', + 'name': 'tenant_name1', + }, + }, + 'user': { + 'id': 'user_id1', + 'username': 'user_name1', + 'roles': [ + {'name': 'role1'}, + {'name': 'role2'}, + ], + }, + 'serviceCatalog': {} +} + + +class FakeApp(object): + """This represents a WSGI app protected by our auth middleware.""" + + def __init__(self, expected_env=None): + expected_env = expected_env or {} + self.expected_env = dict(EXPECTED_V2_DEFAULT_ENV_RESPONSE) + self.expected_env.update(expected_env) + + def __call__(self, env, start_response): + """Assert that expected environment is present when finally called.""" + for k, v in self.expected_env.items(): + assert env[k] == v, '%s != %s' % (env[k], v) + resp = webob.Response() + resp.body = 'SUCCESS' + return resp(env, start_response) + + +class KeystonePasswordAuthProtocolTest(HeatTestCase): + + def setUp(self): + super(KeystonePasswordAuthProtocolTest, self).setUp() + self.config = {'auth_uri': 'http://keystone.test.com:5000'} + self.app = FakeApp( + expected_env={'HTTP_X_AUTH_URL': self.config['auth_uri']}) + self.middleware = KeystonePasswordAuthProtocol(self.app, self.config) + + def _start_fake_response(self, status, headers): + self.response_status = int(status.split(' ', 1)[0]) + self.response_headers = dict(headers) + + def test_valid_request(self): + self.m.StubOutClassWithMocks(keystone_client, 'Client') + mock_client = keystone_client.Client( + username='user_name1', password='goodpassword', + tenant_id='tenant_id1', auth_url=self.config['auth_uri']) + mock_client.auth_ref = TOKEN_RESPONSE + self.m.ReplayAll() + req = webob.Request.blank('/tenant_id1/') + req.headers['X_AUTH_USER'] = 'user_name1' + req.headers['X_AUTH_KEY'] = 'goodpassword' + self.middleware(req.environ, self._start_fake_response) + self.m.VerifyAll() + + def test_request_with_bad_credentials(self): + self.m.StubOutWithMock( + keystone_client, 'Client', use_mock_anything=True) + mock_client = keystone_client.Client( + username='user_name1', password='badpassword', + tenant_id='tenant_id1', auth_url=self.config['auth_uri']) + mock_client.AndRaise(Unauthorized(401)) + self.m.ReplayAll() + req = webob.Request.blank('/tenant_id1/') + req.headers['X_AUTH_USER'] = 'user_name1' + req.headers['X_AUTH_KEY'] = 'badpassword' + self.middleware(req.environ, self._start_fake_response) + self.m.VerifyAll() + self.assertEqual(self.response_status, 401) + + def test_request_with_no_tenant_in_url_or_auth_headers(self): + req = webob.Request.blank('/') + self.middleware(req.environ, self._start_fake_response) + self.assertEqual(self.response_status, 401) -- 2.45.2