"Fossies" - the Fresh Open Source Software Archive

Member "keystone-19.0.0/keystone/identity/backends/sql.py" (14 Apr 2021, 19731 Bytes) of package /linux/misc/openstack/keystone-19.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 "sql.py" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 18.0.0_vs_19.0.0.

    1 # Copyright 2012 OpenStack Foundation
    2 #
    3 # Licensed under the Apache License, Version 2.0 (the "License"); you may
    4 # not use this file except in compliance with the License. You may obtain
    5 # a copy of the License at
    6 #
    7 #      http://www.apache.org/licenses/LICENSE-2.0
    8 #
    9 # Unless required by applicable law or agreed to in writing, software
   10 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
   11 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
   12 # License for the specific language governing permissions and limitations
   13 # under the License.
   14 
   15 import datetime
   16 
   17 from oslo_db import api as oslo_db_api
   18 import sqlalchemy
   19 
   20 from keystone.common import driver_hints
   21 from keystone.common import password_hashing
   22 from keystone.common import resource_options
   23 from keystone.common import sql
   24 import keystone.conf
   25 from keystone import exception
   26 from keystone.i18n import _
   27 from keystone.identity.backends import base
   28 from keystone.identity.backends import resource_options as options
   29 from keystone.identity.backends import sql_model as model
   30 
   31 
   32 CONF = keystone.conf.CONF
   33 
   34 
   35 def _stale_data_exception_checker(exc):
   36     return isinstance(exc, sqlalchemy.orm.exc.StaleDataError)
   37 
   38 
   39 class Identity(base.IdentityDriverBase):
   40     # NOTE(henry-nash): Override the __init__() method so as to take a
   41     # config parameter to enable sql to be used as a domain-specific driver.
   42     def __init__(self, conf=None):
   43         self.conf = conf
   44         super(Identity, self).__init__()
   45 
   46     @property
   47     def is_sql(self):
   48         return True
   49 
   50     def _check_password(self, password, user_ref):
   51         """Check the specified password against the data store.
   52 
   53         Note that we'll pass in the entire user_ref in case the subclass
   54         needs things like user_ref.get('name')
   55         For further justification, please see the follow up suggestion at
   56         https://blueprints.launchpad.net/keystone/+spec/sql-identiy-pam
   57 
   58         """
   59         return password_hashing.check_password(password, user_ref.password)
   60 
   61     # Identity interface
   62     def authenticate(self, user_id, password):
   63         with sql.session_for_read() as session:
   64             try:
   65                 user_ref = self._get_user(session, user_id)
   66                 user_dict = base.filter_user(user_ref.to_dict())
   67             except exception.UserNotFound:
   68                 raise AssertionError(_('Invalid user / password'))
   69         if self._is_account_locked(user_id, user_ref):
   70             raise exception.AccountLocked(user_id=user_id)
   71         elif not self._check_password(password, user_ref):
   72             self._record_failed_auth(user_id)
   73             raise AssertionError(_('Invalid user / password'))
   74         elif not user_ref.enabled:
   75             raise exception.UserDisabled(user_id=user_id)
   76         elif user_ref.password_is_expired:
   77             raise exception.PasswordExpired(user_id=user_id)
   78         # successful auth, reset failed count if present
   79         if user_ref.local_user.failed_auth_count:
   80             self._reset_failed_auth(user_id)
   81         return user_dict
   82 
   83     def _is_account_locked(self, user_id, user_ref):
   84         """Check if the user account is locked.
   85 
   86         Checks if the user account is locked based on the number of failed
   87         authentication attempts.
   88 
   89         :param user_id: The user ID
   90         :param user_ref: Reference to the user object
   91         :returns Boolean: True if the account is locked; False otherwise
   92 
   93         """
   94         ignore_option = user_ref.get_resource_option(
   95             options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_id)
   96         if ignore_option and ignore_option.option_value is True:
   97             return False
   98 
   99         attempts = user_ref.local_user.failed_auth_count or 0
  100         max_attempts = CONF.security_compliance.lockout_failure_attempts
  101         lockout_duration = CONF.security_compliance.lockout_duration
  102         if max_attempts and (attempts >= max_attempts):
  103             if not lockout_duration:
  104                 return True
  105             else:
  106                 delta = datetime.timedelta(seconds=lockout_duration)
  107                 last_failure = user_ref.local_user.failed_auth_at
  108                 if (last_failure + delta) > datetime.datetime.utcnow():
  109                     return True
  110                 else:
  111                     self._reset_failed_auth(user_id)
  112         return False
  113 
  114     def _record_failed_auth(self, user_id):
  115         with sql.session_for_write() as session:
  116             user_ref = session.query(model.User).get(user_id)
  117             if not user_ref.local_user.failed_auth_count:
  118                 user_ref.local_user.failed_auth_count = 0
  119             user_ref.local_user.failed_auth_count += 1
  120             user_ref.local_user.failed_auth_at = datetime.datetime.utcnow()
  121 
  122     def _reset_failed_auth(self, user_id):
  123         with sql.session_for_write() as session:
  124             user_ref = session.query(model.User).get(user_id)
  125             user_ref.local_user.failed_auth_count = 0
  126             user_ref.local_user.failed_auth_at = None
  127 
  128     # user crud
  129 
  130     @sql.handle_conflicts(conflict_type='user')
  131     def create_user(self, user_id, user):
  132         with sql.session_for_write() as session:
  133             user_ref = model.User.from_dict(user)
  134             if self._change_password_required(user_ref):
  135                 user_ref.password_ref.expires_at = datetime.datetime.utcnow()
  136             user_ref.created_at = datetime.datetime.utcnow()
  137             session.add(user_ref)
  138             # Set resource options passed on creation
  139             resource_options.resource_options_ref_to_mapper(
  140                 user_ref, model.UserOption)
  141             return base.filter_user(user_ref.to_dict())
  142 
  143     def _change_password_required(self, user):
  144         if not CONF.security_compliance.change_password_upon_first_use:
  145             return False
  146         ignore_option = user.get_resource_option(
  147             options.IGNORE_CHANGE_PASSWORD_OPT.option_id)
  148         return not (ignore_option and ignore_option.option_value is True)
  149 
  150     def _create_password_expires_query(self, session, query, hints):
  151         for filter_ in hints.filters:
  152             if 'password_expires_at' == filter_['name']:
  153                 # Filter on users who's password expires based on the operator
  154                 # specified in `filter_['comparator']`
  155                 query = query.filter(sqlalchemy.and_(
  156                     model.LocalUser.id == model.Password.local_user_id,
  157                     filter_['comparator'](model.Password.expires_at,
  158                                           filter_['value'])))
  159         # Removes the `password_expired_at` filters so there are no errors
  160         # if the call is filtered further. This is because the
  161         # `password_expires_at` value is not stored in the `User` table but
  162         # derived from the `Password` table's value `expires_at`.
  163         hints.filters = [x for x in hints.filters if x['name'] !=
  164                          'password_expires_at']
  165         return query, hints
  166 
  167     @staticmethod
  168     def _apply_limits_to_list(collection, hints):
  169         if not hints.limit:
  170             return collection
  171 
  172         return collection[:hints.limit['limit']]
  173 
  174     @driver_hints.truncated
  175     def list_users(self, hints):
  176         with sql.session_for_read() as session:
  177             query = session.query(model.User).outerjoin(model.LocalUser)
  178             query, hints = self._create_password_expires_query(session, query,
  179                                                                hints)
  180             user_refs = sql.filter_limit_query(model.User, query, hints)
  181             return [base.filter_user(x.to_dict()) for x in user_refs]
  182 
  183     def unset_default_project_id(self, project_id):
  184         with sql.session_for_write() as session:
  185             query = session.query(model.User)
  186             query = query.filter(model.User.default_project_id == project_id)
  187 
  188             for user in query:
  189                 user.default_project_id = None
  190 
  191     def _get_user(self, session, user_id):
  192         user_ref = session.query(model.User).get(user_id)
  193         if not user_ref:
  194             raise exception.UserNotFound(user_id=user_id)
  195         return user_ref
  196 
  197     def get_user(self, user_id):
  198         with sql.session_for_read() as session:
  199             return base.filter_user(
  200                 self._get_user(session, user_id).to_dict())
  201 
  202     def get_user_by_name(self, user_name, domain_id):
  203         with sql.session_for_read() as session:
  204             query = session.query(model.User).join(model.LocalUser)
  205             query = query.filter(sqlalchemy.and_(
  206                 model.LocalUser.name == user_name,
  207                 model.LocalUser.domain_id == domain_id))
  208             try:
  209                 user_ref = query.one()
  210             except sql.NotFound:
  211                 raise exception.UserNotFound(user_id=user_name)
  212             return base.filter_user(user_ref.to_dict())
  213 
  214     @sql.handle_conflicts(conflict_type='user')
  215     # Explicitly retry on StaleDataErrors, which can happen if two clients
  216     # update the same user's password and the second client has stale password
  217     # information.
  218     @oslo_db_api.wrap_db_retry(exception_checker=_stale_data_exception_checker)
  219     def update_user(self, user_id, user):
  220         with sql.session_for_write() as session:
  221             user_ref = self._get_user(session, user_id)
  222             old_user_dict = user_ref.to_dict()
  223             for k in user:
  224                 old_user_dict[k] = user[k]
  225             new_user = model.User.from_dict(old_user_dict)
  226             for attr in model.User.attributes:
  227                 if attr not in model.User.readonly_attributes:
  228                     setattr(user_ref, attr, getattr(new_user, attr))
  229             # Move the "_resource_options" attribute over to the real user_ref
  230             # so that resource_options.resource_options_ref_to_mapper can
  231             # handle the work.
  232             setattr(user_ref, '_resource_options',
  233                     getattr(new_user, '_resource_options', {}))
  234 
  235             # Move options into the proper attribute mapper construct
  236             resource_options.resource_options_ref_to_mapper(
  237                 user_ref, model.UserOption)
  238 
  239             if 'password' in user:
  240                 user_ref.password = user['password']
  241                 if self._change_password_required(user_ref):
  242                     expires_now = datetime.datetime.utcnow()
  243                     user_ref.password_ref.expires_at = expires_now
  244 
  245             user_ref.extra = new_user.extra
  246             return base.filter_user(
  247                 user_ref.to_dict(include_extra_dict=True))
  248 
  249     def _validate_password_history(self, password, user_ref):
  250         unique_cnt = CONF.security_compliance.unique_last_password_count
  251         # Validate the new password against the remaining passwords.
  252         if unique_cnt > 0:
  253             for password_ref in user_ref.local_user.passwords[-unique_cnt:]:
  254                 if password_hashing.check_password(
  255                         password, password_ref.password_hash):
  256                     raise exception.PasswordHistoryValidationError(
  257                         unique_count=unique_cnt)
  258 
  259     def change_password(self, user_id, new_password):
  260         with sql.session_for_write() as session:
  261             user_ref = session.query(model.User).get(user_id)
  262             lock_pw_opt = user_ref.get_resource_option(
  263                 options.LOCK_PASSWORD_OPT.option_id)
  264             if lock_pw_opt is not None and lock_pw_opt.option_value is True:
  265                 raise exception.PasswordSelfServiceDisabled()
  266             if user_ref.password_ref and user_ref.password_ref.self_service:
  267                 self._validate_minimum_password_age(user_ref)
  268             self._validate_password_history(new_password, user_ref)
  269             user_ref.password = new_password
  270             user_ref.password_ref.self_service = True
  271 
  272     def _validate_minimum_password_age(self, user_ref):
  273         min_age_days = CONF.security_compliance.minimum_password_age
  274         min_age = (user_ref.password_created_at +
  275                    datetime.timedelta(days=min_age_days))
  276         if datetime.datetime.utcnow() < min_age:
  277             days_left = (min_age - datetime.datetime.utcnow()).days
  278             raise exception.PasswordAgeValidationError(
  279                 min_age_days=min_age_days, days_left=days_left)
  280 
  281     def add_user_to_group(self, user_id, group_id):
  282         with sql.session_for_write() as session:
  283             self.get_group(group_id)
  284             self.get_user(user_id)
  285             query = session.query(model.UserGroupMembership)
  286             query = query.filter_by(user_id=user_id)
  287             query = query.filter_by(group_id=group_id)
  288             rv = query.first()
  289             if rv:
  290                 return
  291 
  292             session.add(model.UserGroupMembership(user_id=user_id,
  293                                                   group_id=group_id))
  294 
  295     def check_user_in_group(self, user_id, group_id):
  296         with sql.session_for_read() as session:
  297             self.get_group(group_id)
  298             self.get_user(user_id)
  299 
  300             # Note(knikolla): Check for normal group membership
  301             query = session.query(model.UserGroupMembership)
  302             query = query.filter_by(user_id=user_id)
  303             query = query.filter_by(group_id=group_id)
  304             if query.first():
  305                 return
  306 
  307             # Note(knikolla): Check for expiring group membership
  308             query = session.query(model.ExpiringUserGroupMembership)
  309             query = query.filter(
  310                 model.ExpiringUserGroupMembership.user_id == user_id)
  311             query = query.filter(
  312                 model.ExpiringUserGroupMembership.group_id == group_id)
  313             active = [q for q in query.all() if not q.expired]
  314             if active:
  315                 return
  316 
  317             raise exception.NotFound(_("User '%(user_id)s' not found in"
  318                                        " group '%(group_id)s'") %
  319                                      {'user_id': user_id,
  320                                       'group_id': group_id})
  321 
  322     def remove_user_from_group(self, user_id, group_id):
  323         # We don't check if user or group are still valid and let the remove
  324         # be tried anyway - in case this is some kind of clean-up operation
  325         with sql.session_for_write() as session:
  326             query = session.query(model.UserGroupMembership)
  327             query = query.filter_by(user_id=user_id)
  328             query = query.filter_by(group_id=group_id)
  329             membership_ref = query.first()
  330             if membership_ref is None:
  331                 # Check if the group and user exist to return descriptive
  332                 # exceptions.
  333                 self.get_group(group_id)
  334                 self.get_user(user_id)
  335                 raise exception.NotFound(_("User '%(user_id)s' not found in"
  336                                            " group '%(group_id)s'") %
  337                                          {'user_id': user_id,
  338                                           'group_id': group_id})
  339             session.delete(membership_ref)
  340 
  341     def list_groups_for_user(self, user_id, hints):
  342         def row_to_group_dict(row):
  343             group = row.group.to_dict()
  344             group['membership_expires_at'] = row.expires
  345             return group
  346 
  347         with sql.session_for_read() as session:
  348             self.get_user(user_id)
  349             query = session.query(model.Group).join(model.UserGroupMembership)
  350             query = query.filter(model.UserGroupMembership.user_id == user_id)
  351             query = sql.filter_limit_query(model.Group, query, hints)
  352             groups = [g.to_dict() for g in query]
  353 
  354             # Note(knikolla): We must use the ExpiringGroupMembership model
  355             # so that we can access the expired property.
  356             query = session.query(model.ExpiringUserGroupMembership)
  357             query = query.filter(
  358                 model.ExpiringUserGroupMembership.user_id == user_id)
  359             query = sql.filter_limit_query(
  360                 model.UserGroupMembership, query, hints)
  361             expiring_groups = [row_to_group_dict(r) for r in query.all()
  362                                if not r.expired]
  363 
  364             # Note(knikolla): I would have loved to be able to merge the two
  365             # queries together and use filter_limit_query on the union, but
  366             # I haven't found a generic way to express expiration in a SQL
  367             # query, therefore we have to apply the limits here again.
  368             return self._apply_limits_to_list(groups + expiring_groups, hints)
  369 
  370     def list_users_in_group(self, group_id, hints):
  371         with sql.session_for_read() as session:
  372             self.get_group(group_id)
  373             query = session.query(model.User).outerjoin(model.LocalUser)
  374             query = query.join(model.UserGroupMembership)
  375             query = query.filter(
  376                 model.UserGroupMembership.group_id == group_id)
  377             query, hints = self._create_password_expires_query(session, query,
  378                                                                hints)
  379             query = sql.filter_limit_query(model.User, query, hints)
  380             return [base.filter_user(u.to_dict()) for u in query]
  381 
  382     @oslo_db_api.wrap_db_retry(retry_on_deadlock=True)
  383     def delete_user(self, user_id):
  384         with sql.session_for_write() as session:
  385             ref = self._get_user(session, user_id)
  386 
  387             q = session.query(model.UserGroupMembership)
  388             q = q.filter_by(user_id=user_id)
  389             q.delete(False)
  390 
  391             session.delete(ref)
  392 
  393     # group crud
  394 
  395     @sql.handle_conflicts(conflict_type='group')
  396     def create_group(self, group_id, group):
  397         with sql.session_for_write() as session:
  398             ref = model.Group.from_dict(group)
  399             session.add(ref)
  400             return ref.to_dict()
  401 
  402     @driver_hints.truncated
  403     def list_groups(self, hints):
  404         with sql.session_for_read() as session:
  405             query = session.query(model.Group)
  406             refs = sql.filter_limit_query(model.Group, query, hints)
  407             return [ref.to_dict() for ref in refs]
  408 
  409     def _get_group(self, session, group_id):
  410         ref = session.query(model.Group).get(group_id)
  411         if not ref:
  412             raise exception.GroupNotFound(group_id=group_id)
  413         return ref
  414 
  415     def get_group(self, group_id):
  416         with sql.session_for_read() as session:
  417             return self._get_group(session, group_id).to_dict()
  418 
  419     def get_group_by_name(self, group_name, domain_id):
  420         with sql.session_for_read() as session:
  421             query = session.query(model.Group)
  422             query = query.filter_by(name=group_name)
  423             query = query.filter_by(domain_id=domain_id)
  424             try:
  425                 group_ref = query.one()
  426             except sql.NotFound:
  427                 raise exception.GroupNotFound(group_id=group_name)
  428             return group_ref.to_dict()
  429 
  430     @sql.handle_conflicts(conflict_type='group')
  431     def update_group(self, group_id, group):
  432         with sql.session_for_write() as session:
  433             ref = self._get_group(session, group_id)
  434             old_dict = ref.to_dict()
  435             for k in group:
  436                 old_dict[k] = group[k]
  437             new_group = model.Group.from_dict(old_dict)
  438             for attr in model.Group.attributes:
  439                 if attr != 'id':
  440                     setattr(ref, attr, getattr(new_group, attr))
  441             ref.extra = new_group.extra
  442             return ref.to_dict()
  443 
  444     def delete_group(self, group_id):
  445         with sql.session_for_write() as session:
  446             ref = self._get_group(session, group_id)
  447 
  448             q = session.query(model.UserGroupMembership)
  449             q = q.filter_by(group_id=group_id)
  450             q.delete(False)
  451 
  452             session.delete(ref)