Merge "Tokenless authz with X.509 SSL client certificate"

changes/24/220124/1
Jenkins 8 years ago committed by Gerrit Code Review
commit 7f279ad636

@ -0,0 +1,328 @@
..
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.
================================================
Configuring Keystone for Tokenless Authorization
================================================
.. NOTE::
This feature is experimental and unsupported in Liberty.
-----------
Definitions
-----------
* `X.509 Tokenless Authorization`: Provides a means to authorize client
operations within Keystone by using an X.509 SSL client certificate
without having to issue a token. For details, please refer to the specs
`Tokenless Authorization with X.509 Client SSL Certificate`_
.. _`Tokenless Authorization with X.509 Client SSL Certificate`: http://specs.openstack.org/openstack/keystone-specs/specs/liberty/keystone-tokenless-authz-with-x509-ssl-client-cert.html
Prerequisites
-------------
Keystone must be running in a web container with https enabled; tests have
been done with Apache/2.4.7 running on Ubuntu 14.04 . Please refer to
`running-keystone-in-httpd`_ and `apache-certificate-and-key-installation`_
as references for this setup.
.. _`running-keystone-in-httpd`: http://docs.openstack.org/developer/keystone/apache-httpd.html
.. _`apache-certificate-and-key-installation`: https://www.digitalocean.com/community/tutorials/how-to-create-a-ssl-certificate-on-apache-for-ubuntu-14-04
--------------------
Apache Configuration
--------------------
To enable X.509 tokenless authorization, SSL has to be enabled and configured
in the Apache virtual host file. The Client authentication attribute
``SSLVerifyClient`` should be set as ``optional`` to allow other token
authentication methods and attribute ``SSLOptions`` needs to set as
``+StdEnvVars`` to allow certificate attributes to be passed. The following
is the sample virtual host file used for the testing.
.. code-block:: ini
<VirtualHost *:443>
WSGIScriptAlias / /var/www/cgi-bin/keystone/main
ErrorLog /var/log/apache2/keystone.log
LogLevel debug
CustomLog /var/log/apache2/access.log combined
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/apache.cer
SSLCertificateKeyFile /etc/apache2/ssl/apache.key
SSLCACertificatePath /etc/apache2/capath
SSLOptions +StdEnvVars
SSLVerifyClient optional
</VirtualHost>
----------------------
Keystone Configuration
----------------------
The following options can be defined in `keystone.conf`:
* ``trusted_issuer`` - The multi-str list of trusted issuers to further
filter the certificates that are allowed to participate in the X.509
tokenless authorization. If the option is absent then no certificates
will be allowed. The naming format for the attributes of a Distinguished
Name(DN) must be separated by a comma and contain no spaces; however
spaces are allowed for the value of an attribute, like 'L=San Jose' in
the example below. This configuration option may be repeated for multiple
values. Please look at the sample below.
* ``protocol`` - The protocol name for the X.509 tokenless authorization
along with the option `issuer_attribute` below can look up its
corresponding mapping. It defaults to ``x509``.
* ``issuer_attribute`` - The issuer attribute that is served as an IdP ID for
the X.509 tokenless authorization along with the protocol to look up its
corresponding mapping. It is the environment variable in the WSGI
enviornment that references to the Issuer of the client certificate. It
defaults to ``SSL_CLIENT_I_DN``.
This is a sample configuration for two `trusted_issuer` and a `protocol` set
to ``x509``.
.. code-block:: ini
[tokenless_auth]
trusted_issuer = emailAddress=mary@abc.com,CN=mary,OU=eng,O=abc,L=San Jose,ST=California,C=US
trusted_issuer = emailAddress=john@openstack.com,CN=john,OU=keystone,O=openstack,L=Sunnyvale,ST=California,C=US
protocol = x509
-------------
Setup Mapping
-------------
Like federation, X.509 tokenless authorization also utilizes the mapping
mechanism to formulate an identity. The identity provider must correspond
to the issuer of the X.509 SSL client certificate. The protocol for the
given identity is ``x509`` by default, but can be configurable.
Create an Identity Provider(IdP)
--------------------------------
In order to create an IdP, the issuer DN in the client certificate needs
to be provided. The following sample is what a generic issuer DN looks
like in a certificate.
.. code-block:: ini
E=john@openstack.com
CN=john
OU=keystone
O=openstack
L=Sunnyvale
S=California
C=US
The issuer DN should be constructed as a string that contains no spaces
and have the right order seperated by commas like the example below.
Please be aware that ``emailAddress`` and ``ST`` should be used instead
of ``E`` and ``S`` that are shown in the above example. The following is
the sample Python code used to create the IdP ID.
.. code-block:: python
import hashlib
issuer_dn = 'emailAddress=john@openstack.com,CN=john,OU=keystone,
O=openstack,L=Sunnyvale,ST=California,C=US'
hashed_idp = hashlib.sha256(issuer_dn)
idp_id = hashed_idp.hexdigest()
print(idp_id)
The output of the above Python code will be the IdP ID and the following
sample curl command should be sent to keystone to create an IdP with the
newly generated IdP ID.
.. code-block:: bash
curl -k -s -X PUT -H "X-Auth-Token: <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"identity_provider": {"description": "Stores keystone IDP identities.","enabled": true}}' \
https://<HOSTNAME>:<PORT>/v3/OS-FEDERATION/identity_providers/<IdP ID>
Create a Map
------------
A mapping needs to be created to map the ``Subject DN`` in the client
certificate as a user to yield a valid local user if the user's ``type``
defined as ``local`` in the mapping. For example, the client certificate
has ``Subject DN`` as ``CN=alex,OU=eng,O=nice-network,L=Sunnyvale,
ST=California,C=US``, in the following examples, ``user_name`` will be
mapped to``alex`` and ``domain_name`` will be mapped to ``nice-network``.
And it has user's ``type`` set to ``local``. If user's ``type`` is not
defined, it defaults to ``ephemeral``.
Please refer to `mod_ssl`_ for the detailed mapping attributes.
.. _`mod_ssl`: http://httpd.apache.org/docs/current/mod/mod_ssl.html
.. code-block:: javascript
{
"mapping": {
"rules": [
{
"local": [
{
"user": {
"name": "{0}",
"domain": {
"name": "{1}"
},
"type": "local"
}
}
],
"remote": [
{
"type": "SSL_CLIENT_S_DN_CN"
},
{
"type": "SSL_CLIENT_S_DN_O"
}
]
}
]
}
}
When user's ``type`` is not defined or set to ``ephemeral``, the mapped user
does not have to be a valid local user but the mapping must yield at least
one valid local group. For example:
.. code-block:: javascript
{
"mapping": {
"rules": [
{
"local": [
{
"user": {
"name": "{0}",
"type": "ephemeral"
}
},
{
"group": {
"id": "12345678"
}
}
],
"remote": [
{
"type": "SSL_CLIENT_S_DN_CN"
}
]
}
]
}
}
The following sample curl command should be sent to keystone to create a
mapping with the provided mapping ID. The mapping ID is user designed and
it can be any string as opposed to IdP ID.
.. code-block:: bash
curl -k -s -H "X-Auth-Token: <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"mapping": {"rules": [{"local": [{"user": {"name": "{0}","type": "ephemeral"}},{"group": {"id": "<GROUPID>"}}],"remote": [{"type": "SSL_CLIENT_S_DN_CN"}]}]}}' \
-X PUT https://<HOSTNAME>:<PORT>/v3/OS-FEDERATION/mappings/<MAPPING ID>
Create a Protocol
-----------------
The name of the protocol will be the one defined in `keystone.conf` as
``protocol`` which defaults to ``x509``. The protocol name is user designed
and it can be any name as opposed to IdP ID.
A protocol name and an IdP ID will uniquely identify a mapping.
The following sample curl command should be sent to keystone to create a
protocol with the provided protocol name that is defined in `keystone.conf`.
.. code-block:: bash
curl -k -s -H "X-Auth-Token: <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"protocol": {"mapping_id": "<MAPPING ID>"}}' \
-X PUT https://<HOSTNAME>:<PORT>/v3/OS-FEDERATION/identity_providers/<IdP ID>/protocols/<PROTOCOL NAME>
-------------------------------
Setup ``auth_token`` middleware
-------------------------------
In order to use ``auth_token`` middleware as the service client for X.509
tokenless authorization, both configurable options and scope information
will need to be setup.
Configurable Options
--------------------
The following configurable options in ``auth_token`` middleware
should set to the correct values:
* ``auth_protocol`` - Set to ``https``.
* ``certfile`` - Set to the full path of the certificate file.
* ``keyfile`` - Set to the full path of the private key file.
* ``cafile`` - Set to the full path of the trusted CA certificate file.
Scope Information
-----------------
The scope information will be passed from the headers with the following
header attributes to:
* ``X-Project-Id`` - If specified, its the project scope.
* ``X-Project-Name`` - If specified, its the project scope.
* ``X-Project-Domain-Id`` - If specified, its the domain of project scope.
* ``X-Project-Domain-Name`` - If specified, its the domain of project scope.
* ``X-Domain-Id`` - If specified, its the domain scope.
* ``X-Domain-Name`` - If specified, its the domain scope.
---------------------
Test It Out with cURL
---------------------
Once the above configurations have been setup, the following curl command can
be used for token validation.
.. code-block:: bash
curl -v -k -s -X GET --cert /<PATH>/x509client.crt \
--key /<PATH>/x509client.key \
--cacert /<PATH>/ca.crt \
-H "X-Project-Name: <PROJECT-NAME>" \
-H "X-Project-Domain-Id: <PROJECT-DOMAIN-ID>" \
-H "X-Subject-Token: <TOKEN>" \
https://<HOST>:<PORT>/v3/auth/tokens | python -mjson.tool
Details of the Options
----------------------
* ``--cert`` - The client certificate that will be presented to Keystone.
The ``Issuer`` in the certificate along with the defined ``protocol``
in `keystone.conf` will uniquely identify the mapping. The ``Subject``
in the certificate will be mapped to the valid local user from the
identified mapping.
* ``--key`` - The corresponding client private key.
* ``--cacert`` - It can be the Apache server certificate or its issuer
(signer) certificate.
* ``X-Project-Name`` - The project scope needs to be passed in the header.
* ``X-Project-Domain-Id`` - Its the domain of project scope.
* ``X-Subject-Token`` - The token to be validated.

@ -54,6 +54,7 @@ Getting Started
configure_federation
mapping_combinations
mapping_schema
configure_tokenless_x509
configuringservices
extensions
key_terms

@ -129,9 +129,9 @@ class AuthInfo(object):
"""Encapsulation of "auth" request."""
@staticmethod
def create(context, auth=None):
def create(context, auth=None, scope_only=False):
auth_info = AuthInfo(context, auth=auth)
auth_info._validate_and_normalize_auth_data()
auth_info._validate_and_normalize_auth_data(scope_only)
return auth_info
def __init__(self, context, auth=None):
@ -272,14 +272,25 @@ class AuthInfo(object):
if method_name not in AUTH_METHODS:
raise exception.AuthMethodNotSupported()
def _validate_and_normalize_auth_data(self):
"""Make sure "auth" is valid."""
def _validate_and_normalize_auth_data(self, scope_only=False):
"""Make sure "auth" is valid.
:param scope_only: If it is True, auth methods will not be
validated but only the scope data.
:type scope_only: boolean
"""
# make sure "auth" exist
if not self.auth:
raise exception.ValidationError(attribute='auth',
target='request body')
self._validate_auth_methods()
# NOTE(chioleong): Tokenless auth does not provide auth methods,
# we only care about using this method to validate the scope
# information. Therefore, validating the auth methods here is
# insignificant and we can skip it when scope_only is set to
# true.
if scope_only is False:
self._validate_auth_methods()
self._validate_and_normalize_scope_data()
def get_method_names(self):

@ -894,6 +894,32 @@ FILE_OPTIONS = {
help='Entrypoint for the oAuth1.0 auth plugin module in '
'the keystone.auth.oauth1 namespace.'),
],
'tokenless_auth': [
cfg.MultiStrOpt('trusted_issuer', default=[],
help='The list of trusted issuers to further filter '
'the certificates that are allowed to '
'participate in the X.509 tokenless '
'authorization. If the option is absent then '
'no certificates will be allowed. '
'The naming format for the attributes of a '
'Distinguished Name(DN) must be separated by a '
'comma and contain no spaces. This configuration '
'option may be repeated for multiple values. '
'For example: '
'trusted_issuer=CN=john,OU=keystone,O=openstack '
'trusted_issuer=CN=mary,OU=eng,O=abc'),
cfg.StrOpt('protocol', default='x509',
help='The protocol name for the X.509 tokenless '
'authorization along with the option issuer_attribute '
'below can look up its corresponding mapping.'),
cfg.StrOpt('issuer_attribute', default='SSL_CLIENT_I_DN',
help='The issuer attribute that is served as an IdP ID '
'for the X.509 tokenless authorization along with '
'the protocol to look up its corresponding mapping. '
'It is the environment variable in the WSGI '
'environment that references to the issuer of the '
'client certificate.'),
],
'paste_deploy': [
cfg.StrOpt('config_file', default='keystone-paste.ini',
help='Name of the paste configuration file that defines '

@ -0,0 +1,193 @@
# Copyright 2015 Hewlett-Packard
# All Rights Reserved.
#
# 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.
import hashlib
from oslo_config import cfg
from oslo_log import log
from keystone.auth import controllers
from keystone.common import dependency
from keystone.contrib.federation import constants as federation_constants
from keystone.contrib.federation import utils
from keystone import exception
from keystone.i18n import _
CONF = cfg.CONF
LOG = log.getLogger(__name__)
@dependency.requires('assignment_api', 'federation_api',
'identity_api', 'resource_api')
class TokenlessAuthHelper(object):
def __init__(self, env):
"""A init class for TokenlessAuthHelper.
:param env: The HTTP request environment that should contain
client certificate attributes. These attributes should match
with what the mapping defines. Or a user cannot be mapped and
results un-authenticated. The following examples are for the
attributes that reference to the client certificate's Subject's
Common Name and Organization:
SSL_CLIENT_S_DN_CN, SSL_CLIENT_S_DN_O
:type env: dict
"""
self.env = env
def _build_scope_info(self):
"""Build the token request scope based on the headers.
:returns: scope data
:rtype: dict
"""
project_id = self.env.get('HTTP_X_PROJECT_ID')
project_name = self.env.get('HTTP_X_PROJECT_NAME')
project_domain_id = self.env.get('HTTP_X_PROJECT_DOMAIN_ID')
project_domain_name = self.env.get('HTTP_X_PROJECT_DOMAIN_NAME')
domain_id = self.env.get('HTTP_X_DOMAIN_ID')
domain_name = self.env.get('HTTP_X_DOMAIN_NAME')
scope = {}
if project_id:
scope['project'] = {'id': project_id}
elif project_name:
scope['project'] = {'name': project_name}
if project_domain_id:
scope['project']['domain'] = {'id': project_domain_id}
elif project_domain_name:
scope['project']['domain'] = {'name': project_domain_name}
else:
msg = _('Neither Project Domain ID nor Project Domain Name '
'was provided.')
raise exception.ValidationError(msg)
elif domain_id:
scope['domain'] = {'id': domain_id}
elif domain_name:
scope['domain'] = {'name': domain_name}
else:
raise exception.ValidationError(
attribute='project or domain',
target='scope')
return scope
def get_scope(self):
auth = {}
# NOTE(chioleong): auth methods here are insignificant because
# we only care about using auth.controllers.AuthInfo
# to validate the scope information. Therefore,
# we don't provide any identity.
auth['scope'] = self._build_scope_info()
# NOTE(chioleong): we'll let AuthInfo validate the scope for us
auth_info = controllers.AuthInfo.create({}, auth, scope_only=True)
return auth_info.get_scope()
def get_mapped_user(self, project_id=None, domain_id=None):
"""Map client certificate to an existing user.
If user is ephemeral, there is no validation on the user himself;
however it will be mapped to a corresponding group(s) and the scope
of this ephemeral user is the same as what is assigned to the group.
:param project_id: Project scope of the mapped user.
:param domain_id: Domain scope of the mapped user.
:returns: A dictionary that contains the keys, such as
user_id, user_name, domain_id, domain_name
:rtype: dict
"""
idp_id = self._build_idp_id()
LOG.debug('The IdP Id %s and protocol Id %s are used to look up '
'the mapping.', idp_id, CONF.tokenless_auth.protocol)
mapped_properties, mapping_id = self.federation_api.evaluate(
idp_id, CONF.tokenless_auth.protocol, self.env)
user = mapped_properties.get('user', {})
user_id = user.get('id')
user_name = user.get('name')
user_type = user.get('type')
if user.get('domain') is not None:
user_domain_id = user.get('domain').get('id')
user_domain_name = user.get('domain').get('name')
else:
user_domain_id = None
user_domain_name = None
# if user is ephemeral type, we don't care if the user exists
# or not, but just care if the mapped group(s) is valid.
if user_type == utils.UserType.EPHEMERAL:
user_ref = {'type': utils.UserType.EPHEMERAL}
group_ids = mapped_properties['group_ids']
utils.validate_groups_in_backend(group_ids,
mapping_id,
self.identity_api)
group_ids.extend(
utils.transform_to_group_ids(
mapped_properties['group_names'], mapping_id,
self.identity_api, self.assignment_api))
roles = self.assignment_api.get_roles_for_groups(group_ids,
project_id,
domain_id)
if roles is not None:
role_names = [role['name'] for role in roles]
user_ref['roles'] = role_names
user_ref['group_ids'] = list(group_ids)
user_ref[federation_constants.IDENTITY_PROVIDER] = idp_id
user_ref[federation_constants.PROTOCOL] = (
CONF.tokenless_auth.protocol)
return user_ref
if user_id:
user_ref = self.identity_api.get_user(user_id)
elif user_name and (user_domain_name or user_domain_id):
if user_domain_name:
user_domain = self.resource_api.get_domain_by_name(
user_domain_name)
self.resource_api.assert_domain_enabled(user_domain['id'],
user_domain)
user_domain_id = user_domain['id']
user_ref = self.identity_api.get_user_by_name(user_name,
user_domain_id)
else:
msg = _('User auth cannot be built due to missing either '
'user id, or user name with domain id, or user name '
'with domain name.')
raise exception.ValidationError(msg)
self.identity_api.assert_user_enabled(
user_id=user_ref['id'],
user=user_ref)
user_ref['type'] = utils.UserType.LOCAL
return user_ref
def _build_idp_id(self):
"""Build the IdP name from the given config option issuer_attribute.
The default issuer attribute SSL_CLIENT_I_DN in the environment is
built with the following formula -
base64_idp = sha1(env['SSL_CLIENT_I_DN'])
:returns: base64_idp like the above example
:rtype: str
"""
idp = self.env.get(CONF.tokenless_auth.issuer_attribute)
if idp is None:
raise exception.TokenlessAuthConfigError(
issuer_attribute=CONF.tokenless_auth.issuer_attribute)
hashed_idp = hashlib.sha256(idp)
return hashed_idp.hexdigest()

@ -480,3 +480,9 @@ class OAuthHeadersMissingError(UnexpectedError):
'HTTPd or Apache, ensure WSGIPassAuthorization '
'is set to On.')
title = 'Error retrieving OAuth headers'
class TokenlessAuthConfigError(ValidationError):
message_format = _('Could not determine Identity Provider ID. The '
'configuration option %(issuer_attribute)s '
'was not found in the request environment.')

@ -19,10 +19,14 @@ from oslo_middleware import sizelimit
from oslo_serialization import jsonutils
from keystone.common import authorization
from keystone.common import tokenless_auth
from keystone.common import wsgi
from keystone.contrib.federation import constants as federation_constants
from keystone.contrib.federation import utils
from keystone import exception
from keystone.i18n import _LW
from keystone.i18n import _, _LI, _LW
from keystone.models import token_model
from keystone.token.providers import common
CONF = cfg.CONF
@ -194,17 +198,109 @@ class AuthContextMiddleware(wsgi.Middleware):
LOG.warning(_LW('RBAC: Invalid token'))
raise exception.Unauthorized()
def process_request(self, request):
if AUTH_TOKEN_HEADER not in request.headers:
LOG.debug(('Auth token not in the request header. '
'Will not build auth context.'))
return
def _build_tokenless_auth_context(self, env):
"""Build the authentication context.
The context is built from the attributes provided in the env,
such as certificate and scope attributes.
"""
tokenless_helper = tokenless_auth.TokenlessAuthHelper(env)
(domain_id, project_id, trust_ref, unscoped) = (
tokenless_helper.get_scope())
user_ref = tokenless_helper.get_mapped_user(
project_id,
domain_id)
# NOTE(gyee): if it is an ephemeral user, the
# given X.509 SSL client cert does not need to map to
# an existing user.
if user_ref['type'] == utils.UserType.EPHEMERAL:
auth_context = {}
auth_context['group_ids'] = user_ref['group_ids']
auth_context[federation_constants.IDENTITY_PROVIDER] = (
user_ref[federation_constants.IDENTITY_PROVIDER])
auth_context[federation_constants.PROTOCOL] = (
user_ref[federation_constants.PROTOCOL])
if domain_id and project_id:
msg = _('Scoping to both domain and project is not allowed')
raise ValueError(msg)
if domain_id:
auth_context['domain_id'] = domain_id
if project_id:
auth_context['project_id'] = project_id
auth_context['roles'] = user_ref['roles']
else:
# it's the local user, so token data is needed.
token_helper = common.V3TokenDataHelper()
token_data = token_helper.get_token_data(
user_id=user_ref['id'],
method_names=[CONF.tokenless_auth.protocol],
domain_id=domain_id,
project_id=project_id)
auth_context = {'user_id': user_ref['id']}
auth_context['is_delegated_auth'] = False
if domain_id:
auth_context['domain_id'] = domain_id
if project_id:
auth_context['project_id'] = project_id
auth_context['roles'] = [role['name'] for role
in token_data['token']['roles']]
return auth_context
def _validate_trusted_issuer(self, env):
"""To further filter the certificates that are trusted.
If the config option 'trusted_issuer' is absent or does
not contain the trusted issuer DN, no certificates
will be allowed in tokenless authorization.
:param env: The env contains the client issuer's attributes
:type env: dict
:returns: True if client_issuer is trusted; otherwise False
"""
client_issuer = env.get(CONF.tokenless_auth.issuer_attribute)
if not client_issuer:
msg = _LI('Cannot find client issuer in env by the '
'issuer attribute - %s.')
LOG.info(msg, CONF.tokenless_auth.issuer_attribute)
return False
if client_issuer in CONF.tokenless_auth.trusted_issuer:
return True
msg = _LI('The client issuer %(client_issuer)s does not match with '
'the trusted issuer %(trusted_issuer)s')
LOG.info(
msg, {'client_issuer': client_issuer,
'trusted_issuer': CONF.tokenless_auth.trusted_issuer})
return False
def process_request(self, request):
if authorization.AUTH_CONTEXT_ENV in request.environ:
msg = _LW('Auth context already exists in the request environment')
msg = _LW('Auth context already exists in the request '
'environment; it will be used for authorization '
'instead of creating a new one.')
LOG.warning(msg)
return
auth_context = self._build_auth_context(request)
# NOTE(gyee): token takes precedence over SSL client certificates.
# This will preserve backward compatibility with the existing
# behavior. Tokenless authorization with X.509 SSL client
# certificate is effectively disabled if no trusted issuers are
# provided.
if AUTH_TOKEN_HEADER in request.headers:
auth_context = self._build_auth_context(request)
elif self._validate_trusted_issuer(request.environ):
auth_context = self._build_tokenless_auth_context(
request.environ)
else:
LOG.debug('There is either no auth token in the request or '
'the certificate issuer is not trusted. No auth '
'context will be set.')
return
LOG.debug('RBAC: auth_context: %s', auth_context)
request.environ[authorization.AUTH_CONTEXT_ENV] = auth_context

@ -901,6 +901,251 @@ MAPPING_GROUPS_WHITELIST_AND_BLACKLIST = {
]
}
# Mapping used by tokenless test cases, it maps the user_name
# and domain_name.
MAPPING_WITH_USERNAME_AND_DOMAINNAME = {
'rules': [
{
'local': [
{
'user': {
'name': '{0}',
'domain': {
'name': '{1}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_NAME'
},
{
'type': 'SSL_CLIENT_DOMAIN_NAME'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the user_id
# and domain_name.
MAPPING_WITH_USERID_AND_DOMAINNAME = {
'rules': [
{
'local': [
{
'user': {
'id': '{0}',
'domain': {
'name': '{1}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_ID'
},
{
'type': 'SSL_CLIENT_DOMAIN_NAME'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the user_name
# and domain_id.
MAPPING_WITH_USERNAME_AND_DOMAINID = {
'rules': [
{
'local': [
{
'user': {
'name': '{0}',
'domain': {
'id': '{1}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_NAME'
},
{
'type': 'SSL_CLIENT_DOMAIN_ID'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the user_id
# and domain_id.
MAPPING_WITH_USERID_AND_DOMAINID = {
'rules': [
{
'local': [
{
'user': {
'id': '{0}',
'domain': {
'id': '{1}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_ID'
},
{
'type': 'SSL_CLIENT_DOMAIN_ID'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the domain_id only.
MAPPING_WITH_DOMAINID_ONLY = {
'rules': [
{
'local': [
{
'user': {
'domain': {
'id': '{0}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_DOMAIN_ID'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the domain_name only.
MAPPING_WITH_DOMAINNAME_ONLY = {
'rules': [
{
'local': [
{
'user': {
'domain': {
'name': '{0}'
},
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_DOMAIN_NAME'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the user_name only.
MAPPING_WITH_USERNAME_ONLY = {
'rules': [
{
'local': [
{
'user': {
'name': '{0}',
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_NAME'
}
]
}
]
}
# Mapping used by tokenless test cases, it maps the user_id only.
MAPPING_WITH_USERID_ONLY = {
'rules': [
{
'local': [
{
'user': {
'id': '{0}',
'type': 'local'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_ID'
}
]
}
]
}
MAPPING_FOR_EPHEMERAL_USER = {
'rules': [
{
'local': [
{
'user': {
'name': '{0}',
'type': 'ephemeral'
},
'group': {
'id': 'dummy'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_NAME'
}
]
}
]
}
MAPPING_FOR_DEFAULT_EPHEMERAL_USER = {
'rules': [
{
'local': [
{
'user': {
'name': '{0}'
},
'group': {
'id': 'dummy'
}
}
],
'remote': [
{
'type': 'SSL_CLIENT_USER_NAME'
}
]
}
]
}
EMPLOYEE_ASSERTION = {
'Email': 'tim@example.com',
'UserName': 'tbo',

@ -12,11 +12,20 @@
# License for the specific language governing permissions and limitations
# under the License.
import hashlib
import uuid
from oslo_config import cfg
import webob
from keystone.common import authorization
from keystone.common import tokenless_auth
from keystone.contrib.federation import constants as federation_constants
from keystone import exception
from keystone import middleware
from keystone.tests import unit as tests
from keystone.tests.unit import mapping_fixtures
from keystone.tests.unit import test_backend_sql
CONF = cfg.CONF
@ -117,3 +126,624 @@ class JsonBodyMiddlewareTest(tests.TestCase):
middleware.JsonBodyMiddleware(None).process_request(req)
params = req.environ.get(middleware.PARAMS_ENV, {})
self.assertEqual({}, params)
class AuthContextMiddlewareTest(test_backend_sql.SqlTests):
def setUp(self):
super(AuthContextMiddlewareTest, self).setUp()
self.client_issuer = uuid.uuid4().hex
self.untrusted_client_issuer = uuid.uuid4().hex
self.trusted_issuer = self.client_issuer
self.config_fixture.config(group='tokenless_auth',
trusted_issuer=[self.trusted_issuer])
# This idp_id is calculated based on
# sha256(self.client_issuer)
hashed_idp = hashlib.sha256(self.client_issuer)
self.idp_id = hashed_idp.hexdigest()
self._load_sample_data()
def _load_sample_data(self):
self.domain_id = uuid.uuid4().hex
self.domain_name = uuid.uuid4().hex
self.project_id = uuid.uuid4().hex
self.project_name = uuid.uuid4().hex
self.user_name = uuid.uuid4().hex
self.user_password = uuid.uuid4().hex
self.user_email = uuid.uuid4().hex
self.protocol_id = 'x509'
self.role_id = uuid.uuid4().hex
self.role_name = uuid.uuid4().hex
# for ephemeral user
self.group_name = uuid.uuid4().hex
# 1) Create a domain for the user.
self.domain = {
'description': uuid.uuid4().hex,
'enabled': True,
'id': self.domain_id,
'name': self.domain_name,
}
self.resource_api.create_domain(self.domain_id, self.domain)
# 2) Create a project for the user.
self.project = {
'description': uuid.uuid4().hex,
'domain_id': self.domain_id,
'enabled': True,
'id': self.project_id,
'name': self.project_name,
}
self.resource_api.create_project(self.project_id, self.project)
# 3) Create a user in new domain.
self.user = {
'name': self.user_name,
'domain_id': self.domain_id,
'project_id': self.project_id,
'password': self.user_password,
'email': self.user_email,
}
self.user = self.identity_api.create_user(self.user)
# Add IDP
self.idp = self._idp_ref(id=self.idp_id)
self.federation_api.create_idp(self.idp['id'],
self.idp)
# Add a role
self.role = {
'id': self.role_id,
'name': self.role_name,
}
self.role_api.create_role(self.role_id, self.role)
# Add a group
self.group = {
'name': self.group_name,
'domain_id': self.domain_id,
}
self.group = self.identity_api.create_group(self.group)
# Assign a role to the user on a project
self.assignment_api.add_role_to_user_and_project(
user_id=self.user['id'],
tenant_id=self.project_id,
role_id=self.role_id)
# Assign a role to the group on a project
self.assignment_api.create_grant(
role_id=self.role_id,
group_id=self.group['id'],
project_id=self.project_id)
def _load_mapping_rules(self, rules):
# Add a mapping
self.mapping = self._mapping_ref(rules=rules)
self.federation_api.create_mapping(self.mapping['id'],
self.mapping)
# Add protocols
self.proto_x509 = self._proto_ref(mapping_id=self.mapping['id'])
self.proto_x509['id'] = self.protocol_id
self.federation_api.create_protocol(self.idp['id'],
self.proto_x509['id'],
self.proto_x509)
def _idp_ref(self, id=None):
idp = {
'id': id or uuid.uuid4().hex,
'enabled': True,
'description': uuid.uuid4().hex
}
return idp
def _proto_ref(self, mapping_id=None):
proto = {
'id': uuid.uuid4().hex,
'mapping_id': mapping_id or uuid.uuid4().hex
}
return proto
def _mapping_ref(self, rules=None):
if rules is None:
mapped_rules = {}
else:
mapped_rules = rules.get('rules', {})
return {
'id': uuid.uuid4().hex,
'rules': mapped_rules
}
def _assert_tokenless_auth_context(self, context, ephemeral_user=False):
self.assertIsNotNone(context)
self.assertEqual(self.project_id, context['project_id'])
self.assertIn(self.role_name, context['roles'])
if ephemeral_user:
self.assertEqual(self.group['id'], context['group_ids'][0])
self.assertEqual('ephemeral',
context[federation_constants.PROTOCOL])
self.assertEqual(self.idp_id,
context[federation_constants.IDENTITY_PROVIDER])
else:
self.assertEqual(self.user['id'], context['user_id'])
def _create_context(self, request, mapping_ref=None,
exception_expected=False):
"""Builds the auth context from the given arguments.
auth context will be returned from the AuthContextMiddleware based on
what is being passed in the given request and what mapping is being
setup in the backend DB.
:param request: HTTP request
:param mapping_ref: A mapping in JSON structure will be setup in the
backend DB for mapping an user or a group.
:param exception_expected: Sets to True when an exception is expected
to raised based on the given arguments.
:returns: context an auth context contains user and role information
:rtype: dict
"""
if mapping_ref:
self._load_mapping_rules(mapping_ref)
if not exception_expected:
(middleware.AuthContextMiddleware('Tokenless_auth_test').
process_request(request))
context = request.environ.get(authorization.AUTH_CONTEXT_ENV)
else:
context = middleware.AuthContextMiddleware('Tokenless_auth_test')
return context
def test_context_already_exists(self):
req = make_request()
token_id = uuid.uuid4().hex
req.environ[authorization.AUTH_CONTEXT_ENV] = {'token_id': token_id}
context = self._create_context(request=req)
self.assertEqual(token_id, context['token_id'])
def test_not_applicable_to_token_request(self):
env = {}
env['PATH_INFO'] = '/auth/tokens'
env['REQUEST_METHOD'] = 'POST'
req = make_request(environ=env)
context = self._create_context(request=req)
self.assertIsNone(context)
def test_no_tokenless_attributes_request(self):
req = make_request()
context = self._create_context(request=req)
self.assertIsNone(context)
def test_no_issuer_attribute_request(self):
env = {}
env['HTTP_X_PROJECT_ID'] = uuid.uuid4().hex
req = make_request(environ=env)
context = self._create_context(request=req)
self.assertIsNone(context)
def test_has_only_issuer_and_project_name_request(self):
env = {}
# SSL_CLIENT_I_DN is the attribute name that wsgi env
# references to issuer of the client certificate.
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = uuid.uuid4().hex
req = make_request(environ=env)
context = self._create_context(request=req,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_has_only_issuer_and_project_domain_name_request(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_DOMAIN_NAME'] = uuid.uuid4().hex
req = make_request(environ=env)
context = self._create_context(request=req,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_has_only_issuer_and_project_domain_id_request(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_DOMAIN_ID'] = uuid.uuid4().hex
req = make_request(environ=env)
context = self._create_context(request=req,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_missing_both_domain_and_project_request(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
req = make_request(environ=env)
context = self._create_context(request=req,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_empty_trusted_issuer_list(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = uuid.uuid4().hex
req = make_request(environ=env)
self.config_fixture.config(group='tokenless_auth',
trusted_issuer=[])
context = self._create_context(request=req)
self.assertIsNone(context)
def test_client_issuer_not_trusted(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.untrusted_client_issuer
env['HTTP_X_PROJECT_ID'] = uuid.uuid4().hex
req = make_request(environ=env)
context = self._create_context(request=req)
self.assertIsNone(context)
def test_proj_scope_with_proj_id_and_proj_dom_id_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
# SSL_CLIENT_USER_NAME and SSL_CLIENT_DOMAIN_NAME are the types
# defined in the mapping that will map to the user name and
# domain name
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINNAME)
self._assert_tokenless_auth_context(context)
def test_proj_scope_with_proj_id_only_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINNAME)
self._assert_tokenless_auth_context(context)
def test_proj_scope_with_proj_name_and_proj_dom_id_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINNAME)
self._assert_tokenless_auth_context(context)
def test_proj_scope_with_proj_name_and_proj_dom_name_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINNAME)
self._assert_tokenless_auth_context(context)
def test_proj_scope_with_proj_name_only_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_id
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINNAME,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_mapping_with_userid_and_domainid_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_ID'] = self.user['id']
env['SSL_CLIENT_DOMAIN_ID'] = self.domain_id
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERID_AND_DOMAINID)
self._assert_tokenless_auth_context(context)
def test_mapping_with_userid_and_domainname_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_ID'] = self.user['id']
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERID_AND_DOMAINNAME)
self._assert_tokenless_auth_context(context)
def test_mapping_with_username_and_domainid_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_ID'] = self.domain_id
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINID)
self._assert_tokenless_auth_context(context)
def test_only_domain_name_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
env['SSL_CLIENT_DOMAIN_NAME'] = self.domain_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_DOMAINNAME_ONLY,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_only_domain_id_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
env['SSL_CLIENT_DOMAIN_ID'] = self.domain_id
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_DOMAINID_ONLY,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_missing_domain_data_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
env['SSL_CLIENT_USER_NAME'] = self.user_name
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_ONLY,
exception_expected=True)
self.assertRaises(exception.ValidationError,
context.process_request,
req)
def test_userid_success(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_ID'] = self.project_id
env['HTTP_X_PROJECT_DOMAIN_ID'] = self.domain_id
env['SSL_CLIENT_USER_ID'] = self.user['id']
req = make_request(environ=env)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERID_ONLY)
self._assert_tokenless_auth_context(context)
def test_domain_disable_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_ID'] = self.domain_id
req = make_request(environ=env)
self.domain['enabled'] = False
self.domain = self.resource_api.update_domain(
self.domain['id'], self.domain)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINID,
exception_expected=True)
self.assertRaises(exception.Unauthorized,
context.process_request,
req)
def test_user_disable_fail(self):
env = {}
env['SSL_CLIENT_I_DN'] = self.client_issuer
env['HTTP_X_PROJECT_NAME'] = self.project_name
env['HTTP_X_PROJECT_DOMAIN_NAME'] = self.domain_name
env['SSL_CLIENT_USER_NAME'] = self.user_name
env['SSL_CLIENT_DOMAIN_ID'] = self.domain_id
req = make_request(environ=env)
self.user['enabled'] = False
self.user = self.identity_api.update_user(self.user['id'], self.user)
context = self._create_context(
request=req,
mapping_ref=mapping_fixtures.MAPPING_WITH_USERNAME_AND_DOMAINID,
exception_expected=True)
self.assertRaises(AssertionError,
context.process_request,
req)