"Fossies" - the Fresh Open Source Software Archive

Member "keystone-17.0.0/keystone/server/flask/request_processing/middleware/auth_context.py" (13 May 2020, 20039 Bytes) of package /linux/misc/openstack/keystone-17.0.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "auth_context.py" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 16.0.1_vs_17.0.0.

    1 # Licensed under the Apache License, Version 2.0 (the "License"); you may
    2 # not use this file except in compliance with the License. You may obtain
    3 # a copy of the License at
    4 #
    5 #      http://www.apache.org/licenses/LICENSE-2.0
    6 #
    7 # Unless required by applicable law or agreed to in writing, software
    8 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
    9 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
   10 # License for the specific language governing permissions and limitations
   11 # under the License.
   12 
   13 
   14 import functools
   15 import itertools
   16 import re
   17 import wsgiref.util
   18 
   19 import http.client
   20 from keystonemiddleware import auth_token
   21 import oslo_i18n
   22 from oslo_log import log
   23 from oslo_serialization import jsonutils
   24 import webob.dec
   25 import webob.exc
   26 
   27 from keystone.common import authorization
   28 from keystone.common import context
   29 from keystone.common import provider_api
   30 from keystone.common import render_token
   31 from keystone.common import tokenless_auth
   32 from keystone.common import utils
   33 import keystone.conf
   34 from keystone import exception
   35 from keystone.federation import constants as federation_constants
   36 from keystone.federation import utils as federation_utils
   37 from keystone.i18n import _
   38 from keystone.models import token_model
   39 
   40 CONF = keystone.conf.CONF
   41 LOG = log.getLogger(__name__)
   42 PROVIDERS = provider_api.ProviderAPIs
   43 
   44 # Environment variable used to pass the request context
   45 CONTEXT_ENV = 'openstack.context'
   46 
   47 __all__ = ('AuthContextMiddleware',)
   48 
   49 
   50 CONF = keystone.conf.CONF
   51 LOG = log.getLogger(__name__)
   52 
   53 
   54 JSON_ENCODE_CONTENT_TYPES = set(['application/json',
   55                                  'application/json-home'])
   56 
   57 # minimum access rules support
   58 ACCESS_RULES_MIN_VERSION = token_model.ACCESS_RULES_MIN_VERSION
   59 
   60 
   61 def best_match_language(req):
   62     """Determine the best available locale.
   63 
   64     This returns best available locale based on the Accept-Language HTTP
   65     header passed in the request.
   66     """
   67     if not req.accept_language:
   68         return None
   69     return req.accept_language.best_match(
   70         oslo_i18n.get_available_languages('keystone'))
   71 
   72 
   73 def base_url(context):
   74     url = CONF['public_endpoint']
   75 
   76     if url:
   77         substitutions = dict(
   78             itertools.chain(CONF.items(), CONF.eventlet_server.items()))
   79 
   80         url = url % substitutions
   81     elif 'environment' in context:
   82         url = wsgiref.util.application_uri(context['environment'])
   83         # remove version from the URL as it may be part of SCRIPT_NAME but
   84         # it should not be part of base URL
   85         url = re.sub(r'/v(3|(2\.0))/*$', '', url)
   86 
   87         # now remove the standard port
   88         url = utils.remove_standard_port(url)
   89     else:
   90         # if we don't have enough information to come up with a base URL,
   91         # then fall back to localhost. This should never happen in
   92         # production environment.
   93         url = 'http://localhost:%d' % CONF.eventlet_server.public_port
   94 
   95     return url.rstrip('/')
   96 
   97 
   98 def middleware_exceptions(method):
   99 
  100     @functools.wraps(method)
  101     def _inner(self, request):
  102         try:
  103             return method(self, request)
  104         except exception.Error as e:
  105             LOG.warning(e)
  106             return render_exception(e, request=request,
  107                                     user_locale=best_match_language(request))
  108         except TypeError as e:
  109             LOG.exception(e)
  110             return render_exception(exception.ValidationError(e),
  111                                     request=request,
  112                                     user_locale=best_match_language(request))
  113         except Exception as e:
  114             LOG.exception(e)
  115             return render_exception(exception.UnexpectedError(exception=e),
  116                                     request=request,
  117                                     user_locale=best_match_language(request))
  118 
  119     return _inner
  120 
  121 
  122 def render_response(body=None, status=None, headers=None, method=None):
  123     """Form a WSGI response."""
  124     if headers is None:
  125         headers = []
  126     else:
  127         headers = list(headers)
  128     headers.append(('Vary', 'X-Auth-Token'))
  129 
  130     if body is None:
  131         body = b''
  132         status = status or (http.client.NO_CONTENT,
  133                             http.client.responses[http.client.NO_CONTENT])
  134     else:
  135         content_types = [v for h, v in headers if h == 'Content-Type']
  136         if content_types:
  137             content_type = content_types[0]
  138         else:
  139             content_type = None
  140 
  141         if content_type is None or content_type in JSON_ENCODE_CONTENT_TYPES:
  142             body = jsonutils.dump_as_bytes(body, cls=utils.SmarterEncoder)
  143             if content_type is None:
  144                 headers.append(('Content-Type', 'application/json'))
  145         status = status or (http.client.OK,
  146                             http.client.responses[http.client.OK])
  147 
  148     # NOTE(davechen): `mod_wsgi` follows the standards from pep-3333 and
  149     # requires the value in response header to be binary type(str) on python2,
  150     # unicode based string(str) on python3, or else keystone will not work
  151     # under apache with `mod_wsgi`.
  152     # keystone needs to check the data type of each header and convert the
  153     # type if needed.
  154     # see bug:
  155     # https://bugs.launchpad.net/keystone/+bug/1528981
  156     # see pep-3333:
  157     # https://www.python.org/dev/peps/pep-3333/#a-note-on-string-types
  158     # see source from mod_wsgi:
  159     # https://github.com/GrahamDumpleton/mod_wsgi(methods:
  160     # wsgi_convert_headers_to_bytes(...), wsgi_convert_string_to_bytes(...)
  161     # and wsgi_validate_header_value(...)).
  162     def _convert_to_str(headers):
  163         str_headers = []
  164         for header in headers:
  165             str_header = []
  166             for value in header:
  167                 if not isinstance(value, str):
  168                     str_header.append(str(value))
  169                 else:
  170                     str_header.append(value)
  171             # convert the list to the immutable tuple to build the headers.
  172             # header's key/value will be guaranteed to be str type.
  173             str_headers.append(tuple(str_header))
  174         return str_headers
  175 
  176     headers = _convert_to_str(headers)
  177 
  178     resp = webob.Response(body=body,
  179                           status='%d %s' % status,
  180                           headerlist=headers,
  181                           charset='utf-8')
  182 
  183     if method and method.upper() == 'HEAD':
  184         # NOTE(morganfainberg): HEAD requests should return the same status
  185         # as a GET request and same headers (including content-type and
  186         # content-length). The webob.Response object automatically changes
  187         # content-length (and other headers) if the body is set to b''. Capture
  188         # all headers and reset them on the response object after clearing the
  189         # body. The body can only be set to a binary-type (not TextType or
  190         # NoneType), so b'' is used here and should be compatible with
  191         # both py2x and py3x.
  192         stored_headers = resp.headers.copy()
  193         resp.body = b''
  194         for header, value in stored_headers.items():
  195             resp.headers[header] = value
  196 
  197     return resp
  198 
  199 
  200 def render_exception(error, context=None, request=None, user_locale=None):
  201     """Form a WSGI response based on the current error."""
  202     error_message = error.args[0]
  203     message = oslo_i18n.translate(error_message, desired_locale=user_locale)
  204     if message is error_message:
  205         # translate() didn't do anything because it wasn't a Message,
  206         # convert to a string.
  207         message = str(message)
  208 
  209     body = {'error': {
  210         'code': error.code,
  211         'title': error.title,
  212         'message': message,
  213     }}
  214     headers = []
  215     if isinstance(error, exception.AuthPluginException):
  216         body['error']['identity'] = error.authentication
  217     elif isinstance(error, exception.Unauthorized):
  218         # NOTE(gyee): we only care about the request environment in the
  219         # context. Also, its OK to pass the environment as it is read-only in
  220         # base_url()
  221         local_context = {}
  222         if request:
  223             local_context = {'environment': request.environ}
  224         elif context and 'environment' in context:
  225             local_context = {'environment': context['environment']}
  226         url = base_url(local_context)
  227 
  228         headers.append(('WWW-Authenticate', 'Keystone uri="%s"' % url))
  229     return render_response(status=(error.code, error.title),
  230                            body=body,
  231                            headers=headers)
  232 
  233 
  234 class AuthContextMiddleware(provider_api.ProviderAPIMixin,
  235                             auth_token.BaseAuthProtocol):
  236     """Build the authentication context from the request auth token."""
  237 
  238     kwargs_to_fetch_token = True
  239 
  240     def __init__(self, app):
  241         super(AuthContextMiddleware, self).__init__(app, log=LOG,
  242                                                     service_type='identity')
  243         self.token = None
  244 
  245     def fetch_token(self, token, **kwargs):
  246         try:
  247             self.token = self.token_provider_api.validate_token(
  248                 token, access_rules_support=ACCESS_RULES_MIN_VERSION)
  249             return render_token.render_token_response_from_model(self.token)
  250         except exception.TokenNotFound:
  251             raise auth_token.InvalidToken(_('Could not find token'))
  252 
  253     def _build_tokenless_auth_context(self, request):
  254         """Build the authentication context.
  255 
  256         The context is built from the attributes provided in the env,
  257         such as certificate and scope attributes.
  258         """
  259         tokenless_helper = tokenless_auth.TokenlessAuthHelper(request.environ)
  260 
  261         (domain_id, project_id, trust_ref, unscoped, system) = (
  262             tokenless_helper.get_scope())
  263         user_ref = tokenless_helper.get_mapped_user(
  264             project_id,
  265             domain_id)
  266 
  267         # NOTE(gyee): if it is an ephemeral user, the
  268         # given X.509 SSL client cert does not need to map to
  269         # an existing user.
  270         if user_ref['type'] == federation_utils.UserType.EPHEMERAL:
  271             auth_context = {}
  272             auth_context['group_ids'] = user_ref['group_ids']
  273             auth_context[federation_constants.IDENTITY_PROVIDER] = (
  274                 user_ref[federation_constants.IDENTITY_PROVIDER])
  275             auth_context[federation_constants.PROTOCOL] = (
  276                 user_ref[federation_constants.PROTOCOL])
  277             if domain_id and project_id:
  278                 msg = _('Scoping to both domain and project is not allowed')
  279                 raise ValueError(msg)
  280             if domain_id:
  281                 auth_context['domain_id'] = domain_id
  282             if project_id:
  283                 auth_context['project_id'] = project_id
  284             auth_context['roles'] = user_ref['roles']
  285         else:
  286             # it's the local user, so token data is needed.
  287             token = token_model.TokenModel()
  288             token.user_id = user_ref['id']
  289             token.methods = [CONF.tokenless_auth.protocol]
  290             token.domain_id = domain_id
  291             token.project_id = project_id
  292 
  293             auth_context = {'user_id': user_ref['id']}
  294             auth_context['is_delegated_auth'] = False
  295             if domain_id:
  296                 auth_context['domain_id'] = domain_id
  297             if project_id:
  298                 auth_context['project_id'] = project_id
  299             auth_context['roles'] = [role['name'] for role in token.roles]
  300         return auth_context
  301 
  302     def _validate_trusted_issuer(self, request):
  303         """To further filter the certificates that are trusted.
  304 
  305         If the config option 'trusted_issuer' is absent or does
  306         not contain the trusted issuer DN, no certificates
  307         will be allowed in tokenless authorization.
  308 
  309         :param env: The env contains the client issuer's attributes
  310         :type env: dict
  311         :returns: True if client_issuer is trusted; otherwise False
  312         """
  313         if not CONF.tokenless_auth.trusted_issuer:
  314             return False
  315 
  316         issuer = request.environ.get(CONF.tokenless_auth.issuer_attribute)
  317         if not issuer:
  318             msg = ('Cannot find client issuer in env by the '
  319                    'issuer attribute - %s.')
  320             LOG.info(msg, CONF.tokenless_auth.issuer_attribute)
  321             return False
  322 
  323         if issuer in CONF.tokenless_auth.trusted_issuer:
  324             return True
  325 
  326         msg = ('The client issuer %(client_issuer)s does not match with '
  327                'the trusted issuer %(trusted_issuer)s')
  328         LOG.info(
  329             msg, {'client_issuer': issuer,
  330                   'trusted_issuer': CONF.tokenless_auth.trusted_issuer})
  331 
  332         return False
  333 
  334     @middleware_exceptions
  335     def process_request(self, request):
  336         context_env = request.environ.get(CONTEXT_ENV, {})
  337 
  338         # NOTE(notmorgan): This code is merged over from the admin token
  339         # middleware and now emits the security warning when the
  340         # conf.admin_token value is set.
  341         token = request.headers.get(authorization.AUTH_TOKEN_HEADER)
  342         if CONF.admin_token and (token == CONF.admin_token):
  343             context_env['is_admin'] = True
  344             LOG.warning(
  345                 "The use of the '[DEFAULT] admin_token' configuration"
  346                 "option presents a significant security risk and should "
  347                 "not be set. This option is deprecated in favor of using "
  348                 "'keystone-manage bootstrap' and will be removed in a "
  349                 "future release.")
  350             request.environ[CONTEXT_ENV] = context_env
  351 
  352         if not context_env.get('is_admin', False):
  353             resp = super(AuthContextMiddleware, self).process_request(request)
  354 
  355             if resp:
  356                 return resp
  357             if request.token_auth.user is not None:
  358                 request.set_user_headers(request.token_auth.user)
  359 
  360         # NOTE(jamielennox): function is split so testing can check errors from
  361         # fill_context. There is no actual reason for fill_context to raise
  362         # errors rather than return a resp, simply that this is what happened
  363         # before refactoring and it was easier to port. This can be fixed up
  364         # and the middleware_exceptions helper removed.
  365         self.fill_context(request)
  366 
  367     def _keystone_specific_values(self, token, request_context):
  368         request_context.token_reference = (
  369             render_token.render_token_response_from_model(token)
  370         )
  371         if token.domain_scoped:
  372             # Domain scoped tokens should never have is_admin_project set
  373             # Even if KSA defaults it otherwise.  The two mechanisms are
  374             # parallel; only ione or the other should be used for access.
  375             request_context.is_admin_project = False
  376             request_context.domain_id = token.domain_id
  377             request_context.domain_name = token.domain['name']
  378         if token.oauth_scoped:
  379             request_context.is_delegated_auth = True
  380             request_context.oauth_consumer_id = (
  381                 token.access_token['consumer_id']
  382             )
  383             request_context.oauth_access_token_id = token.access_token_id
  384         if token.trust_scoped:
  385             request_context.is_delegated_auth = True
  386             request_context.trust_id = token.trust_id
  387         if token.is_federated:
  388             request_context.group_ids = []
  389             for group in token.federated_groups:
  390                 request_context.group_ids.append(group['id'])
  391         else:
  392             request_context.group_ids = []
  393 
  394     def fill_context(self, request):
  395         # The request context stores itself in thread-local memory for logging.
  396 
  397         if authorization.AUTH_CONTEXT_ENV in request.environ:
  398             msg = ('Auth context already exists in the request '
  399                    'environment; it will be used for authorization '
  400                    'instead of creating a new one.')
  401             LOG.warning(msg)
  402             return
  403 
  404         kwargs = {
  405             'authenticated': False,
  406             'overwrite': True}
  407         request_context = context.RequestContext.from_environ(
  408             request.environ, **kwargs)
  409         request.environ[context.REQUEST_CONTEXT_ENV] = request_context
  410 
  411         # NOTE(gyee): token takes precedence over SSL client certificates.
  412         # This will preserve backward compatibility with the existing
  413         # behavior. Tokenless authorization with X.509 SSL client
  414         # certificate is effectively disabled if no trusted issuers are
  415         # provided.
  416 
  417         if request.environ.get(CONTEXT_ENV, {}).get('is_admin', False):
  418             request_context.is_admin = True
  419             auth_context = {}
  420 
  421         elif request.token_auth.has_user_token:
  422             # Keystone enforces policy on some values that other services
  423             # do not, and should not, use.  This adds them in to the context.
  424             if not self.token:
  425                 self.token = PROVIDERS.token_provider_api.validate_token(
  426                     request.user_token,
  427                     access_rules_support=request.headers.get(
  428                         authorization.ACCESS_RULES_HEADER)
  429                 )
  430             self._keystone_specific_values(self.token, request_context)
  431             request_context.auth_token = request.user_token
  432             auth_context = request_context.to_policy_values()
  433             additional = {
  434                 'trust_id': request_context.trust_id,
  435                 'trustor_id': request_context.trustor_id,
  436                 'trustee_id': request_context.trustee_id,
  437                 'domain_id': request_context._domain_id,
  438                 'domain_name': request_context.domain_name,
  439                 'group_ids': request_context.group_ids,
  440                 'token': self.token
  441             }
  442             auth_context.update(additional)
  443 
  444         elif self._validate_trusted_issuer(request):
  445             auth_context = self._build_tokenless_auth_context(request)
  446             # NOTE(gyee): we are no longer using auth_context when formulating
  447             # the credentials for RBAC. Instead, we are using the (Oslo)
  448             # request context. So we'll need to set all the necessary
  449             # credential attributes in the request context here.
  450             token_attributes = frozenset((
  451                 'user_id', 'project_id',
  452                 'domain_id', 'user_domain_id',
  453                 'project_domain_id', 'user_domain_name',
  454                 'project_domain_name', 'roles', 'is_admin',
  455                 'project_name', 'domain_name', 'system_scope',
  456                 'is_admin_project', 'service_user_id',
  457                 'service_user_name', 'service_project_id',
  458                 'service_project_name', 'service_user_domain_id'
  459                 'service_user_domain_name', 'service_project_domain_id',
  460                 'service_project_domain_name', 'service_roles'))
  461             for attr in token_attributes:
  462                 if attr in auth_context:
  463                     setattr(request_context, attr, auth_context[attr])
  464             # NOTE(gyee): request_context.token_reference is always
  465             # expecting a 'token' key regardless. But in the case of X.509
  466             # tokenless auth, we don't need a token. So setting it to None
  467             # should be suffice.
  468             request_context.token_reference = {'token': None}
  469         else:
  470             # There is either no auth token in the request or the certificate
  471             # issuer is not trusted. No auth context will be set. This
  472             # typically happens on an initial token request.
  473             return
  474 
  475         # set authenticated to flag to keystone that a token has been validated
  476         request_context.authenticated = True
  477 
  478         LOG.debug('RBAC: auth_context: %s', auth_context)
  479         request.environ[authorization.AUTH_CONTEXT_ENV] = auth_context
  480 
  481     @classmethod
  482     def factory(cls, global_config, **local_config):
  483         """Used for loading in middleware (holdover from paste.deploy)."""
  484         def _factory(app):
  485             conf = global_config.copy()
  486             conf.update(local_config)
  487             return cls(app, **local_config)
  488         return _factory