"Fossies" - the Fresh Open Source Software Archive

Member "keystone-17.0.0/keystone/auth/core.py" (13 May 2020, 23262 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 "core.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 from functools import partial
   14 
   15 from oslo_log import log
   16 import stevedore
   17 
   18 from keystone.common import driver_hints
   19 from keystone.common import provider_api
   20 from keystone.common import utils
   21 import keystone.conf
   22 from keystone import exception
   23 from keystone.i18n import _
   24 from keystone.identity.backends import resource_options as ro
   25 
   26 
   27 LOG = log.getLogger(__name__)
   28 CONF = keystone.conf.CONF
   29 PROVIDERS = provider_api.ProviderAPIs
   30 
   31 # registry of authentication methods
   32 AUTH_METHODS = {}
   33 AUTH_PLUGINS_LOADED = False
   34 
   35 
   36 def _get_auth_driver_manager(namespace, plugin_name):
   37     return stevedore.DriverManager(namespace, plugin_name, invoke_on_load=True)
   38 
   39 
   40 def load_auth_method(method):
   41     plugin_name = CONF.auth.get(method) or 'default'
   42     namespace = 'keystone.auth.%s' % method
   43     driver_manager = _get_auth_driver_manager(namespace, plugin_name)
   44     return driver_manager.driver
   45 
   46 
   47 def load_auth_methods():
   48     global AUTH_PLUGINS_LOADED
   49 
   50     if AUTH_PLUGINS_LOADED:
   51         # Only try and load methods a single time.
   52         return
   53     # config.setup_authentication should be idempotent, call it to ensure we
   54     # have setup all the appropriate configuration options we may need.
   55     keystone.conf.auth.setup_authentication()
   56     for plugin in set(CONF.auth.methods):
   57         AUTH_METHODS[plugin] = load_auth_method(plugin)
   58     AUTH_PLUGINS_LOADED = True
   59 
   60 
   61 def get_auth_method(method_name):
   62     global AUTH_METHODS
   63     if method_name not in AUTH_METHODS:
   64         raise exception.AuthMethodNotSupported()
   65     return AUTH_METHODS[method_name]
   66 
   67 
   68 class AuthContext(dict):
   69     """Retrofitting auth_context to reconcile identity attributes.
   70 
   71     The identity attributes must not have conflicting values among the
   72     auth plug-ins. The only exception is `expires_at`, which is set to its
   73     earliest value.
   74 
   75     """
   76 
   77     # identity attributes need to be reconciled among the auth plugins
   78     IDENTITY_ATTRIBUTES = frozenset(['user_id', 'project_id',
   79                                      'access_token_id', 'domain_id',
   80                                      'expires_at'])
   81 
   82     def __setitem__(self, key, val):
   83         """Override __setitem__ to prevent conflicting values."""
   84         if key in self.IDENTITY_ATTRIBUTES and key in self:
   85             existing_val = self[key]
   86             if key == 'expires_at':
   87                 # special treatment for 'expires_at', we are going to take
   88                 # the earliest expiration instead.
   89                 if existing_val != val:
   90                     LOG.info('"expires_at" has conflicting values '
   91                              '%(existing)s and %(new)s.  Will use the '
   92                              'earliest value.',
   93                              {'existing': existing_val, 'new': val})
   94                 if existing_val is None or val is None:
   95                     val = existing_val or val
   96                 else:
   97                     val = min(existing_val, val)
   98             elif existing_val != val:
   99                 msg = _('Unable to reconcile identity attribute %(attribute)s '
  100                         'as it has conflicting values %(new)s and %(old)s') % (
  101                             {'attribute': key,
  102                              'new': val,
  103                              'old': existing_val})
  104                 raise exception.Unauthorized(msg)
  105         return super(AuthContext, self).__setitem__(key, val)
  106 
  107     def update(self, E=None, **F):
  108         """Override update to prevent conflicting values."""
  109         # NOTE(notmorgan): This will not be nearly as performant as the
  110         # use of the built-in "update" method on the dict, however, the
  111         # volume of data being changed here is very minimal in most cases
  112         # and should not see a significant impact by iterating instead of
  113         # explicit setting of values.
  114         update_dicts = (E or {}, F or {})
  115         for d in update_dicts:
  116             for key, val in d.items():
  117                 self[key] = val
  118 
  119 
  120 class AuthInfo(provider_api.ProviderAPIMixin, object):
  121     """Encapsulation of "auth" request."""
  122 
  123     @staticmethod
  124     def create(auth=None, scope_only=False):
  125         auth_info = AuthInfo(auth=auth)
  126         auth_info._validate_and_normalize_auth_data(scope_only)
  127         return auth_info
  128 
  129     def __init__(self, auth=None):
  130         self.auth = auth
  131         self._scope_data = (None, None, None, None, None)
  132         # self._scope_data is
  133         # (domain_id, project_id, trust_ref, unscoped, system)
  134         # project scope: (None, project_id, None, None, None)
  135         # domain scope: (domain_id, None, None, None, None)
  136         # trust scope: (None, None, trust_ref, None, None)
  137         # unscoped: (None, None, None, 'unscoped', None)
  138         # system: (None, None, None, None, 'all')
  139 
  140     def _assert_project_is_enabled(self, project_ref):
  141         # ensure the project is enabled
  142         try:
  143             PROVIDERS.resource_api.assert_project_enabled(
  144                 project_id=project_ref['id'],
  145                 project=project_ref)
  146         except AssertionError as e:
  147             LOG.warning(e)
  148             raise exception.Unauthorized from e
  149 
  150     def _assert_domain_is_enabled(self, domain_ref):
  151         try:
  152             PROVIDERS.resource_api.assert_domain_enabled(
  153                 domain_id=domain_ref['id'],
  154                 domain=domain_ref)
  155         except AssertionError as e:
  156             LOG.warning(e)
  157             raise exception.Unauthorized from e
  158 
  159     def _lookup_domain(self, domain_info):
  160         domain_id = domain_info.get('id')
  161         domain_name = domain_info.get('name')
  162         try:
  163             if domain_name:
  164                 if (CONF.resource.domain_name_url_safe == 'strict' and
  165                         utils.is_not_url_safe(domain_name)):
  166                     msg = 'Domain name cannot contain reserved characters.'
  167                     tr_msg = _('Domain name cannot contain reserved '
  168                                'characters.')
  169                     LOG.warning(msg)
  170                     raise exception.Unauthorized(message=tr_msg)
  171                 domain_ref = PROVIDERS.resource_api.get_domain_by_name(
  172                     domain_name)
  173             else:
  174                 domain_ref = PROVIDERS.resource_api.get_domain(domain_id)
  175         except exception.DomainNotFound as e:
  176             LOG.warning(e)
  177             raise exception.Unauthorized(e)
  178         self._assert_domain_is_enabled(domain_ref)
  179         return domain_ref
  180 
  181     def _lookup_project(self, project_info):
  182         project_id = project_info.get('id')
  183         project_name = project_info.get('name')
  184         try:
  185             if project_name:
  186                 if (CONF.resource.project_name_url_safe == 'strict' and
  187                         utils.is_not_url_safe(project_name)):
  188                     msg = 'Project name cannot contain reserved characters.'
  189                     tr_msg = _('Project name cannot contain reserved '
  190                                'characters.')
  191                     LOG.warning(msg)
  192                     raise exception.Unauthorized(message=tr_msg)
  193                 if 'domain' not in project_info:
  194                     raise exception.ValidationError(attribute='domain',
  195                                                     target='project')
  196                 domain_ref = self._lookup_domain(project_info['domain'])
  197                 project_ref = PROVIDERS.resource_api.get_project_by_name(
  198                     project_name, domain_ref['id'])
  199             else:
  200                 project_ref = PROVIDERS.resource_api.get_project(project_id)
  201                 domain_id = project_ref['domain_id']
  202                 if not domain_id:
  203                     raise exception.ProjectNotFound(project_id=project_id)
  204                 # NOTE(morganfainberg): The _lookup_domain method will raise
  205                 # exception.Unauthorized if the domain isn't found or is
  206                 # disabled.
  207                 self._lookup_domain({'id': domain_id})
  208         except exception.ProjectNotFound as e:
  209             LOG.warning(e)
  210             raise exception.Unauthorized(e)
  211         self._assert_project_is_enabled(project_ref)
  212         return project_ref
  213 
  214     def _lookup_trust(self, trust_info):
  215         trust_id = trust_info.get('id')
  216         if not trust_id:
  217             raise exception.ValidationError(attribute='trust_id',
  218                                             target='trust')
  219         trust = PROVIDERS.trust_api.get_trust(trust_id)
  220         return trust
  221 
  222     def _lookup_app_cred(self, app_cred_info):
  223         app_cred_id = app_cred_info.get('id')
  224         if app_cred_id:
  225             get_app_cred = partial(
  226                 PROVIDERS.application_credential_api.get_application_credential
  227             )
  228             return get_app_cred(app_cred_id)
  229         name = app_cred_info.get('name')
  230         if not name:
  231             raise exception.ValidationError(attribute='name or ID',
  232                                             target='application credential')
  233         user = app_cred_info.get('user')
  234         if not user:
  235             raise exception.ValidationError(attribute='user',
  236                                             target='application credential')
  237         user_id = user.get('id')
  238         if not user_id:
  239             if 'domain' not in user:
  240                 raise exception.ValidationError(attribute='domain',
  241                                                 target='user')
  242             domain_ref = self._lookup_domain(user['domain'])
  243             user_id = PROVIDERS.identity_api.get_user_by_name(
  244                 user['name'], domain_ref['id'])['id']
  245         hints = driver_hints.Hints()
  246         hints.add_filter('name', name)
  247         app_cred_api = PROVIDERS.application_credential_api
  248         app_creds = app_cred_api.list_application_credentials(
  249             user_id, hints)
  250         if len(app_creds) != 1:
  251             message = "Could not find application credential: %s" % name
  252             tr_message = _("Could not find application credential: %s") % name
  253             LOG.warning(message)
  254             raise exception.Unauthorized(tr_message)
  255         return app_creds[0]
  256 
  257     def _set_scope_from_app_cred(self, app_cred_info):
  258         app_cred_ref = self._lookup_app_cred(app_cred_info)
  259         self._scope_data = (None, app_cred_ref['project_id'], None, None, None)
  260         return
  261 
  262     def _validate_and_normalize_scope_data(self):
  263         """Validate and normalize scope data."""
  264         if 'identity' in self.auth:
  265             if 'application_credential' in self.auth['identity']['methods']:
  266                 # Application credentials can't choose their own scope
  267                 if 'scope' in self.auth:
  268                     detail = "Application credentials cannot request a scope."
  269                     raise exception.ApplicationCredentialAuthError(
  270                         detail=detail)
  271                 self._set_scope_from_app_cred(
  272                     self.auth['identity']['application_credential'])
  273                 return
  274         if 'scope' not in self.auth:
  275             return
  276         if sum(['project' in self.auth['scope'],
  277                 'domain' in self.auth['scope'],
  278                 'unscoped' in self.auth['scope'],
  279                 'system' in self.auth['scope'],
  280                 'OS-TRUST:trust' in self.auth['scope']]) != 1:
  281             msg = 'system, project, domain, OS-TRUST:trust or unscoped'
  282             raise exception.ValidationError(attribute=msg, target='scope')
  283         if 'system' in self.auth['scope']:
  284             self._scope_data = (None, None, None, None, 'all')
  285             return
  286         if 'unscoped' in self.auth['scope']:
  287             self._scope_data = (None, None, None, 'unscoped', None)
  288             return
  289         if 'project' in self.auth['scope']:
  290             project_ref = self._lookup_project(self.auth['scope']['project'])
  291             self._scope_data = (None, project_ref['id'], None, None, None)
  292         elif 'domain' in self.auth['scope']:
  293             domain_ref = self._lookup_domain(self.auth['scope']['domain'])
  294             self._scope_data = (domain_ref['id'], None, None, None, None)
  295         elif 'OS-TRUST:trust' in self.auth['scope']:
  296             trust_ref = self._lookup_trust(
  297                 self.auth['scope']['OS-TRUST:trust'])
  298             # TODO(ayoung): when trusts support domains, fill in domain data
  299             if trust_ref.get('project_id') is not None:
  300                 project_ref = self._lookup_project(
  301                     {'id': trust_ref['project_id']})
  302                 self._scope_data = (
  303                     None, project_ref['id'], trust_ref, None, None
  304                 )
  305 
  306             else:
  307                 self._scope_data = (None, None, trust_ref, None, None)
  308 
  309     def _validate_auth_methods(self):
  310         # make sure all the method data/payload are provided
  311         for method_name in self.get_method_names():
  312             if method_name not in self.auth['identity']:
  313                 raise exception.ValidationError(attribute=method_name,
  314                                                 target='identity')
  315 
  316         # make sure auth method is supported
  317         for method_name in self.get_method_names():
  318             if method_name not in AUTH_METHODS:
  319                 raise exception.AuthMethodNotSupported()
  320 
  321     def _validate_and_normalize_auth_data(self, scope_only=False):
  322         """Make sure "auth" is valid.
  323 
  324         :param scope_only: If it is True, auth methods will not be
  325                            validated but only the scope data.
  326         :type scope_only: boolean
  327         """
  328         # make sure "auth" exist
  329         if not self.auth:
  330             raise exception.ValidationError(attribute='auth',
  331                                             target='request body')
  332 
  333         # NOTE(chioleong): Tokenless auth does not provide auth methods,
  334         # we only care about using this method to validate the scope
  335         # information. Therefore, validating the auth methods here is
  336         # insignificant and we can skip it when scope_only is set to
  337         # true.
  338         if scope_only is False:
  339             self._validate_auth_methods()
  340         self._validate_and_normalize_scope_data()
  341 
  342     def get_method_names(self):
  343         """Return the identity method names.
  344 
  345         :returns: list of auth method names
  346 
  347         """
  348         # Sanitizes methods received in request's body
  349         # Filters out duplicates, while keeping elements' order.
  350         method_names = []
  351         for method in self.auth['identity']['methods']:
  352             if method not in method_names:
  353                 method_names.append(method)
  354         return method_names
  355 
  356     def get_method_data(self, method):
  357         """Get the auth method payload.
  358 
  359         :returns: auth method payload
  360 
  361         """
  362         if method not in self.auth['identity']['methods']:
  363             raise exception.ValidationError(attribute=method,
  364                                             target='identity')
  365         return self.auth['identity'][method]
  366 
  367     def get_scope(self):
  368         """Get scope information.
  369 
  370         Verify and return the scoping information.
  371 
  372         :returns: (domain_id, project_id, trust_ref, unscoped, system).
  373                    If scope to a project, (None, project_id, None, None, None)
  374                    will be returned.
  375                    If scoped to a domain, (domain_id, None, None, None, None)
  376                    will be returned.
  377                    If scoped to a trust,
  378                    (None, project_id, trust_ref, None, None),
  379                    Will be returned, where the project_id comes from the
  380                    trust definition.
  381                    If unscoped, (None, None, None, 'unscoped', None) will be
  382                    returned.
  383                    If system_scoped, (None, None, None, None, 'all') will be
  384                    returned.
  385 
  386         """
  387         return self._scope_data
  388 
  389     def set_scope(self, domain_id=None, project_id=None, trust=None,
  390                   unscoped=None, system=None):
  391         """Set scope information."""
  392         if domain_id and project_id:
  393             msg = _('Scoping to both domain and project is not allowed')
  394             raise ValueError(msg)
  395         if domain_id and trust:
  396             msg = _('Scoping to both domain and trust is not allowed')
  397             raise ValueError(msg)
  398         if project_id and trust:
  399             msg = _('Scoping to both project and trust is not allowed')
  400             raise ValueError(msg)
  401         if system and project_id:
  402             msg = _('Scoping to both project and system is not allowed')
  403             raise ValueError(msg)
  404         if system and domain_id:
  405             msg = _('Scoping to both domain and system is not allowed')
  406             raise ValueError(msg)
  407         self._scope_data = (domain_id, project_id, trust, unscoped, system)
  408 
  409 
  410 class UserMFARulesValidator(provider_api.ProviderAPIMixin, object):
  411     """Helper object that can validate the MFA Rules."""
  412 
  413     @classmethod
  414     def _auth_methods(cls):
  415         if AUTH_PLUGINS_LOADED:
  416             return set(AUTH_METHODS.keys())
  417         raise RuntimeError(_('Auth Method Plugins are not loaded.'))
  418 
  419     @classmethod
  420     def check_auth_methods_against_rules(cls, user_id, auth_methods):
  421         """Validate the MFA rules against the successful auth methods.
  422 
  423         :param user_id: The user's ID (uuid).
  424         :type user_id: str
  425         :param auth_methods: List of methods that were used for auth
  426         :type auth_methods: set
  427         :returns: Boolean, ``True`` means rules match and auth may proceed,
  428                   ``False`` means rules do not match.
  429         """
  430         user_ref = PROVIDERS.identity_api.get_user(user_id)
  431         mfa_rules = user_ref['options'].get(ro.MFA_RULES_OPT.option_name, [])
  432         mfa_rules_enabled = user_ref['options'].get(
  433             ro.MFA_ENABLED_OPT.option_name, True)
  434         rules = cls._parse_rule_structure(mfa_rules, user_ref['id'])
  435 
  436         if not rules or not mfa_rules_enabled:
  437             # return quickly if the rules are disabled for the user or not set
  438             LOG.debug('MFA Rules not processed for user `%(user_id)s`. '
  439                       'Rule list: `%(rules)s` (Enabled: `%(enabled)s`).',
  440                       {'user_id': user_id,
  441                        'rules': mfa_rules,
  442                        'enabled': mfa_rules_enabled})
  443             return True
  444 
  445         for r in rules:
  446             # NOTE(notmorgan): We only check against the actually loaded
  447             # auth methods meaning that the keystone administrator may
  448             # disable an auth method, and a rule will still pass making it
  449             # impossible to accidently lock-out a subset of users with a
  450             # bad keystone.conf
  451             r_set = set(r).intersection(cls._auth_methods())
  452             if set(auth_methods).issuperset(r_set):
  453                 # Rule Matches no need to continue, return here.
  454                 LOG.debug('Auth methods for user `%(user_id)s`, `%(methods)s` '
  455                           'matched MFA rule `%(rule)s`. Loaded '
  456                           'auth_methods: `%(loaded)s`',
  457                           {'user_id': user_id,
  458                            'rule': list(r_set),
  459                            'methods': auth_methods,
  460                            'loaded': cls._auth_methods()})
  461                 return True
  462 
  463         LOG.debug('Auth methods for user `%(user_id)s`, `%(methods)s` did not '
  464                   'match a MFA rule in `%(rules)s`.',
  465                   {'user_id': user_id,
  466                    'methods': auth_methods,
  467                    'rules': rules})
  468         return False
  469 
  470     @staticmethod
  471     def _parse_rule_structure(rules, user_id):
  472         """Validate and parse the rule data structure.
  473 
  474         Rule sets must be in the form of list of lists. The lists may not
  475         have duplicates and must not be empty. The top-level list may be empty
  476         indicating that no rules exist.
  477 
  478         :param rules: The list of rules from the user_ref
  479         :type rules: list
  480         :param user_id: the user_id, used for logging purposes
  481         :type user_id: str
  482         :returns: list of list, duplicates are stripped
  483         """
  484         # NOTE(notmorgan): Most of this is done at the API request validation
  485         # and in the storage layer, it makes sense to also validate here and
  486         # ensure the data returned from the DB is sane, This will not raise
  487         # any exceptions, but just produce a usable set of data for rules
  488         # processing.
  489         rule_set = []
  490         if not isinstance(rules, list):
  491             LOG.error('Corrupt rule data structure for user %(user_id)s, '
  492                       'no rules loaded.',
  493                       {'user_id': user_id})
  494             # Corrupt Data means no rules. Auth success > MFA rules in this
  495             # case.
  496             return rule_set
  497         elif not rules:
  498             # Exit early, nothing to do here.
  499             return rule_set
  500 
  501         for r_list in rules:
  502             if not isinstance(r_list, list):
  503                 # Rule was not a list, it is invalid, drop the rule from
  504                 # being considered.
  505                 LOG.info('Ignoring Rule %(type)r; rule must be a list of '
  506                          'strings.',
  507                          {'type': type(r_list)})
  508                 continue
  509 
  510             if r_list:
  511                 # No empty rules are allowed.
  512                 _ok_rule = True
  513                 for item in r_list:
  514                     if not isinstance(item, str):
  515                         # Rules may only contain strings for method names
  516                         # Reject a rule with non-string values
  517                         LOG.info('Ignoring Rule %(rule)r; rule contains '
  518                                  'non-string values.',
  519                                  {'rule': r_list})
  520                         # Rule is known to be bad, drop it from consideration.
  521                         _ok_rule = False
  522                         break
  523                 # NOTE(notmorgan): No FOR/ELSE used here! Though it could be
  524                 # done and avoid the use of _ok_rule. This is a note for
  525                 # future developers to avoid using for/else and as an example
  526                 # of how to implement it that is readable and maintainable.
  527                 if _ok_rule:
  528                     # Unique the r_list and cast back to a list and then append
  529                     # as we know the rule is ok (matches our requirements).
  530                     # This is outside the for loop, as the for loop is
  531                     # only used to validate the elements in the list. The
  532                     # This de-dupe should never be needed, but we are being
  533                     # extra careful at all levels of validation for the MFA
  534                     # rules.
  535                     r_list = list(set(r_list))
  536                     rule_set.append(r_list)
  537 
  538         return rule_set