# 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 functools import itertools import re import wsgiref.util from keystonemiddleware import auth_token import oslo_i18n from oslo_log import log from oslo_serialization import jsonutils import six from six.moves import http_client import webob.dec import webob.exc from keystone.common import authorization from keystone.common import context from keystone.common import provider_api from keystone.common import render_token from keystone.common import tokenless_auth from keystone.common import utils import keystone.conf from keystone import exception from keystone.federation import constants as federation_constants from keystone.federation import utils as federation_utils from keystone.i18n import _ from keystone.models import token_model CONF = keystone.conf.CONF LOG = log.getLogger(__name__) PROVIDERS = provider_api.ProviderAPIs # Environment variable used to pass the request context CONTEXT_ENV = 'openstack.context' __all__ = ('AuthContextMiddleware',) CONF = keystone.conf.CONF LOG = log.getLogger(__name__) JSON_ENCODE_CONTENT_TYPES = set(['application/json', 'application/json-home']) def best_match_language(req): """Determine the best available locale. This returns best available locale based on the Accept-Language HTTP header passed in the request. """ if not req.accept_language: return None return req.accept_language.best_match( oslo_i18n.get_available_languages('keystone')) def base_url(context): url = CONF['public_endpoint'] if url: substitutions = dict( itertools.chain(CONF.items(), CONF.eventlet_server.items())) url = url % substitutions elif 'environment' in context: url = wsgiref.util.application_uri(context['environment']) # remove version from the URL as it may be part of SCRIPT_NAME but # it should not be part of base URL url = re.sub(r'/v(3|(2\.0))/*$', '', url) # now remove the standard port url = utils.remove_standard_port(url) else: # if we don't have enough information to come up with a base URL, # then fall back to localhost. This should never happen in # production environment. url = 'http://localhost:%d' % CONF.eventlet_server.public_port return url.rstrip('/') def middleware_exceptions(method): @functools.wraps(method) def _inner(self, request): try: return method(self, request) except exception.Error as e: LOG.warning(six.text_type(e)) return render_exception(e, request=request, user_locale=best_match_language(request)) except TypeError as e: LOG.exception(six.text_type(e)) return render_exception(exception.ValidationError(e), request=request, user_locale=best_match_language(request)) except Exception as e: LOG.exception(six.text_type(e)) return render_exception(exception.UnexpectedError(exception=e), request=request, user_locale=best_match_language(request)) return _inner def render_response(body=None, status=None, headers=None, method=None): """Form a WSGI response.""" if headers is None: headers = [] else: headers = list(headers) headers.append(('Vary', 'X-Auth-Token')) if body is None: body = b'' status = status or (http_client.NO_CONTENT, http_client.responses[http_client.NO_CONTENT]) else: content_types = [v for h, v in headers if h == 'Content-Type'] if content_types: content_type = content_types[0] else: content_type = None if content_type is None or content_type in JSON_ENCODE_CONTENT_TYPES: body = jsonutils.dump_as_bytes(body, cls=utils.SmarterEncoder) if content_type is None: headers.append(('Content-Type', 'application/json')) status = status or (http_client.OK, http_client.responses[http_client.OK]) # NOTE(davechen): `mod_wsgi` follows the standards from pep-3333 and # requires the value in response header to be binary type(str) on python2, # unicode based string(str) on python3, or else keystone will not work # under apache with `mod_wsgi`. # keystone needs to check the data type of each header and convert the # type if needed. # see bug: # https://bugs.launchpad.net/keystone/+bug/1528981 # see pep-3333: # https://www.python.org/dev/peps/pep-3333/#a-note-on-string-types # see source from mod_wsgi: # https://github.com/GrahamDumpleton/mod_wsgi(methods: # wsgi_convert_headers_to_bytes(...), wsgi_convert_string_to_bytes(...) # and wsgi_validate_header_value(...)). def _convert_to_str(headers): str_headers = [] for header in headers: str_header = [] for value in header: if not isinstance(value, str): str_header.append(str(value)) else: str_header.append(value) # convert the list to the immutable tuple to build the headers. # header's key/value will be guaranteed to be str type. str_headers.append(tuple(str_header)) return str_headers headers = _convert_to_str(headers) resp = webob.Response(body=body, status='%d %s' % status, headerlist=headers, charset='utf-8') if method and method.upper() == 'HEAD': # NOTE(morganfainberg): HEAD requests should return the same status # as a GET request and same headers (including content-type and # content-length). The webob.Response object automatically changes # content-length (and other headers) if the body is set to b''. Capture # all headers and reset them on the response object after clearing the # body. The body can only be set to a binary-type (not TextType or # NoneType), so b'' is used here and should be compatible with # both py2x and py3x. stored_headers = resp.headers.copy() resp.body = b'' for header, value in stored_headers.items(): resp.headers[header] = value return resp def render_exception(error, context=None, request=None, user_locale=None): """Form a WSGI response based on the current error.""" error_message = error.args[0] message = oslo_i18n.translate(error_message, desired_locale=user_locale) if message is error_message: # translate() didn't do anything because it wasn't a Message, # convert to a string. message = six.text_type(message) body = {'error': { 'code': error.code, 'title': error.title, 'message': message, }} headers = [] if isinstance(error, exception.AuthPluginException): body['error']['identity'] = error.authentication elif isinstance(error, exception.Unauthorized): # NOTE(gyee): we only care about the request environment in the # context. Also, its OK to pass the environment as it is read-only in # base_url() local_context = {} if request: local_context = {'environment': request.environ} elif context and 'environment' in context: local_context = {'environment': context['environment']} url = base_url(local_context) headers.append(('WWW-Authenticate', 'Keystone uri="%s"' % url)) return render_response(status=(error.code, error.title), body=body, headers=headers) class AuthContextMiddleware(provider_api.ProviderAPIMixin, auth_token.BaseAuthProtocol): """Build the authentication context from the request auth token.""" kwargs_to_fetch_token = True def __init__(self, app): super(AuthContextMiddleware, self).__init__(app, log=LOG) def fetch_token(self, token, **kwargs): try: token_model = self.token_provider_api.validate_token(token) return render_token.render_token_response_from_model(token_model) except exception.TokenNotFound: raise auth_token.InvalidToken(_('Could not find token')) def _build_tokenless_auth_context(self, request): """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(request.environ) (domain_id, project_id, trust_ref, unscoped, system) = ( 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'] == federation_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 = token_model.TokenModel() token.user_id = user_ref['id'] token.methods = [CONF.tokenless_auth.protocol] token.domain_id = domain_id token.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.roles] return auth_context def _validate_trusted_issuer(self, request): """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 """ if not CONF.tokenless_auth.trusted_issuer: return False issuer = request.environ.get(CONF.tokenless_auth.issuer_attribute) if not issuer: msg = ('Cannot find client issuer in env by the ' 'issuer attribute - %s.') LOG.info(msg, CONF.tokenless_auth.issuer_attribute) return False if issuer in CONF.tokenless_auth.trusted_issuer: return True msg = ('The client issuer %(client_issuer)s does not match with ' 'the trusted issuer %(trusted_issuer)s') LOG.info( msg, {'client_issuer': issuer, 'trusted_issuer': CONF.tokenless_auth.trusted_issuer}) return False @middleware_exceptions def process_request(self, request): context_env = request.environ.get(CONTEXT_ENV, {}) # NOTE(notmorgan): This code is merged over from the admin token # middleware and now emits the security warning when the # conf.admin_token value is set. token = request.headers.get(authorization.AUTH_TOKEN_HEADER) if CONF.admin_token and (token == CONF.admin_token): context_env['is_admin'] = True LOG.warning( "The use of the '[DEFAULT] admin_token' configuration" "option presents a significant security risk and should " "not be set. This option is deprecated in favor of using " "'keystone-manage bootstrap' and will be removed in a " "future release.") request.environ[CONTEXT_ENV] = context_env if not context_env.get('is_admin', False): resp = super(AuthContextMiddleware, self).process_request(request) if resp: return resp if request.token_auth.user is not None: request.set_user_headers(request.token_auth.user) # NOTE(jamielennox): function is split so testing can check errors from # fill_context. There is no actual reason for fill_context to raise # errors rather than return a resp, simply that this is what happened # before refactoring and it was easier to port. This can be fixed up # and the middleware_exceptions helper removed. self.fill_context(request) def _keystone_specific_values(self, token, request_context): request_context.token_reference = ( render_token.render_token_response_from_model(token) ) if token.domain_scoped: # Domain scoped tokens should never have is_admin_project set # Even if KSA defaults it otherwise. The two mechanisms are # parallel; only ione or the other should be used for access. request_context.is_admin_project = False request_context.domain_id = token.domain_id request_context.domain_name = token.domain['name'] if token.oauth_scoped: request_context.is_delegated_auth = True request_context.oauth_consumer_id = ( token.access_token['consumer_id'] ) request_context.oauth_access_token_id = token.access_token_id if token.trust_scoped: request_context.is_delegated_auth = True request_context.trust_id = token.trust_id if token.is_federated: request_context.group_ids = [] for group in token.federated_groups: request_context.group_ids.append(group['id']) else: request_context.group_ids = [] def fill_context(self, request): # The request context stores itself in thread-local memory for logging. if authorization.AUTH_CONTEXT_ENV in request.environ: msg = ('Auth context already exists in the request ' 'environment; it will be used for authorization ' 'instead of creating a new one.') LOG.warning(msg) return kwargs = { 'authenticated': False, 'overwrite': True} request_context = context.RequestContext.from_environ( request.environ, **kwargs) request.environ[context.REQUEST_CONTEXT_ENV] = request_context # 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 request.environ.get(CONTEXT_ENV, {}).get('is_admin', False): request_context.is_admin = True auth_context = {} elif request.token_auth.has_user_token: # Keystone enforces policy on some values that other services # do not, and should not, use. This adds them in to the context. token = PROVIDERS.token_provider_api.validate_token( request.user_token ) self._keystone_specific_values(token, request_context) request_context.auth_token = request.user_token auth_context = request_context.to_policy_values() additional = { 'trust_id': request_context.trust_id, 'trustor_id': request_context.trustor_id, 'trustee_id': request_context.trustee_id, 'domain_id': request_context._domain_id, 'domain_name': request_context.domain_name, 'group_ids': request_context.group_ids, 'token': token } auth_context.update(additional) elif self._validate_trusted_issuer(request): auth_context = self._build_tokenless_auth_context(request) # NOTE(gyee): we are no longer using auth_context when formulating # the credentials for RBAC. Instead, we are using the (Oslo) # request context. So we'll need to set all the necessary # credential attributes in the request context here. token_attributes = frozenset(( 'user_id', 'project_id', 'domain_id', 'user_domain_id', 'project_domain_id', 'user_domain_name', 'project_domain_name', 'roles', 'is_admin', 'project_name', 'domain_name', 'system_scope', 'is_admin_project', 'service_user_id', 'service_user_name', 'service_project_id', 'service_project_name', 'service_user_domain_id' 'service_user_domain_name', 'service_project_domain_id', 'service_project_domain_name', 'service_roles')) for attr in token_attributes: if attr in auth_context: setattr(request_context, attr, auth_context[attr]) # NOTE(gyee): request_context.token_reference is always # expecting a 'token' key regardless. But in the case of X.509 # tokenless auth, we don't need a token. So setting it to None # should be suffice. request_context.token_reference = {'token': None} else: # There is either no auth token in the request or the certificate # issuer is not trusted. No auth context will be set. This # typically happens on an initial token request. return # set authenticated to flag to keystone that a token has been validated request_context.authenticated = True LOG.debug('RBAC: auth_context: %s', auth_context) request.environ[authorization.AUTH_CONTEXT_ENV] = auth_context @classmethod def factory(cls, global_config, **local_config): """Used for loading in middleware (holdover from paste.deploy).""" def _factory(app): conf = global_config.copy() conf.update(local_config) return cls(app, **local_config) return _factory