From: Cody A.W. Somerville Date: Mon, 6 May 2013 02:05:50 +0000 (-0400) Subject: Support password authentication. X-Git-Tag: 2014.1~641^2 X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=58126e14d7a9e50721b71aee0b9d113c4d79bd8c;p=openstack-build%2Fheat-build.git 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 --- 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)