diff --git a/keystone/conf/security_compliance.py b/keystone/conf/security_compliance.py index 9946fcb9bf..ad7b761eb1 100644 --- a/keystone/conf/security_compliance.py +++ b/keystone/conf/security_compliance.py @@ -54,17 +54,6 @@ non-zero value. This feature depends on the `sql` backend for the `[identity] driver`. """)) -lockout_ignored_user_ids = cfg.ListOpt( - 'lockout_ignored_user_ids', - default=[], - help=utils.fmt(""" -Comma separated list of user IDs to be ignored when checking if a user should -be locked out based on failed authentication attempts. Thus, users in this list -can fail to authenticate for an unlimited amount of times and will never be -locked out. This feature will only be enabled if `[security_compliance] -lockout_failure_attempts` is set. -""")) - password_expires_days = cfg.IntOpt( 'password_expires_days', min=1, @@ -160,7 +149,6 @@ ALL_OPTS = [ disable_user_account_days_inactive, lockout_failure_attempts, lockout_duration, - lockout_ignored_user_ids, password_expires_days, password_expires_ignore_user_ids, unique_last_password_count, diff --git a/keystone/identity/backends/resource_options.py b/keystone/identity/backends/resource_options.py index 501e8fa724..bca9bfed53 100644 --- a/keystone/identity/backends/resource_options.py +++ b/keystone/identity/backends/resource_options.py @@ -27,6 +27,12 @@ IGNORE_PASSWORD_EXPIRY_OPT = ( option_name='ignore_password_expiry', validator=resource_options.boolean_validator, json_schema_validation=parameter_types.boolean)) +IGNORE_LOCKOUT_ATTEMPT_OPT = ( + resource_options.ResourceOption( + option_id='1002', + option_name='ignore_lockout_failure_attempts', + validator=resource_options.boolean_validator, + json_schema_validation=parameter_types.boolean)) # NOTE(notmorgan): wrap this in a function for testing purposes. @@ -35,6 +41,7 @@ def register_user_options(): for opt in [ IGNORE_CHANGE_PASSWORD_OPT, IGNORE_PASSWORD_EXPIRY_OPT, + IGNORE_LOCKOUT_ATTEMPT_OPT ]: USER_OPTIONS_REGISTRY.register_option(opt) diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index 54f8ebd796..70c8b32517 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -87,8 +87,11 @@ class Identity(base.IdentityDriverBase): :returns Boolean: True if the account is locked; False otherwise """ - if user_id in CONF.security_compliance.lockout_ignored_user_ids: + ignore_option = user_ref.get_resource_option( + options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_id) + if ignore_option and ignore_option.option_value is True: return False + attempts = user_ref.local_user.failed_auth_count or 0 max_attempts = CONF.security_compliance.lockout_failure_attempts lockout_duration = CONF.security_compliance.lockout_duration diff --git a/keystone/tests/unit/identity/test_backend_sql.py b/keystone/tests/unit/identity/test_backend_sql.py index 9ceeca4098..841a524dfd 100644 --- a/keystone/tests/unit/identity/test_backend_sql.py +++ b/keystone/tests/unit/identity/test_backend_sql.py @@ -533,10 +533,11 @@ class LockingOutUserTests(test_backend_sql.SqlTests): password=uuid.uuid4().hex) def test_lock_out_for_ignored_user(self): - # add the user id to the ignore list - self.config_fixture.config( - group='security_compliance', - lockout_ignored_user_ids=[self.user['id']]) + # mark the user as exempt from failed password attempts + # ignore user and reset password, password not expired + self.user['options'][iro.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name] = True + self.identity_api.update_user(self.user['id'], self.user) + # fail authentication repeatedly the max number of times self._fail_auth_repeatedly(self.user['id']) # authenticate with wrong password, account should not be locked diff --git a/keystone/tests/unit/test_v3_identity.py b/keystone/tests/unit/test_v3_identity.py index f73701a2ca..890fe64c49 100644 --- a/keystone/tests/unit/test_v3_identity.py +++ b/keystone/tests/unit/test_v3_identity.py @@ -1037,6 +1037,29 @@ class UserSelfServiceChangingPasswordsTestCase(ChangePasswordTestCase): self.token = self.get_request_token(reset_password, http_client.CREATED) + def test_lockout_exempt(self): + self.config_fixture.config(group='security_compliance', + lockout_failure_attempts=1) + + # create user + self.user_ref = unit.create_user(self.identity_api, + domain_id=self.domain['id']) + + # update the user, mark her as exempt from lockout + ignore_opt_name = options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name + self.user_ref['options'][ignore_opt_name] = True + self.identity_api.update_user(self.user_ref['id'], self.user_ref) + + # fail to auth, this should lockout the user, since we're allowed + # one failure, but we're exempt from lockout! + bad_password = uuid.uuid4().hex + self.token = self.get_request_token(bad_password, + http_client.UNAUTHORIZED) + + # attempt to authenticate with correct password + self.get_request_token(self.user_ref['password'], + expected_status=http_client.CREATED) + class PasswordValidationTestCase(ChangePasswordTestCase): diff --git a/keystone/tests/unit/test_validation.py b/keystone/tests/unit/test_validation.py index bc3188b52f..5e7f7ff61d 100644 --- a/keystone/tests/unit/test_validation.py +++ b/keystone/tests/unit/test_validation.py @@ -1839,6 +1839,42 @@ class UserValidationTestCase(unit.BaseTestCase): } self.update_user_validator.validate(request_to_validate) + def test_user_create_with_options_lockout_password(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + ro.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name: True + } + } + self.create_user_validator.validate(request_to_validate) + + def test_user_update_with_options_lockout_password(self): + request_to_validate = { + 'options': { + ro.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name: False + } + } + self.update_user_validator.validate(request_to_validate) + + def test_user_update_with_two_options(self): + request_to_validate = { + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: True, + ro.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name: True + } + } + self.update_user_validator.validate(request_to_validate) + + def test_user_create_with_two_options(self): + request_to_validate = { + 'name': self.user_name, + 'options': { + ro.IGNORE_CHANGE_PASSWORD_OPT.option_name: False, + ro.IGNORE_LOCKOUT_ATTEMPT_OPT.option_name: True + } + } + self.create_user_validator.validate(request_to_validate) + class GroupValidationTestCase(unit.BaseTestCase): """Test for V3 Group API validation."""