Fix external auth (REMOTE_USER) plugin support

According to the WSGI specification "REMOTE_USER should be the string
username of the user, nothing more" [1], therefore no modifications
should be made to the REMOTE_USER variable and it should be fully
considered as the username. Otherwise the expected semantics of the
REMOTE_USER variable change, and an site administrator could get
undesirable side-effects.

[1] http://wsgi.readthedocs.org/en/latest/specifications/simple_authentication.html#specification

Moreover, it is important to have a consistent behaviour regarding
external authentication in V2 (not domain aware), V3 with default
domain and V3 with domain (see Bug #1253484) so that we produce similar
results with the three methods.

This change aims to solve this issues by removing the split of the
REMOTE_USER variable by "@" at all:

- In external.DefaultDomain, we cannot split REMOTE_USER by "@". This split
  will cause errors for remote users containing an "@" (not only
  emails, but also X.509 subjects, etc). The external.DefaultDomain plugin
  considers the REMOTE_USER variable as the username, and the configured
  default domain as the domain

- In external.Domain we should not split also the REMOTE_USER by "@". A
  new environment variable (REMOTE_DOMAIN) is introduced, so that any
  external plugin can pass down the right domain for the user. The
  external.Domain plugin considers the REMOTE_USER as the username, the
  REMOTE_DOMAIN as the domain if it is present, otherwise it takes the
  configured default domain.

- Two legacy plugins are also provided with the same behaviour as the
  Havana shipped ones. This plugins should not be used and are provided
  for compatibility reasons (see Bug #1254619)

Closes-Bug: #1254619
Closes-Bug: #1211233
Closes-Bug: #1253484

DocImpact: This change breaks backwards compatibility in favour of
security (see bug #1254619), therefore an upgrade not is needed. It is
needed to document the new plugins and state clearly the semantics of
the REMOTE_USER and REMOTE_DOMAIN variable for the WSGI filters. The
default external authentication plugin has been changed from
exernal.ExternalDefault to external.Default.

Change-Id: I1b2521a526fa976146dfe2fcf4d4c1851416d8ae
This commit is contained in:
Alvaro Lopez Garcia 2013-10-08 11:08:42 +02:00 committed by Dolph Mathews
parent 6c7f00d459
commit 1889ff2075
8 changed files with 253 additions and 54 deletions

View File

@ -129,7 +129,8 @@ file. It is up to the plugin to register its own configuration options.
Keystone provides three authentication methods by default. ``password`` handles password Keystone provides three authentication methods by default. ``password`` handles password
authentication and ``token`` handles token authentication. ``external`` is used in conjunction authentication and ``token`` handles token authentication. ``external`` is used in conjunction
with authentication performed by a container web server that sets the ``REMOTE_USER`` with authentication performed by a container web server that sets the ``REMOTE_USER``
environment variable. environment variable. For more details, refer to :doc:`External Authentication
<external-auth>`.
How to Implement an Authentication Plugin How to Implement an Authentication Plugin
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -174,7 +175,8 @@ agree on the ``user_id`` in the ``auth_context``.
The ``REMOTE_USER`` environment variable is only set from a containing webserver. However, The ``REMOTE_USER`` environment variable is only set from a containing webserver. However,
to ensure that a user must go through other authentication mechanisms, even if this variable to ensure that a user must go through other authentication mechanisms, even if this variable
is set, remove ``external`` from the list of plugins specified in ``methods``. This effectively is set, remove ``external`` from the list of plugins specified in ``methods``. This effectively
disables external authentication. disables external authentication. For more details, refer to :doc:`External
Authentication <external-auth>`.
Token Provider Token Provider

View File

@ -1,21 +1,51 @@
=========================================== ===========================================
Using external authentication with Keystone Using external authentication with Keystone
=========================================== ===========================================
.. _external-auth:
When Keystone is executed in :doc:`HTTPD <apache-httpd>` it is possible to When Keystone is executed in a web server like :doc:`Apache HTTPD
use external authentication methods different from the authentication <apache-httpd>` it is possible to use external authentication methods different
provided by the identity store backend. For example, this makes possible to from the authentication provided by the identity store backend or the different
use a SQL identity backend together with X.509 authentication, Kerberos, etc. authentication plugins. For example, this makes possible to use an SQL identity
instead of using the username/password combination. backend together with, X.509 authentication or Kerberos, for example, instead
of using the username and password combination.
When a web server is in charge of authentication, it is normally possible to
set the ``REMOTE_USER`` environment variable so that it can be used in the
underlying application. Keystone can be configured to use that environment
variable if set, so that the authentication is handled by the web server.
Configuration
=============
In Identity API v2, there is no way to disable external authentication. In
order to activate the external authentication mechanism for Identity API v3,
the ``external`` method must be in the list of enabled authentication methods.
By default it is enabled, so if you don't want to use external authentication,
remove it from the ``methods`` option in the ``auth`` section.
To configure the plugin that should be used set the ``external`` option again
in the ``auth`` section. There are two external authentication method plugins
provided by Keystone:
* ``keystone.auth.plugins.external.Default``: This plugin won't take into
account the domain information that the external authentication method may
pass down to Keystone and will always use the configured default domain. The
``REMOTE_USER`` variable is the username.
* ``keystone.auth.plugins.external.Domain``: This plugin expects that the
``REMOTE_DOMAIN`` variable contains the domain for the user. If this variable
is not present, the configured default domain will be used. The
``REMOTE_USER`` variable is the username.
Using HTTPD authentication Using HTTPD authentication
========================== ==========================
Webservers like Apache HTTP support many methods of authentication. Keystone can Web servers like Apache HTTP support many methods of authentication. Keystone
profit from this feature and let the authentication be done in the webserver, can profit from this feature and let the authentication be done in the web
that will pass down the authenticated user to Keystone using the ``REMOTE_USER`` server, that will pass down the authenticated user to Keystone using the
environment variable. This user must exist in advance in the identity backend ``REMOTE_USER`` environment variable. This user must exist in advance in the
so as to get a token from the controller. identity backend so as to get a token from the controller.
To use this method, Keystone should be running on :doc:`HTTPD <apache-httpd>`. To use this method, Keystone should be running on :doc:`HTTPD <apache-httpd>`.
@ -47,13 +77,14 @@ custom authentication mechanisms using the ``REMOTE_USER`` WSGI environment
variable. variable.
.. ATTENTION:: .. ATTENTION::
Please note that even if it is possible to develop a custom authentication Please note that even if it is possible to develop a custom authentication
module, it is preferable to use the modules in the HTTPD server. Such module, it is preferable to use the modules in the HTTPD server. Such
authentication modules in webservers like Apache have normally undergone authentication modules in webservers like Apache have normally undergone
years of development and use in production systems and are actively maintained years of development and use in production systems and are actively
upstream. Developing a custom authentication module that implements the same maintained upstream. Developing a custom authentication module that
authentication as an existing Apache module likely introduces a higher implements the same authentication as an existing Apache module likely
security risk. introduces a higher security risk.
If you find you must implement a custom authentication mechanism, you will need If you find you must implement a custom authentication mechanism, you will need
to develop a custom WSGI middleware pipeline component. This middleware should to develop a custom WSGI middleware pipeline component. This middleware should
@ -94,19 +125,21 @@ Pipeline configuration
---------------------- ----------------------
Once you have your WSGI middleware component developed you have to add it to Once you have your WSGI middleware component developed you have to add it to
your pipeline. The first step is to add the middleware to your configuration file. your pipeline. The first step is to add the middleware to your configuration
Assuming that your middleware module is ``keystone.middleware.MyMiddlewareAuth``, file. Assuming that your middleware module is
you can configure it in your ``keystone-paste.ini`` as:: ``keystone.middleware.MyMiddlewareAuth``, you can configure it in your
``keystone-paste.ini`` as::
[filter:my_auth] [filter:my_auth]
paste.filter_factory = keystone.middleware.MyMiddlewareAuth.factory paste.filter_factory = keystone.middleware.MyMiddlewareAuth.factory
The second step is to add your middleware to the pipeline. The exact place where The second step is to add your middleware to the pipeline. The exact place
you should place it will depend on your code (i.e. if you need for example that where you should place it will depend on your code (i.e. if you need for
the request body is converted from JSON before perform the authentication you example that the request body is converted from JSON before perform the
should place it after the ``json_body`` filter) but it should be set before the authentication you should place it after the ``json_body`` filter) but it
``public_service`` (for the ``public_api`` pipeline) or ``admin_service`` (for should be set before the ``public_service`` (for the ``public_api`` pipeline)
the ``admin_api`` pipeline), since they consume authentication. or ``admin_service`` (for the ``admin_api`` pipeline), since they consume
authentication.
For example, if the original pipeline looks like this:: For example, if the original pipeline looks like this::

View File

@ -14,7 +14,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
"""Keystone External Authentication Plugin""" """Keystone External Authentication Plugins"""
import abc import abc
@ -24,6 +24,7 @@ from keystone import auth
from keystone.common import config from keystone.common import config
from keystone import exception from keystone import exception
from keystone.openstack.common import log as logging from keystone.openstack.common import log as logging
from keystone.openstack.common import versionutils
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -45,7 +46,7 @@ class Base(auth.AuthMethodHandler):
msg = _('No authenticated user') msg = _('No authenticated user')
raise exception.Unauthorized(msg) raise exception.Unauthorized(msg)
try: try:
user_ref = self._authenticate(REMOTE_USER, auth_info) user_ref = self._authenticate(REMOTE_USER, context, auth_info)
auth_context['user_id'] = user_ref['id'] auth_context['user_id'] = user_ref['id']
if ('kerberos' in CONF.token.bind and if ('kerberos' in CONF.token.bind and
(context['environment'].get('AUTH_TYPE', '').lower() (context['environment'].get('AUTH_TYPE', '').lower()
@ -56,7 +57,7 @@ class Base(auth.AuthMethodHandler):
raise exception.Unauthorized(msg) raise exception.Unauthorized(msg)
@abc.abstractmethod @abc.abstractmethod
def _authenticate(self, remote_user): def _authenticate(self, remote_user, context, auth_info):
"""Look up the user in the identity backend. """Look up the user in the identity backend.
Return user_ref Return user_ref
@ -64,9 +65,78 @@ class Base(auth.AuthMethodHandler):
pass pass
class Default(Base): class DefaultDomain(Base):
def _authenticate(self, remote_user, auth_info): def _authenticate(self, remote_user, context, auth_info):
"""Use remote_user to look up the user in the identity backend.""" """Use remote_user to look up the user in the identity backend."""
domain_id = CONF.identity.default_domain_id
user_ref = auth_info.identity_api.get_user_by_name(remote_user,
domain_id)
return user_ref
class Domain(Base):
def _authenticate(self, remote_user, context, auth_info):
"""Use remote_user to look up the user in the identity backend.
The domain will be extracted from the REMOTE_DOMAIN environment
variable if present. If not, the default domain will be used.
"""
username = remote_user
try:
domain_name = context['environment']['REMOTE_DOMAIN']
except KeyError:
domain_id = CONF.identity.default_domain_id
else:
domain_ref = (auth_info.identity_api.
get_domain_by_name(domain_name))
domain_id = domain_ref['id']
user_ref = auth_info.identity_api.get_user_by_name(username,
domain_id)
return user_ref
class ExternalDefault(DefaultDomain):
"""Deprecated. Please use keystone.auth.external.DefaultDomain instead."""
@versionutils.deprecated(
as_of=versionutils.deprecated.ICEHOUSE,
in_favor_of='keystone.auth.external.DefaultDomain',
remove_in=+1)
def __init__(self):
super(ExternalDefault, self).__init__()
class ExternalDomain(Domain):
"""Deprecated. Please use keystone.auth.external.Domain instead."""
@versionutils.deprecated(
as_of=versionutils.deprecated.ICEHOUSE,
in_favor_of='keystone.auth.external.Domain',
remove_in=+1)
def __init__(self):
super(ExternalDomain, self).__init__()
class LegacyDefaultDomain(Base):
"""Deprecated. Please use keystone.auth.external.DefaultDomain instead.
This plugin exists to provide compatibility for the unintended behavior
described here: https://bugs.launchpad.net/keystone/+bug/1253484
"""
@versionutils.deprecated(
as_of=versionutils.deprecated.ICEHOUSE,
in_favor_of='keystone.auth.external.DefaultDomain',
remove_in=+1)
def __init__(self):
super(LegacyDefaultDomain, self).__init__()
def _authenticate(self, remote_user, context, auth_info):
"""Use remote_user to look up the user in the identity backend."""
# NOTE(dolph): this unintentionally discards half the REMOTE_USER value
names = remote_user.split('@') names = remote_user.split('@')
username = names.pop(0) username = names.pop(0)
domain_id = CONF.identity.default_domain_id domain_id = CONF.identity.default_domain_id
@ -75,8 +145,17 @@ class Default(Base):
return user_ref return user_ref
class Domain(Base): class LegacyDomain(Base):
def _authenticate(self, remote_user, auth_info): """Deprecated. Please use keystone.auth.external.Domain instead."""
@versionutils.deprecated(
as_of=versionutils.deprecated.ICEHOUSE,
in_favor_of='keystone.auth.external.Domain',
remove_in=+1)
def __init__(self):
super(LegacyDomain, self).__init__()
def _authenticate(self, remote_user, context, auth_info):
"""Use remote_user to look up the user in the identity backend. """Use remote_user to look up the user in the identity backend.
If remote_user contains an `@` assume that the substring before the If remote_user contains an `@` assume that the substring before the
@ -95,21 +174,3 @@ class Domain(Base):
user_ref = auth_info.identity_api.get_user_by_name(username, user_ref = auth_info.identity_api.get_user_by_name(username,
domain_id) domain_id)
return user_ref return user_ref
# NOTE(aloga): ExternalDefault and External have been renamed to Default and
# Domain.
class ExternalDefault(Default):
"""Deprecated. Please use keystone.auth.external.Default instead."""
def __init__(self):
msg = _('keystone.auth.external.ExternalDefault is deprecated in'
'favor of keystone.auth.external.Default')
LOG.warning(msg)
class ExternalDomain(Domain):
"""Deprecated. Please use keystone.auth.external.Domain instead."""
def __init__(self):
msg = _('keystone.auth.external.ExternalDomain is deprecated in'
'favor of keystone.auth.external.Domain')
LOG.warning(msg)

View File

@ -256,7 +256,7 @@ FILE_OPTIONS = {
default='keystone.auth.plugins.token.Token'), default='keystone.auth.plugins.token.Token'),
#deals with REMOTE_USER authentication #deals with REMOTE_USER authentication
cfg.StrOpt('external', cfg.StrOpt('external',
default='keystone.auth.plugins.external.Default')], default='keystone.auth.plugins.external.DefaultDomain')],
'paste_deploy': [ 'paste_deploy': [
cfg.StrOpt('config_file', default=None)], cfg.StrOpt('config_file', default=None)],
'memcache': [ 'memcache': [

View File

@ -0,0 +1,3 @@
[auth]
methods = external, password, token
external = keystone.auth.plugins.external.LegacyDefaultDomain

View File

@ -0,0 +1,3 @@
[auth]
methods = external, password, token
external = keystone.auth.plugins.external.LegacyDomain

View File

@ -1043,8 +1043,11 @@ class RestfulTestCase(rest.RestfulTestCase):
auth_data['scope'] = self.build_auth_scope(**kwargs) auth_data['scope'] = self.build_auth_scope(**kwargs)
return {'auth': auth_data} return {'auth': auth_data}
def build_external_auth_request(self, remote_user, auth_data=None): def build_external_auth_request(self, remote_user,
remote_domain=None, auth_data=None):
context = {'environment': {'REMOTE_USER': remote_user}} context = {'environment': {'REMOTE_USER': remote_user}}
if remote_domain:
context['environment']['REMOTE_DOMAIN'] = remote_domain
if not auth_data: if not auth_data:
auth_data = self.build_authentication_request()['auth'] auth_data = self.build_authentication_request()['auth']
no_context = None no_context = None

View File

@ -1094,12 +1094,42 @@ class TestAuthExternalDisabled(test_v3.RestfulTestCase):
auth_context) auth_context)
class TestAuthExternalDomain(test_v3.RestfulTestCase): class TestAuthExternalLegacyDefaultDomain(test_v3.RestfulTestCase):
content_type = 'json' content_type = 'json'
def config_files(self): def config_files(self):
cfg_list = self._config_file_list[:] cfg_list = self._config_file_list[:]
cfg_list.append(tests.dirs.tests('auth_plugin_external_domain.conf')) cfg_list.append(
tests.dirs.tests('auth_plugin_external_default_legacy.conf'))
return cfg_list
def test_remote_user_no_realm(self):
CONF.auth.methods = 'external'
api = auth.controllers.Auth()
context, auth_info, auth_context = self.build_external_auth_request(
self.default_domain_user['name'])
api.authenticate(context, auth_info, auth_context)
self.assertEqual(auth_context['user_id'],
self.default_domain_user['id'])
def test_remote_user_no_domain(self):
api = auth.controllers.Auth()
context, auth_info, auth_context = self.build_external_auth_request(
self.user['name'])
self.assertRaises(exception.Unauthorized,
api.authenticate,
context,
auth_info,
auth_context)
class TestAuthExternalLegacyDomain(test_v3.RestfulTestCase):
content_type = 'json'
def config_files(self):
cfg_list = self._config_file_list[:]
cfg_list.append(
tests.dirs.tests('auth_plugin_external_domain_legacy.conf'))
return cfg_list return cfg_list
def test_remote_user_with_realm(self): def test_remote_user_with_realm(self):
@ -1144,6 +1174,61 @@ class TestAuthExternalDomain(test_v3.RestfulTestCase):
self.assertEqual(token['bind']['kerberos'], self.user['name']) self.assertEqual(token['bind']['kerberos'], self.user['name'])
class TestAuthExternalDomain(test_v3.RestfulTestCase):
content_type = 'json'
def config_files(self):
cfg_list = self._config_file_list[:]
cfg_list.append(tests.dirs.tests('auth_plugin_external_domain.conf'))
return cfg_list
def test_remote_user_with_realm(self):
api = auth.controllers.Auth()
remote_user = self.user['name']
remote_domain = self.domain['name']
context, auth_info, auth_context = self.build_external_auth_request(
remote_user, remote_domain=remote_domain)
api.authenticate(context, auth_info, auth_context)
self.assertEqual(auth_context['user_id'], self.user['id'])
# Now test to make sure the user name can, itself, contain the
# '@' character.
user = {'name': 'myname@mydivision'}
self.identity_api.update_user(self.user['id'], user)
remote_user = user["name"]
context, auth_info, auth_context = self.build_external_auth_request(
remote_user, remote_domain=remote_domain)
api.authenticate(context, auth_info, auth_context)
self.assertEqual(auth_context['user_id'], self.user['id'])
def test_project_id_scoped_with_remote_user(self):
CONF.token.bind = ['kerberos']
auth_data = self.build_authentication_request(
project_id=self.project['id'])
remote_user = self.user['name']
remote_domain = self.domain['name']
self.admin_app.extra_environ.update({'REMOTE_USER': remote_user,
'REMOTE_DOMAIN': remote_domain,
'AUTH_TYPE': 'Negotiate'})
r = self.post('/auth/tokens', body=auth_data)
token = self.assertValidProjectScopedTokenResponse(r)
self.assertEqual(token['bind']['kerberos'], self.user['name'])
def test_unscoped_bind_with_remote_user(self):
CONF.token.bind = ['kerberos']
auth_data = self.build_authentication_request()
remote_user = self.user['name']
remote_domain = self.domain['name']
self.admin_app.extra_environ.update({'REMOTE_USER': remote_user,
'REMOTE_DOMAIN': remote_domain,
'AUTH_TYPE': 'Negotiate'})
r = self.post('/auth/tokens', body=auth_data)
token = self.assertValidUnscopedTokenResponse(r)
self.assertEqual(token['bind']['kerberos'], self.user['name'])
class TestAuthJSON(test_v3.RestfulTestCase): class TestAuthJSON(test_v3.RestfulTestCase):
content_type = 'json' content_type = 'json'
@ -1608,6 +1693,15 @@ class TestAuthJSON(test_v3.RestfulTestCase):
api.authenticate(context, auth_info, auth_context) api.authenticate(context, auth_info, auth_context)
self.assertEqual(auth_context['user_id'], self.assertEqual(auth_context['user_id'],
self.default_domain_user['id']) self.default_domain_user['id'])
# Now test to make sure the user name can, itself, contain the
# '@' character.
user = {'name': 'myname@mydivision'}
self.identity_api.update_user(self.default_domain_user['id'], user)
context, auth_info, auth_context = self.build_external_auth_request(
user["name"])
api.authenticate(context, auth_info, auth_context)
self.assertEqual(auth_context['user_id'],
self.default_domain_user['id'])
def test_remote_user_no_domain(self): def test_remote_user_no_domain(self):
api = auth.controllers.Auth() api = auth.controllers.Auth()